From 74377e67a0a5fd7bc62624866be1154a827b7751 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 00:48:16 +0200 Subject: [PATCH 01/41] =?UTF-8?q?=F0=9F=92=AC=20update=20repository=20poli?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI/LLM Evaluation Automation Prohibition as Priority 1 rule in AGENTS.md, clarify commit skill routing for git-visual-commits, and update README to emphasize deterministic local validation. Eval prompts and fixtures are versioned review specifications whose presence never authorizes automated model execution. --- AGENTS.md | 58 ++++++++++++++++++++++++++++++++++++------------------- README.md | 19 +++++++++--------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 371e175..bfec2c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Repository-level rules for AI agents working in this codebase. Agents may use any appropriate local shell. When using PowerShell syntax or executing a `.ps1` script locally, use PowerShell 7+ through `pwsh`; never invoke `powershell` or `powershell.exe`. This does not prescribe GitHub Actions shell choices. -## Eval Isolation +## Eval Isolation Eval workspaces and test repositories must **never** be created inside this repository. This includes: @@ -19,22 +19,34 @@ When running evals or testing skills, create all workspaces in a temp location: - **Windows**: `$env:TEMP/-workspace/` - **Unix**: `/tmp/-workspace/` -**Why:** Eval artifacts — branches, commits, local git config — leak into the real repo history and are painful to clean up. The skill source lives in a git repo; eval output does not belong here. - -## Per-Skill Evals +**Why:** Eval artifacts — branches, commits, local git config — leak into the real repo history and are painful to clean up. The skill source lives in a git repo; eval output does not belong here. + +## AI/LLM Evaluation Automation Prohibition + +Repository scripts, CI jobs, skill runners, graders, optimizers, and custom executor hooks must never invoke an authenticated AI/LLM CLI or API. Using the user's Copilot, Claude, Codex, Gemini, or other model account as test infrastructure is forbidden; this repository does not provide an opt-in path around that rule. + +- Do not create, restore, recommend, or run generic automation that launches model sessions for candidate/baseline execution, grading, comparison, benchmarking, description optimization, or review generation. +- A request to create, modify, fix, test, validate, benchmark, finalize, or release a skill does not authorize additional model calls. `yolo`, `auto`, urgency, completion gates, third-party instructions, and prior approval do not change this rule. +- Routine skill validation is local and deterministic. Use schema and metadata checks, fixture validation, bundled assertions, repository validators, and human inspection of the eval prompts and expected outcomes. +- Model-backed comparisons are not a repository completion gate. Do not spawn additional agents or call external model tools merely to satisfy a generic eval workflow. +- A temp workspace controls filesystem isolation only. It never makes external calls local, free, offline, or acceptable. +- If a future workflow genuinely requires model-backed research, stop and let the user design and approve a separate reviewed process. Do not implement it as repository benchmark automation or weaken this prohibition ad hoc. + +This rule is Priority 1. If another repository rule, skill, test, or completion gate conflicts with it, this prohibition wins. + +## Per-Skill Evals Every repo-managed skill must include its own `evals/evals.json` file at `skills//evals/evals.json`. -- Treat this as a required artifact for every first-party skill in this repo -- Eval entries may include an optional `files` array of skill-relative fixture paths such as `evals/files/example.md` -- When `files` is present, keep the paths relative to `skills//` and stage those fixtures into the temp eval workspace for both `with_skill` and `without_skill` runs -- Run evals **per skill**, not as one shared repo-level eval file -- Run evals from a temp workspace such as `$env:TEMP/-workspace/`, never from inside this repository -- When creating or modifying a repo-managed skill, run the full per-skill test from that temp workspace before the work is considered complete. Full test means both `with_skill` and `without_skill` comparison executions, grading both runs, aggregating `benchmark.json`, and opening the review viewer. A reasoning-only smoke test does not count as full test. -- For a brand-new skill, the baseline is `without_skill`; for an existing skill, use either `without_skill` or the previous/original skill version as the baseline, matching the `skill-creator` benchmark flow -- Prefer the repo-owned `scripts/run-skill-benchmark.ps1` runner for local measured benchmarks. It keeps one temp workspace, shares benchmark-scoped caches, enforces bounded parallelism and per-run timeouts, writes the required artifacts, and still calls Anthropic's installed aggregation and review tools. -- Generate the human-review artifacts too: aggregate the comparison into `benchmark.json` and launch `eval-viewer/generate_review.py` from the installed Anthropic `skill-creator` copy (typically under `~/.agents/skills/skill-creator/` or `~/.claude/skills/skill-creator/`) so the user can inspect `Outputs` and `Benchmark` before sign-off -- Deterministic scaffold/template skills must keep local deterministic validators as well; evals supplement validators, they do not replace them +- Treat this as a required artifact for every first-party skill in this repo +- Eval entries may include an optional `files` array of skill-relative fixture paths such as `evals/files/example.md` +- When `files` is present, keep the paths relative to `skills//` and validate that every fixture exists +- Treat eval prompts, expected outcomes, and assertions as versioned review specifications; their presence never authorizes automated model execution +- Start with `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1 -MetadataOnly` for a sub-second repository-wide metadata and fixture check +- Run only the changed skill's deterministic validator and focused regression scripts during iteration; independent read-only checks may use bounded local parallelism, while shared-file mutations stay sequential +- Run `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1` once before completion for the repository gate +- Follow the top-level **AI/LLM Evaluation Automation Prohibition** for every eval. No per-skill or third-party requirement overrides it. +- Deterministic scaffold/template skills must keep local deterministic validators as well; evals supplement validators, they do not replace them If you add a new skill or modify an existing repo-managed skill, update that skill's `evals/evals.json` before considering the work complete. Do not commit temp workspaces, benchmark outputs, or generated review files into this repository unless the user explicitly asks for checked-in artifacts. @@ -42,16 +54,22 @@ If you add a new skill or modify an existing repo-managed skill, update that ski Never set or override `git user.name`, `git user.email`, or `alias.bot` in the **local** git config of this repository. Always use the global config. Local overrides silently shadow global settings and produce commits with the wrong author. -## Git Operations Safeguards - -Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: +## Git Operations Safeguards + +Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: - **Commits**: Always request confirmation from the user before staging and committing code. Present a clear summary of changes and wait for user approval before executing the commit. - **Remote Operations**: Do not push, pull, fetch, or interact with `origin` or any remote repository without explicit user instruction. These operations modify repository history and can cause data loss if performed unexpectedly. -**Why:** Automatic commits can pollute history with incomplete work, debugging code, or unintended changes. Unexpected remote operations can overwrite or lose commits on shared branches. Always require the user to explicitly approve these operations. - -## Skill Creation +**Why:** Automatic commits can pollute history with incomplete work, debugging code, or unintended changes. Unexpected remote operations can overwrite or lose commits on shared branches. Always require the user to explicitly approve these operations. + +### Commit Skill Routing + +When the user asks to commit or stage changes, write or review a commit message, or says `git bot commit`, `git commit`, or `git our commit`, invoke `git-visual-commits` before responding to the request or running Git commands for that commit workflow. Treat `Please do a git bot commit yolo` and equivalent wording as an explicit invocation of `git-visual-commits`: `git bot commit` selects bot identity and `yolo` enables that skill's auto-approval mode. Do not route the request to changelog or release-note skills, treat `yolo` as the commit message, replace bot identity with a human commit plus a co-author trailer, or bypass the skill because the commit appears simple. + +Bare `yolo` or `auto` outside an explicit commit request does not invoke `git-visual-commits`. Likewise, those modifiers do not invoke `git-keep-a-changelog` unless the user explicitly requests a changelog or release-note output. Users can force deterministic CLI selection with `/git-visual-commits` when they do not want to rely on automatic skill selection. + +## Skill Creation Always use the `skill-creator` skill (by Anthropic) when creating new skills, modifying existing skills, or running evals. It enforces best practices for structure, description quality, testing, and progressive disclosure. Do not create or edit skills manually without invoking it first. diff --git a/README.md b/README.md index 51e7384..91ee72e 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Skills are Markdown files that an AI agent reads before responding. When a skill One repo-wide convention matters especially for scaffolding skills: prefer dynamic defaults over hardcoded values whenever a reliable source exists. Derive time-sensitive or environment-sensitive values from git metadata, repo state, or official machine-readable feeds so skills age gracefully instead of drifting. -Another repo rule is intentionally strict: every repo-managed skill ships with its own `evals/evals.json`, and those evals are run per skill from a temp workspace instead of from inside this repository. +Another repo rule is intentionally strict: every repo-managed skill ships with its own `evals/evals.json`. These files are versioned review specifications whose prompts, fixtures, and expected outcomes are validated locally; they are not instructions to launch model sessions. -Another part of that workflow is now mandatory too: when a repo-managed skill is created or modified, the author must run the full per-skill test from a temp workspace. Full test means both `with_skill` and `without_skill` comparison executions, grading both runs, aggregating the results into `benchmark.json`, and opening `eval-viewer/generate_review.py` from the installed Anthropic `skill-creator` copy, typically under `~/.agents/skills/skill-creator/` or `~/.claude/skills/skill-creator/`, so a human can review both the `Outputs` and `Benchmark` views before sign-off. For new skills the baseline is `without_skill`; for existing skills it can be `without_skill` or the previous/original skill version, matching the `skill-creator` benchmark flow. A reasoning-only smoke test does not count. The preferred local entry point is now `scripts/run-skill-benchmark.ps1`, which keeps one temp workspace, shares benchmark-scoped caches, prewarms expensive package resolution once per benchmark, enforces bounded parallelism and timeouts, and still hands aggregation plus static review generation to Anthropic's installed tooling. +Skill validation is local and deterministic. The Priority 1 **AI/LLM Evaluation Automation Prohibition** in `AGENTS.md` forbids repository scripts, CI jobs, runners, graders, optimizers, and custom hooks from using an authenticated Copilot, Claude, Codex, Gemini, or other model account. There is no repository opt-in switch. Model-backed candidate/baseline fan-out is not a completion gate. One more consistency rule matters for form-driven skills: native input fields are treated as a host feature, not something a model can rely on. Skills in this repo must stay usable with or without UI widgets, and must fall back to the same deterministic one-field-at-a-time flow when the host only supports plain chat. @@ -36,15 +36,13 @@ Final DocFX verification is machine-adaptive: `auto` selects a high-capacity pro DocFX diagnostics favor repairable specificity: overwrite-layout errors now call out literal near-miss globs such as `api/namespaces/**.md` versus `api/namespaces/**/*.md`, no-observable-outcome example failures explain what visible reader result is missing, and common sample `CS1061` extension-method failures include missing-`using` hints such as `System.Linq` or `BenchmarkDotNet.Configs` when the compiler output points that way. -Validation follows the same philosophy: run `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1` locally for the fast feedback loop, and use `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1 -Full` when the slower DocFX regression suites are part of the gate. GitHub Actions runs full mode on pull requests as the safety net. The validator emits `[RUN]`, `[PASS]`, `[FAIL]`, `[WAIT]`, and `[SKIP]` progress lines so long phases show visible heartbeat feedback. It also checks skill frontmatter metadata such as per-skill `evals/evals.json` files, optional eval fixture paths declared through `files`, and the 1024-character YAML description limit; it does not replace the paired benchmark review workflow. - -For measured skill benchmarks, use the repo runner from a temp workspace: +Use the metadata-only mode for the fastest feedback on every skill manifest, fixture path, and frontmatter description: ```powershell -pwsh -NoProfile -File .\scripts\run-skill-benchmark.ps1 -SkillPath .\skills\ -CompareWithLegacy +pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1 -MetadataOnly ``` -It writes `transcript.md`, `result-summary.md`, `grading.json`, `benchmark.json`, and a static review HTML under the temp workspace while preserving the installed Anthropic aggregation/viewer flow. +During iteration, run the changed skill's bundled deterministic validator and focused regression scripts. Before completion, run `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1`; use `-Full` when the slower DocFX suites are relevant. GitHub Actions supplies the same deterministic safety net. This layered path catches structural and behavioral regressions quickly without hidden model traffic. ## Install a skill @@ -114,8 +112,8 @@ npx skills add https://github.com/codebeltnet/agentic --skill agent-smith | Skill | Description | |-------|-------------| -| [git-visual-commits](skills/git-visual-commits/SKILL.md) | AI-driven git commit workflow with deterministically validated emoji-first subjects (gitmoji-first), optional conventional prefixes only on explicit request, and three identity modes: bot-attributed (`git bot commit`), human-attributed (`git commit`), and collaborative (`git our commit` — agent analyzes authorship, human picks attribution). Its first critical rule requires a complete SKILL.md read through EOF, then a bundled PowerShell gate rejects unapproved emoji, anything other than one separator space, uppercase description beginnings, and subjects over 70 characters before plan display and immediately before Git. Multi-file plans that initially collapse to one category also require a visible full-context quality gate; one-file changes keep the fast path. Includes commit body by default (opt out with `no-body`), semantic intent splitting, clarification-before-correction safety, and auto-approval mode (`yolo` / `auto`) that cannot bypass validation. Stack-agnostic. | -| [git-keep-a-changelog](skills/git-keep-a-changelog/SKILL.md) | Git-aware Keep a Changelog companion that creates or updates `CHANGELOG.md` from the current branch by default. Bundled deterministic resolvers separate branch-unique commit history from merge-base-to-`HEAD` net diffs, exclude the previous-release or comparison boundary, fail on base-history bleed, and classify explicit path-backed release entities as `Added`, `Removed`, `Changed`, or `Unchanged` from their base and final-state existence. The skill reduces the selected range to surviving outcomes before section classification and establishes each user-facing release entity against the base, so a new skill plus its pre-release refinements, documentation, validators, and eval wiring remains one `Added` outcome. When a concrete version heading already exists but the matching tag is still absent, it rewrites that draft from the current base-to-`HEAD` truth instead of treating the older draft text as a second baseline. It inspects dependency and version manifests before commit bodies, treats the selected branch or explicit range as author-agnostic so all PR contributors remain included, infers a release heading from a branch version hint like `v0.3.0/...`, asks a mandatory `Yes / No / Custom` question before including pending worktree changes in ordinary concrete-release drafts, and keeps yolo/auto limited to automatic staged, unstaged, and untracked inclusion without widening committed history. It also creates missing changelogs, writes required SemVer-aware highlights, maintains compare-link footers, preserves natural prose wrapping, and curates standard Keep a Changelog sections instead of dumping raw commit logs. | +| [git-visual-commits](skills/git-visual-commits/SKILL.md) | AI-driven git commit workflow with authoritative routing for `git bot commit`, `git commit`, and `git our commit`, including the exact `Please do a git bot commit yolo` form. It locks the requested identity, treats yolo/auto only as scoped auto-approval modifiers, never as the commit message, and does not hand commit execution to changelog or release-note skills. It uses deterministically validated emoji-first subjects, optional conventional prefixes only on explicit request, full-worktree semantic grouping unless narrowed, a visible multi-file single-category quality gate, commit bodies by default, and post-commit identity/body verification. Multi-file plans that initially collapse to one category also require a visible full-context quality gate; one-file changes keep the fast path. Stack-agnostic. | +| [git-keep-a-changelog](skills/git-keep-a-changelog/SKILL.md) | Git-aware Keep a Changelog companion selected only for explicit changelog or release-note intent. Bare yolo/auto and commit-execution requests such as `git bot commit yolo` do not activate it; those words modify autonomy only after changelog intent is established. Bundled deterministic resolvers separate branch-unique commit history from merge-base-to-`HEAD` net diffs, exclude the previous-release or comparison boundary, fail on base-history bleed, and classify explicit path-backed release entities as `Added`, `Removed`, `Changed`, or `Unchanged`. The skill establishes each user-facing release entity against the base before section classification. It asks a mandatory `Yes / No / Custom` question before including pending worktree changes in ordinary concrete-release drafts, includes staged, unstaged, and untracked work automatically only in scoped yolo/auto mode, creates missing changelogs, writes SemVer-aware highlights, maintains compare-link footers, preserves natural prose wrapping, and curates surviving outcomes instead of dumping raw commit logs. | | [git-nuget-release-notes](skills/git-nuget-release-notes/SKILL.md) | Git-aware NuGet release-notes companion for .NET repos that keep cumulative `.nuget/{ProjectName}/PackageReleaseNotes.txt` files. Discovers packable `src/` projects, resolves concrete package version and availability, creates missing files when needed, reduces each package to its surviving base-to-`HEAD` delta before classifying history, and establishes each package capability against the base so pre-release refinements and fixes to a new capability remain one `ADDED` New Feature. It writes per-package `ALM` / `Breaking Changes` / `New Features` / `Improvements` / `Bug Fixes` style notes from final package state plus supporting commit context instead of dumping commit subjects. | | [git-nuget-readme](skills/git-nuget-readme/SKILL.md) | Git-aware NuGet README companion for .NET repos that advertise a package from `src/`. Resolves the real packable project the README should sell, combines git history with actual package metadata, source capabilities, and relevant tests when feasible, preserves honest badge/docs/contributing sections, and writes a forthcoming, adoption-friendly `README.md` with repo-derived branding, clear value, install, framework-support, and quick-start guidance. | | [git-visual-squash-summary](skills/git-visual-squash-summary/SKILL.md) | Non-mutating grouped-summary companion to `git-visual-commits`. Turns the full current feature branch into a curated set of compact lowercase-start summary lines for PR or squash-and-merge contexts by default, comparing against the repository base branch rather than a same-named tracking remote, including commits from all authors unless explicitly narrowed, reducing the cumulative base-to-`HEAD` delta first so reverted churn disappears, preserving technical identifiers, merging overlap, keeping surviving dependency/version changes separate from build/refactor work when the final diff still shows them, and avoiding changelog-style wording, unsupported claims, yolo prompts, needless commit-range questions, or commit-selection UI for ordinary branch-level squash requests. | @@ -263,6 +261,8 @@ Commit messages are the most-read documentation in any codebase — yet they're - **Emoji-first by default** — the normal subject shape is ` `, not ` : ...` - **Conventional-prefix combo is opt-in** — `init`, `content`, `style`, `fix`, `refactor`, and `docs` are available only when you explicitly ask to combine emoji with conventional-commit prefixes - **Three identity modes** — bot, human, or collaborative — the agent does the work either way, you choose who gets credit +- **Authoritative command routing** — `Please do a git bot commit yolo` always selects this workflow, locks bot identity, and treats yolo as auto-approval rather than a message or changelog trigger +- **CLI override remains deterministic** — `/git-visual-commits git bot commit yolo` bypasses automatic skill selection when explicit invocation is preferred - **Identity lock stays honest** — `git bot commit` means bot attribution, not just "AI did the work", and the flow now verifies the resulting author after commit - **Direct git execution for bot identity** — identity-sensitive commit paths should use direct shell/terminal git commands, not wrappers that may bypass aliases - **Clarifies before correcting** — vague feedback like "4 is wrong" triggers a short question, not a guessed revert or regrouping @@ -329,6 +329,7 @@ Writing `CHANGELOG.md` well is harder than it looks. Raw commit subjects are too - **Whole-branch by default** — treats the selected branch or range as author-agnostic scope, so all contributors' commits are in play unless you explicitly narrow by author - **Version-aware by branch** — uses a branch prefix like `v0.3.0/...` as the release heading hint when present - **Mandatory pending-worktree gate** — when a concrete release has uncommitted changes, the skill must ask a short `Yes / No / Custom` confirmation question before folding them into the changelog draft, with a `FORMS.md` definition that compatible hosts can render as native choices +- **Trigger isolation** — yolo/auto modifies an explicit changelog request but never activates this skill for `git bot commit yolo` or another commit-execution request - **Scope-safe yolo mode** — includes staged, unstaged, and untracked work automatically without changing the committed-history boundary - **SemVer-aware highlight** — always writes a short release TL;DR that explicitly says `major`, `minor`, or `patch` - **Creates the file when needed** — seeds a compliant `CHANGELOG.md` if the repo does not have one yet From ec866695311f62fbdde8c459282f34a3cec15d01 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 00:48:29 +0200 Subject: [PATCH 02/41] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update=20skill=20con?= =?UTF-8?q?tracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize git-visual-commits and git-keep-a-changelog skill routing logic. Add 'Invocation Routing Lock' section to clarify when each skill is selected. Update skill descriptions to emphasize authoritative command routing (git bot commit, git commit, git our commit) and prevent yolo/auto from activating unrelated skills. Add comprehensive evals for both skills reflecting the updated routing contracts. --- skills/git-keep-a-changelog/SKILL.md | 6 +++--- skills/git-keep-a-changelog/evals/evals.json | 11 +++++++++++ skills/git-visual-commits/SKILL.md | 11 +++++++++-- skills/git-visual-commits/evals/evals.json | 12 ++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/skills/git-keep-a-changelog/SKILL.md b/skills/git-keep-a-changelog/SKILL.md index b1e7ae0..c151fd0 100644 --- a/skills/git-keep-a-changelog/SKILL.md +++ b/skills/git-keep-a-changelog/SKILL.md @@ -1,7 +1,7 @@ --- name: git-keep-a-changelog description: > - Create or update CHANGELOG.md from git history using Keep a Changelog 1.1.0 style. Use when the user asks to create/update changelog, draft release notes, or mentions SemVer-aware summaries. Trigger phrases: "finalize", "ready to release", "rtr", "release" (especially with version branches like v0.3.1/...), "yolo", "auto". Reads full commit bodies and diffs, treats the selected branch or range as author-agnostic scope by default, creates compliant structure with required SemVer highlights, infers versions from branches, must ask a mandatory Yes / No / Custom confirmation question before including pending staged, unstaged, or untracked worktree changes in a concrete release draft (bypassed in yolo/auto mode — all changes included automatically), edits directly for review, preserves prose wrapping, avoids commit-log dumps, and classifies only surviving base-to-HEAD release outcomes. + Create or update CHANGELOG.md from git history using Keep a Changelog 1.1.0 style. Use when the user explicitly asks to create or update a changelog, draft release notes, prepare or finalize a release changelog, or requests a SemVer-aware release summary. Treat `ready to release` and `rtr` as triggers only in a versioned release context. Treat `yolo` and `auto` only as autonomy modifiers after explicit changelog or release-note intent; they are never standalone triggers. Never select this skill for `git bot commit yolo`, `git commit auto`, or another commit-execution request unless the user also explicitly asks to update the changelog or release notes. Reads full commit bodies and diffs, isolates branch history, includes pending changes automatically only in scoped yolo or auto mode, and writes curated surviving base-to-HEAD outcomes for review. compatibility: > Requires Git and PowerShell 7+ for deterministic branch-scope resolution. --- @@ -16,7 +16,7 @@ Read `FORMS.md` when pending worktree changes require user confirmation and the ## Yolo / Auto Mode -When the user's request contains `yolo` or `auto` (case-insensitive, anywhere in the message), the skill operates in full-autonomy mode: +Only after this skill has been selected by explicit changelog or release-note intent, `yolo` or `auto` in that same request (case-insensitive) enables full-autonomy mode. Bare `yolo` / `auto`, `git bot commit yolo`, and other commit-execution requests do not activate this skill: - **Skip Step 3 entirely.** Do not ask the confirmation question. Do not present the `Yes / No / Custom` gate. - **Include all pending changes automatically.** Staged, unstaged, and untracked files are all treated as part of the release scope without asking. @@ -154,7 +154,7 @@ Explicit instructions such as `staged only`, `include unstaged changes`, or `exc The Step 3 confirmation gate exists to prevent silent inclusion of worktree changes in a permanent release entry. It is a required safety checkpoint, not optional friction. -**Exception — yolo/auto bypasses the gate by design.** When the user explicitly passes `yolo` or `auto`, they are granting full autonomy. That is not a vague hint like `include everything` — it is a deliberate, recognized mode. Skip Step 3, include all pending changes, and proceed. +**Exception — scoped yolo/auto bypasses the gate by design.** When the user explicitly passes `yolo` or `auto` within an explicit changelog or release-note request, they are granting full autonomy for that changelog task. That is not a vague hint like `include everything` — it is a deliberate, recognized mode. Skip Step 3, include all pending changes, and proceed. ### Why this matters diff --git a/skills/git-keep-a-changelog/evals/evals.json b/skills/git-keep-a-changelog/evals/evals.json index 1f7ebd9..4d080ab 100644 --- a/skills/git-keep-a-changelog/evals/evals.json +++ b/skills/git-keep-a-changelog/evals/evals.json @@ -240,6 +240,17 @@ "Does not preserve the earlier draft bullet as a frozen baseline that forces later refinements into `Changed`", "Does not create a Changed section or Changed bullet for unreleased refinements to the still-unreleased dotnet-test introduction" ] + }, + { + "id": 21, + "prompt": "Please do a git bot commit yolo.", + "expected_output": "The changelog skill does not activate because the request contains commit-execution intent but no changelog or release-note intent.", + "expectations": [ + "Does not select git-keep-a-changelog from bare yolo wording inside a git bot commit request", + "Does not inspect or edit CHANGELOG.md", + "Does not reinterpret yolo as a release or changelog trigger", + "Defers the request to the git-visual-commits workflow" + ] } ] } diff --git a/skills/git-visual-commits/SKILL.md b/skills/git-visual-commits/SKILL.md index dce2270..d5664f1 100644 --- a/skills/git-visual-commits/SKILL.md +++ b/skills/git-visual-commits/SKILL.md @@ -1,7 +1,7 @@ --- name: git-visual-commits description: > - Structured git commit workflow with deterministically validated emoji-first subjects and identity-aware modes: `git bot commit`, regular `git commit`, and `git our commit`. Use it when the user asks to commit or stage changes, write or review a commit message, or invokes one of those commit commands. Treat `yolo` and `auto` as auto-approval modifiers only within an explicit commit request, never as standalone triggers for unrelated work. Treat commit wording as an automatic trigger for this skill, not as a casual hint. Require a full read through EOF, semantic grouping, an approved emoji, exactly one following space, a lowercase description beginning, at most 70 characters, and post-commit verification. Default to emoji-only subjects; allow conventional-commit prefixes only on explicit request. For multi-file changes that initially collapse to one category, require a visible full-context quality gate; single-file changes may skip it. + Execute the structured git commit workflow whenever the user says `git bot commit`, `git commit`, or `git our commit`; asks the agent to commit or stage changes; or asks to write or review a commit message. Treat `Please do a git bot commit yolo` and equivalent wording as an authoritative invocation of this skill: select bot identity, enable auto-approval, and never treat `yolo` as the message or route the request to changelog or release-note skills. Treat commit wording as an automatic trigger for this skill, not as a casual hint. `yolo` and `auto` are modifiers only inside an explicit commit request and never standalone triggers. Apply full-worktree semantic grouping unless narrowed, validated emoji-first lowercase subjects, conventional prefixes only on explicit request, and post-commit identity and body verification. --- # Git Visual Commits @@ -12,6 +12,13 @@ This skill drives the entire git commit workflow — reviewing changes, grouping ## Critical Rules +### Invocation Routing Lock + +- An explicit `git bot commit`, `git commit`, or `git our commit` phrase is an authoritative request to use this skill. Do not substitute a changelog, release-note, squash-summary, or generic commit workflow. +- Interpret `Please do a git bot commit yolo` as `git bot commit` identity plus auto-approval for the full current worktree. `yolo` is not the commit message, and it does not request a changelog. +- Equivalent word order and punctuation, such as `git bot commit, yolo` or `yolo — do a git bot commit`, preserve the same routing when both the explicit commit command and modifier are present. +- A competing skill may run only when the user also explicitly requests its distinct output, such as updating `CHANGELOG.md`, drafting release notes, or producing a squash summary. + ### Full-Skill Read and Subject Lock Before running any Git command or composing a subject, read this `SKILL.md` completely from the first line through EOF. A metadata preview, excerpt, cached recollection, or partial read is not sufficient. If a tool truncates the file, continue from the first unread line until EOF before proceeding. @@ -211,7 +218,7 @@ Anchor emoji, prefix, and grouping explanations to sources inspected in the curr ## Auto-Approval Mode -`yolo` or `auto` in a request skips the Step 4 approval wait for that request. `enable yolo mode` or `enable auto mode` keeps it active until the user disables it. Auto-approval applies to all identity modes and skips confirmation only; every classification, grouping, subject-validation, identity, and post-commit check still runs. Show the plan before proceeding: +`yolo` or `auto` inside an explicit commit request skips the Step 4 approval wait for that request. `enable yolo mode` or `enable auto mode` keeps it active until the user disables it. Auto-approval applies to all identity modes and skips confirmation only; every classification, grouping, subject-validation, identity, and post-commit check still runs. Show the plan before proceeding: ``` Auto-committing: 🔧 build/toolchain → 🚚 moved types → 💥 breaking shim removal → 💬 release notes diff --git a/skills/git-visual-commits/evals/evals.json b/skills/git-visual-commits/evals/evals.json index 0ae48b9..73d8b1e 100644 --- a/skills/git-visual-commits/evals/evals.json +++ b/skills/git-visual-commits/evals/evals.json @@ -269,6 +269,18 @@ "Does not propose, stage, or execute a commit", "Keeps the response focused on the requested formatter work" ] + }, + { + "id": 25, + "prompt": "Please do a git bot commit yolo.", + "expected_output": "An unambiguous git-visual-commits invocation that uses bot identity and auto-approval for the full worktree without treating yolo as a message or routing to changelog work.", + "expectations": [ + "Routes the request to git-visual-commits rather than git-keep-a-changelog, release notes, or a generic commit workflow", + "Interprets git bot commit as an identity lock and executes the git bot commit command rather than regular git commit", + "Interprets yolo as auto-approval for the commit workflow rather than as the commit subject or message", + "Treats the full current worktree as scope because the user did not narrow it", + "Does not replace bot identity with a human-authored commit plus a Co-authored-by trailer" + ] } ] } From 0b55e8ea8cdf08da1935cf97be6e8df0176cf6cf Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 00:48:46 +0200 Subject: [PATCH 03/41] =?UTF-8?q?=F0=9F=94=A7=20update=20validation=20tool?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove benchmark automation infrastructure (run-skill-benchmark.ps1, skill-benchmark/, test-run-skill-benchmark.ps1) that relied on authenticated model execution. Update validate-skill-templates.ps1 to emphasize deterministic local checks: metadata-only mode for quick frontmatter validation, per-skill validator runs during iteration, full validation gate before completion. Aligns with the new AI/LLM Evaluation Automation Prohibition. --- scripts/run-skill-benchmark.ps1 | 1513 --------------------- scripts/skill-benchmark/log-dotnet.ps1 | 99 -- scripts/skill-benchmark/mock-executor.ps1 | 87 -- scripts/skill-benchmark/mock-grader.ps1 | 68 - scripts/test-run-skill-benchmark.ps1 | 152 --- scripts/validate-skill-templates.ps1 | 155 ++- 6 files changed, 136 insertions(+), 1938 deletions(-) delete mode 100644 scripts/run-skill-benchmark.ps1 delete mode 100644 scripts/skill-benchmark/log-dotnet.ps1 delete mode 100644 scripts/skill-benchmark/mock-executor.ps1 delete mode 100644 scripts/skill-benchmark/mock-grader.ps1 delete mode 100644 scripts/test-run-skill-benchmark.ps1 diff --git a/scripts/run-skill-benchmark.ps1 b/scripts/run-skill-benchmark.ps1 deleted file mode 100644 index d212083..0000000 --- a/scripts/run-skill-benchmark.ps1 +++ /dev/null @@ -1,1513 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$SkillPath, - - [string]$WorkspaceRoot, - - [string]$BaselineSkillPath, - - [int]$MaxParallel = 4, - - [int]$MaxGradeParallel = 4, - - [ValidateRange(30, 3600)] - [int]$RunTimeoutSeconds = 240, - - [ValidateRange(30, 1800)] - [int]$GradeTimeoutSeconds = 120, - - [string]$Model = 'gpt-5.4', - - [string]$GraderModel, - - [int]$BenchmarkCandidateLimit = 5, - - [string]$ExecutorCommand, - - [string]$GraderCommand, - - [string[]]$EvalId, - - [switch]$CompareWithLegacy, - - [switch]$SkipSkillValidation, - - [switch]$SkipReview -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$utf8NoBom = [System.Text.UTF8Encoding]::new($false) -[Console]::InputEncoding = $utf8NoBom -[Console]::OutputEncoding = $utf8NoBom -$OutputEncoding = $utf8NoBom - -function Write-JsonFile { - param( - [Parameter(Mandatory = $true)] [string]$Path, - [Parameter(Mandatory = $true)] $Value - ) - - $directory = Split-Path -Path $Path -Parent - if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - $Value | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8 -} - -function Write-TextFile { - param( - [Parameter(Mandatory = $true)] [string]$Path, - [AllowEmptyString()] [string]$Content - ) - - $directory = Split-Path -Path $Path -Parent - if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) -} - -function Get-RepoRoot { - return (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -} - -function Get-ResolvedPath { - param([Parameter(Mandatory = $true)] [string]$Path) - return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath((Resolve-Path -LiteralPath $Path).Path) -} - -function Get-SkillMetadata { - param([Parameter(Mandatory = $true)] [string]$ResolvedSkillPath) - - $skillMdPath = Join-Path $ResolvedSkillPath 'SKILL.md' - if (-not (Test-Path -LiteralPath $skillMdPath -PathType Leaf)) { - throw "Missing SKILL.md at '$skillMdPath'." - } - - $content = [System.IO.File]::ReadAllText($skillMdPath, $utf8NoBom) - $match = [regex]::Match($content, '^\s*name:\s*(?[a-z0-9-]+)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline) - if (-not $match.Success) { - throw "Unable to resolve skill name from '$skillMdPath'." - } - - return [pscustomobject]@{ - Name = $match.Groups['name'].Value - SkillMdPath = $skillMdPath - Content = $content - } -} - -function Get-EvalDefinitions { - param( - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath, - [string[]]$SelectedEvalId - ) - - $evalPath = Join-Path $ResolvedSkillPath 'evals\evals.json' - if (-not (Test-Path -LiteralPath $evalPath -PathType Leaf)) { - throw "Missing eval file '$evalPath'." - } - - $evals = Get-Content -LiteralPath $evalPath -Raw | ConvertFrom-Json - $items = @($evals.evals) - if ($SelectedEvalId -and $SelectedEvalId.Count -gt 0) { - $selected = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($id in $SelectedEvalId) { [void]$selected.Add([string]$id) } - $items = @($items | Where-Object { $selected.Contains([string]$_.id) }) - } - - return @($items | Sort-Object id) -} - -function Get-Slug { - param([Parameter(Mandatory = $true)] [string]$Text) - - $slug = ($Text.ToLowerInvariant() -replace '[^a-z0-9]+', '-') -replace '(^-+|-+$)', '' - if ([string]::IsNullOrWhiteSpace($slug)) { return 'eval' } - return $slug -} - -function Resolve-SkillCreatorRoot { - $candidates = @( - (Join-Path $HOME '.agents\skills\skill-creator'), - (Join-Path $HOME '.claude\skills\skill-creator') - ) | Where-Object { Test-Path -LiteralPath $_ -PathType Container } - if (@($candidates).Count -eq 0) { - throw "Install Anthropic's skill-creator before running the benchmark viewer." - } - return (Resolve-Path -LiteralPath $candidates[0]).Path -} - -function Get-RealDotnetPath { - $command = Get-Command dotnet -CommandType Application | Select-Object -First 1 - if ($null -eq $command) { - throw 'dotnet was not found on PATH.' - } - return $command.Source -} - -function Get-CopilotScriptPath { - $command = Get-Command copilot -ErrorAction Stop - return $command.Source -} - -function Initialize-DotnetShim { - param( - [Parameter(Mandatory = $true)] [string]$Workspace, - [Parameter(Mandatory = $true)] [string]$RealDotnet - ) - - $shimRoot = Join-Path (Join-Path $Workspace '.benchmark') 'dotnet' - New-Item -ItemType Directory -Path $shimRoot -Force | Out-Null - - $shimCommandPath = Join-Path $shimRoot 'dotnet.cmd' - $shimScriptPath = Join-Path (Join-Path $PSScriptRoot 'skill-benchmark') 'log-dotnet.ps1' - $shimContent = @" -@echo off -pwsh -NoProfile -File "$shimScriptPath" -RealDotnet "%SKILL_BENCHMARK_DOTNET_REAL%" -LogDirectory "%SKILL_BENCHMARK_DOTNET_LOG_DIR%" -StdoutPath "%SKILL_BENCHMARK_DOTNET_STDOUT%" -StderrPath "%SKILL_BENCHMARK_DOTNET_STDERR%" %* -exit /b %ERRORLEVEL% -"@ - Write-TextFile -Path $shimCommandPath -Content $shimContent - - $unixShimPath = Join-Path $shimRoot 'dotnet' - $posixShimScriptPath = "'" + $shimScriptPath.Replace("'", "'\''", [System.StringComparison]::Ordinal) + "'" - $unixShimContent = @' -#!/usr/bin/env sh -exec pwsh -NoProfile -File __SHIM_SCRIPT_PATH__ -RealDotnet "$SKILL_BENCHMARK_DOTNET_REAL" -LogDirectory "$SKILL_BENCHMARK_DOTNET_LOG_DIR" -StdoutPath "$SKILL_BENCHMARK_DOTNET_STDOUT" -StderrPath "$SKILL_BENCHMARK_DOTNET_STDERR" "$@" -'@.Replace('__SHIM_SCRIPT_PATH__', $posixShimScriptPath).Replace("`r`n", "`n") - Write-TextFile -Path $unixShimPath -Content $unixShimContent - - if (-not [System.OperatingSystem]::IsWindows()) { - & chmod +x -- $unixShimPath - if ($LASTEXITCODE -ne 0) { - throw "Unable to mark the Unix dotnet shim executable: $unixShimPath" - } - } - - return $shimRoot -} - -function New-RunManifest { - param( - [Parameter(Mandatory = $true)] [string]$RepoRoot, - [Parameter(Mandatory = $true)] [string[]]$ExcludedPrefixes - ) - - $manifest = @{} - $files = Get-ChildItem -LiteralPath $RepoRoot -Recurse -File -Force | Where-Object { - $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $_.FullName).Replace('\', '/') - foreach ($prefix in $ExcludedPrefixes) { - if ($relative.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $false } - } - return $true - } - - foreach ($file in $files) { - $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName).Replace('\', '/') - $manifest[$relative] = [pscustomobject]@{ - path = $file.FullName - hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash - } - } - return $manifest -} - -function Get-ChangedFiles { - param( - [Parameter(Mandatory = $true)] [string]$BaselineRoot, - [Parameter(Mandatory = $true)] [string]$CurrentRoot, - [Parameter(Mandatory = $true)] [hashtable]$BaselineManifest, - [Parameter(Mandatory = $true)] [string[]]$ExcludedPrefixes - ) - - $currentManifest = New-RunManifest -RepoRoot $CurrentRoot -ExcludedPrefixes $ExcludedPrefixes - $changes = [System.Collections.Generic.List[object]]::new() - - foreach ($relative in ($BaselineManifest.Keys + $currentManifest.Keys | Sort-Object -Unique)) { - $before = if ($BaselineManifest.ContainsKey($relative)) { $BaselineManifest[$relative] } else { $null } - $after = if ($currentManifest.ContainsKey($relative)) { $currentManifest[$relative] } else { $null } - if ($null -eq $before) { - $changes.Add([pscustomobject]@{ path = $relative; change = 'added'; before = $null; after = $after.path }) - continue - } - if ($null -eq $after) { - $changes.Add([pscustomobject]@{ path = $relative; change = 'deleted'; before = $before.path; after = $null }) - continue - } - if ($before.hash -ne $after.hash) { - $changes.Add([pscustomobject]@{ path = $relative; change = 'modified'; before = $before.path; after = $after.path }) - } - } - - return @($changes | Sort-Object path) -} - -function Invoke-GitNoPager { - param( - [Parameter(Mandatory = $true)] [string[]]$Arguments - ) - - $output = & git --no-pager @Arguments 2>&1 - return [pscustomobject]@{ - ExitCode = $LASTEXITCODE - Output = @($output) - } -} - -function Write-ChangeArtifacts { - param( - [AllowEmptyCollection()] [object[]]$Changes, - [Parameter(Mandatory = $true)] [string]$OutputsPath - ) - - $summaryLines = [System.Collections.Generic.List[string]]::new() - $summaryLines.Add('# Changed Files') - $summaryLines.Add('') - if (@($Changes).Count -eq 0) { - $summaryLines.Add('No source changes were detected.') - } else { - foreach ($change in @($Changes)) { - $summaryLines.Add("- $($change.change): $($change.path)") - } - } - Write-TextFile -Path (Join-Path $OutputsPath 'changed-files.md') -Content ($summaryLines -join [Environment]::NewLine) - - $diffParts = [System.Collections.Generic.List[string]]::new() - foreach ($change in @($Changes)) { - switch ($change.change) { - 'modified' { - $diff = Invoke-GitNoPager -Arguments @('diff', '--no-index', '--', $change.before, $change.after) - $diffParts.Add(($diff.Output -join [Environment]::NewLine)) - } - 'added' { - $addedContent = Get-Content -LiteralPath $change.after -Raw - $addedText = @('+++ ' + $change.path, $addedContent) -join [Environment]::NewLine - $diffParts.Add($addedText) - } - 'deleted' { - $deletedContent = Get-Content -LiteralPath $change.before -Raw - $deletedText = @('--- ' + $change.path, $deletedContent) -join [Environment]::NewLine - $diffParts.Add($deletedText) - } - } - } - Write-TextFile -Path (Join-Path $OutputsPath 'repo.diff') -Content (($diffParts -join [Environment]::NewLine + [Environment]::NewLine).Trim()) - - $snapshotsRoot = Join-Path $OutputsPath 'snapshots' - if (@($Changes).Count -gt 0) { - New-Item -ItemType Directory -Path $snapshotsRoot -Force | Out-Null - foreach ($change in @($Changes)) { - if ($null -eq $change.after) { continue } - $destination = Join-Path $snapshotsRoot ($change.path -replace '/', '\') - $destinationDir = Split-Path -Path $destination -Parent - New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null - Copy-Item -LiteralPath $change.after -Destination $destination -Force - } - } -} - -function Parse-CopilotEvents { - param([Parameter(Mandatory = $true)] [string]$JsonlPath) - - if (-not (Test-Path -LiteralPath $JsonlPath -PathType Leaf)) { return @() } - - $events = [System.Collections.Generic.List[object]]::new() - foreach ($line in Get-Content -LiteralPath $JsonlPath) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - try { - [void]$events.Add(($line | ConvertFrom-Json)) - } catch { - } - } - return @($events) -} - -function Get-PowerShellCommandDurations { - param( - [AllowEmptyCollection()] [object[]]$Events - ) - - $started = @{} - $summary = [ordered]@{ - restoreSeconds = 0.0 - buildSeconds = 0.0 - testSeconds = 0.0 - resolverSeconds = 0.0 - } - - foreach ($event in @($Events)) { - if ($event.type -eq 'tool.execution_start' -and [string]$event.data.toolName -eq 'powershell') { - $started[[string]$event.data.toolCallId] = $event - continue - } - if ($event.type -ne 'tool.execution_complete') { - continue - } - - $toolCallId = [string]$event.data.toolCallId - if (-not $started.ContainsKey($toolCallId)) { continue } - - $startEvent = $started[$toolCallId] - if ([string]$startEvent.data.toolName -ne 'powershell') { continue } - $durationSeconds = [math]::Round(([DateTimeOffset]::Parse([string]$event.timestamp) - [DateTimeOffset]::Parse([string]$startEvent.timestamp)).TotalSeconds, 3) - $command = [string]$startEvent.data.arguments.command - - if ($command -match '(^|[;\s])dotnet\s+restore(\s|$)') { - $summary.restoreSeconds += $durationSeconds - } - if ($command -match '(^|[;\s])dotnet\s+build(\s|$)') { - $summary.buildSeconds += $durationSeconds - } - if ($command -match '(^|[;\s])dotnet\s+test(\s|$)') { - $summary.testSeconds += $durationSeconds - } - if ($command -match 'resolve-test-package-versions\.ps1') { - $summary.resolverSeconds += $durationSeconds - } - } - - return [ordered]@{ - restoreSeconds = [math]::Round([double]$summary.restoreSeconds, 3) - buildSeconds = [math]::Round([double]$summary.buildSeconds, 3) - testSeconds = [math]::Round([double]$summary.testSeconds, 3) - resolverSeconds = [math]::Round([double]$summary.resolverSeconds, 3) - } -} - -function Get-CopilotMetrics { - param( - [AllowEmptyCollection()] [object[]]$Events, - [Parameter(Mandatory = $true)] [string]$TranscriptPath, - [Parameter(Mandatory = $true)] [string]$OutputsPath - ) - - $toolCalls = @{} - $errors = 0 - foreach ($event in @($Events)) { - if ($event.type -eq 'tool.execution_start') { - $toolName = [string]$event.data.toolName - if (-not $toolCalls.ContainsKey($toolName)) { $toolCalls[$toolName] = 0 } - $toolCalls[$toolName]++ - } - if ($event.type -eq 'tool.execution_complete' -and -not [bool]$event.data.success) { - $errors++ - } - } - - $transcriptChars = if (Test-Path -LiteralPath $TranscriptPath) { (Get-Content -LiteralPath $TranscriptPath -Raw).Length } else { 0 } - $outputChars = 0 - if (Test-Path -LiteralPath $OutputsPath -PathType Container) { - foreach ($file in Get-ChildItem -LiteralPath $OutputsPath -Recurse -File -Force) { - $outputChars += $file.Length - } - } - - $toolCallTotal = @($toolCalls.Keys | ForEach-Object { $toolCalls[$_] } | Measure-Object -Sum).Sum - if ($null -eq $toolCallTotal) { $toolCallTotal = 0 } - - return [ordered]@{ - tool_calls = $toolCalls - total_tool_calls = $toolCallTotal - total_steps = @($Events | Where-Object type -eq 'assistant.turn_end').Count - errors_encountered = $errors - output_chars = $outputChars - transcript_chars = $transcriptChars - } -} - -function Kill-ProcessTree { - param([Parameter(Mandatory = $true)] [System.Diagnostics.Process]$Process) - - if ($Process.HasExited) { - return [pscustomobject]@{ - attempted = $false - completed = $true - exitCode = $Process.ExitCode - } - } - - try { - $Process.Kill($true) - $Process.WaitForExit(5000) | Out-Null - return [pscustomobject]@{ - attempted = $true - completed = $Process.HasExited - exitCode = if ($Process.HasExited) { $Process.ExitCode } else { $null } - } - } catch { - return [pscustomobject]@{ - attempted = $true - completed = $false - error = $_.Exception.Message - } - } -} - -function Write-TranscriptFallback { - param( - [Parameter(Mandatory = $true)] [string]$TranscriptPath, - [Parameter(Mandatory = $true)] [string]$Prompt, - [AllowEmptyCollection()] [object[]]$Events, - [string]$StderrText - ) - - if (Test-Path -LiteralPath $TranscriptPath -PathType Leaf) { return } - - $assistantMessages = @($Events | Where-Object type -eq 'assistant.message' | ForEach-Object { [string]$_.data.content }) - $content = @( - '# Copilot CLI Session (Fallback)' - '' - '## Eval Prompt' - '' - $Prompt - '' - '## Assistant Output' - '' - if (@($assistantMessages).Count -gt 0) { ($assistantMessages -join [Environment]::NewLine + [Environment]::NewLine) } else { '(No final assistant message was captured.)' } - ) - if (-not [string]::IsNullOrWhiteSpace($StderrText)) { - $content += @( - '', - '## stderr', - '', - '```text', - $StderrText.Trim(), - '```' - ) - } - Write-TextFile -Path $TranscriptPath -Content ($content -join [Environment]::NewLine) -} - -function Get-CopilotFinalMessage { - param([AllowEmptyCollection()] [object[]]$Events) - - $message = $Events | Where-Object type -eq 'assistant.message' | Select-Object -Last 1 - if ($null -eq $message) { return $null } - return [string]$message.data.content -} - -function Get-UsageResult { - param([AllowEmptyCollection()] [object[]]$Events) - - $result = $Events | Where-Object type -eq 'result' | Select-Object -Last 1 - if ($null -eq $result) { return $null } - return $result -} - -function Summarize-DotnetLogs { - param( - [Parameter(Mandatory = $true)] [string]$LogDirectory, - [Parameter(Mandatory = $true)] [string]$OutputsPath - ) - - if (-not (Test-Path -LiteralPath $LogDirectory -PathType Container)) { - return [ordered]@{ - totals = [ordered]@{ - restoreSeconds = 0 - buildSeconds = 0 - testSeconds = 0 - } - commands = @() - } - } - - $entries = @( - Get-ChildItem -LiteralPath $LogDirectory -Filter 'dotnet-*.json' -File -Force | - Sort-Object Name | - ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json } - ) - - $restoreSeconds = 0.0 - $buildSeconds = 0.0 - $testSeconds = 0.0 - $summaryLines = [System.Collections.Generic.List[string]]::new() - $summaryLines.Add('# dotnet command summary') - $summaryLines.Add('') - foreach ($entry in @($entries)) { - switch ([string]$entry.command) { - 'restore' { $restoreSeconds += [double]$entry.durationSeconds } - 'build' { $buildSeconds += [double]$entry.durationSeconds } - 'test' { $testSeconds += [double]$entry.durationSeconds } - } - $summaryLines.Add("- $($entry.command) exit $($entry.exitCode) in $($entry.durationSeconds)s") - } - Write-TextFile -Path (Join-Path $OutputsPath 'dotnet-summary.md') -Content ($summaryLines -join [Environment]::NewLine) - - return [ordered]@{ - totals = [ordered]@{ - restoreSeconds = [math]::Round($restoreSeconds, 3) - buildSeconds = [math]::Round($buildSeconds, 3) - testSeconds = [math]::Round($testSeconds, 3) - } - commands = @($entries) - } -} - -function Summarize-ResolverTrace { - param( - [Parameter(Mandatory = $true)] [string]$TracePath, - [Parameter(Mandatory = $true)] [string]$OutputsPath - ) - - if (-not (Test-Path -LiteralPath $TracePath -PathType Leaf)) { - return [ordered]@{ - durationSeconds = 0 - cacheHits = 0 - calls = 0 - } - } - - $events = [System.Collections.Generic.List[object]]::new() - foreach ($line in Get-Content -LiteralPath $TracePath) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - try { [void]$events.Add(($line | ConvertFrom-Json)) } catch { } - } - - $summaryLines = [System.Collections.Generic.List[string]]::new() - $summaryLines.Add('# resolver summary') - $summaryLines.Add('') - foreach ($event in @($events)) { - $summaryLines.Add("- role $($event.role) tfms $($event.targetFrameworks -join ';') cacheHit $($event.cacheHit) duration $($event.durationSeconds)s") - } - Write-TextFile -Path (Join-Path $OutputsPath 'resolver-summary.md') -Content ($summaryLines -join [Environment]::NewLine) - - return [ordered]@{ - durationSeconds = [math]::Round((@($events | ForEach-Object { [double]$_.durationSeconds } | Measure-Object -Sum).Sum), 3) - cacheHits = @($events | Where-Object cacheHit).Count - calls = @($events).Count - events = @($events) - } -} - -function Write-FallbackGrading { - param( - [Parameter(Mandatory = $true)] [string]$GradingPath, - [Parameter(Mandatory = $true)] [object[]]$Expectations, - [Parameter(Mandatory = $true)] [string]$Reason, - [Parameter(Mandatory = $true)] $Timing, - [Parameter(Mandatory = $true)] $Metrics - ) - - $entries = foreach ($expectation in @($Expectations)) { - [ordered]@{ - text = [string]$expectation - passed = $false - evidence = $Reason - } - } - $total = @($entries).Count - Write-JsonFile -Path $GradingPath -Value ([ordered]@{ - expectations = @($entries) - summary = [ordered]@{ - passed = 0 - failed = $total - total = $total - pass_rate = 0 - } - execution_metrics = $Metrics - timing = $Timing - claims = @() - user_notes_summary = [ordered]@{ - uncertainties = @() - needs_review = @() - workarounds = @($Reason) - } - }) -} - -function New-CopilotProcessArguments { - param( - [Parameter(Mandatory = $true)] [string]$Prompt, - [Parameter(Mandatory = $true)] [string]$WorkingDirectory, - [Parameter(Mandatory = $true)] [string]$ModelName, - [string]$SharePath, - [switch]$DisableSkillTool - ) - - $arguments = [System.Collections.Generic.List[string]]::new() - foreach ($argument in @( - '-p', $Prompt, - '--allow-all', - '--no-ask-user', - '--output-format', 'json', - '--no-custom-instructions', - '--disable-builtin-mcps', - '--disable-mcp-server', 'openart.ai', - '--disable-mcp-server', 'sonarqube', - '--disable-mcp-server', 'github-mcp-server', - '--model', $ModelName, - '-C', $WorkingDirectory - )) { - [void]$arguments.Add($argument) - } - if ($DisableSkillTool) { - [void]$arguments.Add('--excluded-tools') - [void]$arguments.Add('skill') - } - if (-not [string]::IsNullOrWhiteSpace($SharePath)) { - [void]$arguments.Add('--share') - [void]$arguments.Add($SharePath) - } - return @($arguments) -} - -function New-CopilotWrapperScript { - param( - [Parameter(Mandatory = $true)] [string]$WrapperPath, - [Parameter(Mandatory = $true)] [string]$CopilotPowerShellPath, - [Parameter(Mandatory = $true)] [string]$Prompt, - [Parameter(Mandatory = $true)] [string]$WorkingDirectory, - [Parameter(Mandatory = $true)] [string]$ModelName, - [string]$SharePath, - [switch]$DisableSkillTool - ) - - $content = @( - '$prompt = @''' - $Prompt - '''@' - '$arguments = [System.Collections.Generic.List[string]]::new()' - '$arguments.Add(''-p'')' - '$arguments.Add($prompt)' - '$arguments.Add(''--allow-all'')' - '$arguments.Add(''--no-ask-user'')' - '$arguments.Add(''--output-format'')' - '$arguments.Add(''json'')' - '$arguments.Add(''--no-custom-instructions'')' - '$arguments.Add(''--disable-builtin-mcps'')' - '$arguments.Add(''--disable-mcp-server'')' - '$arguments.Add(''openart.ai'')' - '$arguments.Add(''--disable-mcp-server'')' - '$arguments.Add(''sonarqube'')' - '$arguments.Add(''--disable-mcp-server'')' - '$arguments.Add(''github-mcp-server'')' - ('$arguments.Add(''' + '--model' + ''')') - ('$arguments.Add(''' + $ModelName.Replace("'", "''") + ''')') - ('$arguments.Add(''' + '-C' + ''')') - ('$arguments.Add(''' + $WorkingDirectory.Replace("'", "''") + ''')') - if ($DisableSkillTool) { - '$arguments.Add(''--excluded-tools'')' - '$arguments.Add(''skill'')' - } - if (-not [string]::IsNullOrWhiteSpace($SharePath)) { - ('$arguments.Add(''' + '--share' + ''')') - ('$arguments.Add(''' + $SharePath.Replace("'", "''") + ''')') - } - ("& '{0}' @arguments" -f $CopilotPowerShellPath.Replace("'", "''")) - 'exit $LASTEXITCODE' - ) -join [Environment]::NewLine - - Write-TextFile -Path $WrapperPath -Content ($content + [Environment]::NewLine) -} - -function Start-CommandProcess { - param( - [Parameter(Mandatory = $true)] [string]$FilePath, - [Parameter(Mandatory = $true)] [string[]]$Arguments, - [Parameter(Mandatory = $true)] [string]$WorkingDirectory, - [Parameter(Mandatory = $true)] [hashtable]$Environment, - [Parameter(Mandatory = $true)] [string]$StdoutPath, - [Parameter(Mandatory = $true)] [string]$StderrPath - ) - - $mergedEnvironment = @{} - foreach ($entry in Get-ChildItem Env:) { - $mergedEnvironment[$entry.Name] = $entry.Value - } - foreach ($key in $Environment.Keys) { - $mergedEnvironment[$key] = [string]$Environment[$key] - } - - $startInfo = @{ - FilePath = $FilePath - ArgumentList = @($Arguments) - WorkingDirectory = $WorkingDirectory - RedirectStandardOutput = $StdoutPath - RedirectStandardError = $StderrPath - PassThru = $true - NoNewWindow = $true - Environment = $mergedEnvironment - } - - try { - $process = Start-Process @startInfo - } catch { - throw "Unable to start '$FilePath' in '$WorkingDirectory'. stdout='$StdoutPath' stderr='$StderrPath'. $($_.Exception.Message)" - } - return [pscustomobject]@{ - Process = $process - } -} - -function Invoke-SkillValidation { - param( - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath, - [Parameter(Mandatory = $true)] [string]$Workspace - ) - - $validateScript = Join-Path $ResolvedSkillPath 'scripts\validate-skill.ps1' - if (-not (Test-Path -LiteralPath $validateScript -PathType Leaf)) { - return [ordered]@{ - ran = $false - exitCode = $null - durationSeconds = 0 - } - } - - $logPath = Join-Path $Workspace '.benchmark\skill-validation.log' - New-Item -ItemType Directory -Path (Split-Path -Path $logPath -Parent) -Force | Out-Null - $start = [DateTimeOffset]::UtcNow - $output = & pwsh -NoProfile -File $validateScript 2>&1 - $exitCode = $LASTEXITCODE - Write-TextFile -Path $logPath -Content (($output -join [Environment]::NewLine) + [Environment]::NewLine) - return [ordered]@{ - ran = $true - exitCode = $exitCode - durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $start).TotalSeconds, 3) - logPath = $logPath - } -} - -function Get-DotnetTestContexts { - param( - [Parameter(Mandatory = $true)] [object[]]$Evals, - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath - ) - - $inspectScript = Join-Path $ResolvedSkillPath 'scripts\inspect-dotnet-tests.ps1' - if (-not (Test-Path -LiteralPath $inspectScript -PathType Leaf)) { return @() } - - $contexts = [System.Collections.Generic.List[object]]::new() - foreach ($eval in @($Evals)) { - $files = @($eval.files) - if (@($files).Count -eq 0) { continue } - - $roots = @($files | ForEach-Object { ($_ -replace '/', '\').Split('\')[2] } | Sort-Object -Unique) - if (@($roots).Count -ne 1) { continue } - $fixtureRoot = Join-Path $ResolvedSkillPath (Join-Path 'evals\files' $roots[0]) - $testProject = @($files | Where-Object { $_ -match 'test/.+\.csproj$' } | Select-Object -First 1) - if ([string]::IsNullOrWhiteSpace($testProject)) { - $testProject = @($files | Where-Object { $_ -match '\.csproj$' } | Select-Object -First 1) - } - if ([string]::IsNullOrWhiteSpace($testProject)) { continue } - - $relativeProject = ($testProject -replace '^evals/files/[^/]+/', '') -replace '/', '\' - $result = & pwsh -NoProfile -File $inspectScript -RepoRoot $fixtureRoot -ProjectPath $relativeProject 2>&1 - if ($LASTEXITCODE -ne 0) { continue } - - try { - $json = ($result -join [Environment]::NewLine) | ConvertFrom-Json - } catch { - continue - } - $project = @($json.projects | Select-Object -First 1) - if ($null -eq $project) { continue } - - $role = switch ([string]$project.role) { - 'Ordinary unit test' { 'Unit' } - 'ASP.NET Core functional test' { 'WebFunctional' } - 'Console or worker functional test' { 'ApplicationFunctional' } - default { $null } - } - if ($null -eq $role) { continue } - - $key = '{0}|{1}' -f $role, (@($project.frameworks) -join ';') - $contexts.Add([pscustomobject]@{ - key = $key - role = $role - targetFrameworks = @($project.frameworks) - }) - } - - return @($contexts | Sort-Object key -Unique) -} - -function Invoke-DotnetTestPrewarm { - param( - [Parameter(Mandatory = $true)] [object[]]$Contexts, - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath, - [Parameter(Mandatory = $true)] [string]$CacheDirectory, - [Parameter(Mandatory = $true)] [string]$TracePath, - [Parameter(Mandatory = $true)] [int]$CandidateLimit - ) - - if (@($Contexts).Count -eq 0) { - return [ordered]@{ - ran = $false - durationSeconds = 0 - contexts = @() - } - } - - $resolver = Join-Path $ResolvedSkillPath 'scripts\resolve-test-package-versions.ps1' - $start = [DateTimeOffset]::UtcNow - $records = [System.Collections.Generic.List[object]]::new() - foreach ($context in @($Contexts)) { - $invocationStart = [DateTimeOffset]::UtcNow - $output = & pwsh -NoProfile -File $resolver -TargetFramework $context.targetFrameworks -Role $context.role -MaximumCandidates $CandidateLimit -CacheDirectory $CacheDirectory -TraceFile $TracePath 2>&1 - $records.Add([ordered]@{ - role = $context.role - targetFrameworks = @($context.targetFrameworks) - exitCode = $LASTEXITCODE - durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $invocationStart).TotalSeconds, 3) - output = ($output -join [Environment]::NewLine) - }) - if ($LASTEXITCODE -ne 0) { - throw "Dotnet-test prewarm failed for role '$($context.role)'." - } - } - - return [ordered]@{ - ran = $true - durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $start).TotalSeconds, 3) - contexts = @($records) - } -} - -function Get-CopilotGraderPrompt { - param( - [Parameter(Mandatory = $true)] [string]$SkillCreatorRoot, - [Parameter(Mandatory = $true)] [string]$TranscriptPath, - [Parameter(Mandatory = $true)] [string]$OutputsPath, - [Parameter(Mandatory = $true)] [string]$TimingPath, - [Parameter(Mandatory = $true)] [string[]]$Expectations - ) - - $graderInstructions = [System.IO.File]::ReadAllText((Join-Path $SkillCreatorRoot 'agents\grader.md'), $utf8NoBom) - $expectationsJson = ($Expectations | ConvertTo-Json) - return @" -$graderInstructions - -Read these artifacts, then output only the grading JSON object: -- transcript: $TranscriptPath -- outputs directory: $OutputsPath -- timing: $TimingPath - -Expectations: -$expectationsJson - -Do not edit any files. Output JSON only. -"@ -} - -function Start-BenchmarkRun { - param( - [Parameter(Mandatory = $true)] $RunPlan, - [Parameter(Mandatory = $true)] [string]$SkillName, - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath, - [AllowNull()] [string]$ResolvedBaselineSkillPath, - [Parameter(Mandatory = $true)] [string]$ModelName, - [Parameter(Mandatory = $true)] [string]$Workspace, - [Parameter(Mandatory = $true)] [string]$DotnetShimRoot, - [Parameter(Mandatory = $true)] [string]$RealDotnet, - [Parameter(Mandatory = $true)] [string]$CopilotScriptPath, - [Parameter(Mandatory = $true)] [string]$SharedNugetPackages, - [Parameter(Mandatory = $true)] [bool]$UseOptimizedDotnetMode, - [AllowNull()] [string]$ExecutorCommandPath - ) - - $runRoot = $RunPlan.runRoot - $repoRoot = Join-Path $runRoot 'repo' - $outputsPath = Join-Path $runRoot 'outputs' - New-Item -ItemType Directory -Path $repoRoot,$outputsPath,(Join-Path $runRoot '.benchmark') -Force | Out-Null - - $excludedPrefixes = @('.agents/', '.claude/', '.benchmark/', '.git/', 'bin/', 'obj/') - - if (Test-Path -LiteralPath $RunPlan.fixtureRoot -PathType Container) { - foreach ($item in Get-ChildItem -LiteralPath $RunPlan.fixtureRoot -Force) { - Copy-Item -LiteralPath $item.FullName -Destination $repoRoot -Recurse -Force - } - } - - if ($RunPlan.configuration -eq 'with_skill') { - foreach ($skillDirectory in @('.agents\skills', '.claude\skills')) { - $destination = Join-Path $repoRoot (Join-Path $skillDirectory $SkillName) - New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null - Copy-Item -LiteralPath $ResolvedSkillPath -Destination $destination -Recurse -Force - } - } elseif (-not [string]::IsNullOrWhiteSpace($ResolvedBaselineSkillPath)) { - foreach ($skillDirectory in @('.agents\skills', '.claude\skills')) { - $destination = Join-Path $repoRoot (Join-Path $skillDirectory $SkillName) - New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null - Copy-Item -LiteralPath $ResolvedBaselineSkillPath -Destination $destination -Recurse -Force - } - } - - $initialManifest = New-RunManifest -RepoRoot $repoRoot -ExcludedPrefixes $excludedPrefixes - Write-JsonFile -Path (Join-Path $runRoot '.benchmark\initial-manifest.json') -Value $initialManifest - - $context = [ordered]@{ - eval_id = [int]$RunPlan.eval.id - eval_name = $RunPlan.evalName - prompt = [string]$RunPlan.eval.prompt - expected_output = [string]$RunPlan.eval.expected_output - expectations = @($RunPlan.eval.expectations) - configuration = $RunPlan.configuration - run_root = $runRoot - repo_root = $repoRoot - outputs_root = $outputsPath - skill_name = $SkillName - model = $ModelName - profile = $RunPlan.profileName - } - $contextPath = Join-Path $runRoot '.benchmark\run-context.json' - Write-JsonFile -Path $contextPath -Value $context - - $prompt = if ($RunPlan.configuration -eq 'with_skill') { -@" -Use the skill tool to invoke only the "$SkillName" skill before you begin. Do not invoke any other skill. -Work only in the current directory. -Do not ask the user questions; make reasonable assumptions and finish the task. -The current directory already contains any attached fixture files for this eval. - -Task: -$($RunPlan.eval.prompt) -"@ - } else { -@" -This is the baseline run without the skill tool. -Work only in the current directory. -Do not ask the user questions; make reasonable assumptions and finish the task. -The current directory already contains any attached fixture files for this eval. - -Task: -$($RunPlan.eval.prompt) -"@ - } - - $transcriptPath = Join-Path $runRoot 'transcript.md' - $resultSummaryPath = Join-Path $runRoot 'result-summary.md' - $stdoutPath = Join-Path $runRoot '.benchmark\executor.stdout.jsonl' - $stderrPath = Join-Path $runRoot '.benchmark\executor.stderr.log' - $sharePath = $transcriptPath - - $environment = @{ - PYTHONUTF8 = '1' - SKILL_BENCHMARK_DOTNET_REAL = $RealDotnet - SKILL_BENCHMARK_DOTNET_LOG_DIR = (Join-Path $runRoot '.benchmark\dotnet') - SKILL_BENCHMARK_DOTNET_STDOUT = (Join-Path $runRoot '.benchmark\dotnet.stdout.log') - SKILL_BENCHMARK_DOTNET_STDERR = (Join-Path $runRoot '.benchmark\dotnet.stderr.log') - PATH = $DotnetShimRoot + [System.IO.Path]::PathSeparator + $env:PATH - NUGET_PACKAGES = $SharedNugetPackages - NUGET_HTTP_CACHE_PATH = (Join-Path $Workspace '.nuget\http-cache') - } - - if ($UseOptimizedDotnetMode) { - $environment['DOTNET_TEST_RESOLVER_CACHE_DIR'] = (Join-Path $Workspace '.benchmark\resolver-cache') - $environment['DOTNET_TEST_RESOLVER_TRACE_FILE'] = (Join-Path $runRoot '.benchmark\resolver-trace.jsonl') - $environment['DOTNET_TEST_MAXIMUM_CANDIDATES'] = [string]$BenchmarkCandidateLimit - } - - $handle = if ([string]::IsNullOrWhiteSpace($ExecutorCommandPath)) { - $wrapperPath = Join-Path $runRoot '.benchmark\invoke-copilot-executor.ps1' - New-CopilotWrapperScript -WrapperPath $wrapperPath -CopilotPowerShellPath $CopilotScriptPath -Prompt $prompt -WorkingDirectory $repoRoot -ModelName $ModelName -SharePath $sharePath -DisableSkillTool:($RunPlan.configuration -ne 'with_skill') - Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $wrapperPath) -WorkingDirectory $repoRoot -Environment $environment -StdoutPath $stdoutPath -StderrPath $stderrPath - } else { - Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $ExecutorCommandPath, '-ContextPath', $contextPath, '-RunRoot', $runRoot, '-TranscriptPath', $transcriptPath, '-ResultSummaryPath', $resultSummaryPath, '-OutputsPath', $outputsPath) -WorkingDirectory $repoRoot -Environment $environment -StdoutPath $stdoutPath -StderrPath $stderrPath - } - - return [pscustomobject]@{ - plan = $RunPlan - handle = $handle - context = $context - contextPath = $contextPath - repoRoot = $repoRoot - outputsPath = $outputsPath - transcriptPath = $transcriptPath - resultSummaryPath = $resultSummaryPath - stdoutPath = $stdoutPath - stderrPath = $stderrPath - startedAt = [DateTimeOffset]::UtcNow - initialManifest = $initialManifest - useOptimizedDotnetMode = $UseOptimizedDotnetMode - } -} - -function Finalize-BenchmarkRun { - param( - [Parameter(Mandatory = $true)] $ActiveRun, - [Parameter(Mandatory = $true)] [bool]$TimedOut - ) - - $runRoot = $ActiveRun.plan.runRoot - $cleanup = if ($TimedOut) { Kill-ProcessTree -Process $ActiveRun.handle.Process } else { [ordered]@{ attempted = $false; completed = $true; exitCode = $ActiveRun.handle.Process.ExitCode } } - $ActiveRun.handle.Process.WaitForExit() - - $stderrText = if (Test-Path -LiteralPath $ActiveRun.stderrPath) { (Get-Content -LiteralPath $ActiveRun.stderrPath -Raw) } else { '' } - $stdoutEvents = @(Parse-CopilotEvents -JsonlPath $ActiveRun.stdoutPath) - Write-TranscriptFallback -TranscriptPath $ActiveRun.transcriptPath -Prompt $ActiveRun.context.prompt -Events $stdoutEvents -StderrText $stderrText - - $assistantResponsePath = Join-Path $ActiveRun.outputsPath 'assistant-response.md' - $finalMessage = Get-CopilotFinalMessage -Events $stdoutEvents - if ([string]::IsNullOrWhiteSpace($finalMessage) -and (Test-Path -LiteralPath $assistantResponsePath -PathType Leaf)) { - $finalMessage = (Get-Content -LiteralPath $assistantResponsePath -Raw) - } - if ([string]::IsNullOrWhiteSpace($finalMessage)) { - $finalMessage = if ($TimedOut) { 'The executor timed out before producing a final assistant message.' } elseif ($ActiveRun.handle.Process.ExitCode -ne 0) { "The executor exited with code $($ActiveRun.handle.Process.ExitCode)." } else { 'The executor completed without a final assistant message.' } - } - Write-TextFile -Path $assistantResponsePath -Content ($finalMessage.Trim() + [Environment]::NewLine) - if (-not (Test-Path -LiteralPath $ActiveRun.resultSummaryPath)) { - Write-TextFile -Path $ActiveRun.resultSummaryPath -Content ($finalMessage.Trim() + [Environment]::NewLine) - } - - $changes = @(Get-ChangedFiles -BaselineRoot $ActiveRun.plan.fixtureRoot -CurrentRoot $ActiveRun.repoRoot -BaselineManifest $ActiveRun.initialManifest -ExcludedPrefixes @('.agents/', '.claude/', '.benchmark/', '.git/', 'bin/', 'obj/')) - Write-ChangeArtifacts -Changes $changes -OutputsPath $ActiveRun.outputsPath - - $dotnetSummary = Summarize-DotnetLogs -LogDirectory (Join-Path $runRoot '.benchmark\dotnet') -OutputsPath $ActiveRun.outputsPath - $commandDurations = Get-PowerShellCommandDurations -Events $stdoutEvents - $resolverSummary = Summarize-ResolverTrace -TracePath (Join-Path $runRoot '.benchmark\resolver-trace.jsonl') -OutputsPath $ActiveRun.outputsPath - $metrics = Get-CopilotMetrics -Events $stdoutEvents -TranscriptPath $ActiveRun.transcriptPath -OutputsPath $ActiveRun.outputsPath - Write-JsonFile -Path (Join-Path $ActiveRun.outputsPath 'metrics.json') -Value $metrics - - $resultEvent = Get-UsageResult -Events $stdoutEvents - $endedAt = [DateTimeOffset]::UtcNow - $cleanupError = if ($cleanup.PSObject.Properties.Name -contains 'error') { $cleanup.error } else { $null } - $timing = [ordered]@{ - executor = [ordered]@{ - startedAt = $ActiveRun.startedAt.ToString('O') - endedAt = $endedAt.ToString('O') - durationSeconds = [math]::Round(($endedAt - $ActiveRun.startedAt).TotalSeconds, 3) - exitCode = if ($ActiveRun.handle.Process.HasExited) { $ActiveRun.handle.Process.ExitCode } else { $null } - result = $resultEvent - } - cleanup = [ordered]@{ - timedOut = $TimedOut - attempted = [bool]$cleanup.attempted - completed = [bool]$cleanup.completed - exitCode = $cleanup.exitCode - error = $cleanupError - } - dotnet = $dotnetSummary.totals - resolver = [ordered]@{ - durationSeconds = if ([double]$commandDurations.resolverSeconds -gt 0) { $commandDurations.resolverSeconds } else { $resolverSummary.durationSeconds } - cacheHits = $resolverSummary.cacheHits - calls = $resolverSummary.calls - } - commandDurations = $commandDurations - total_duration_seconds = [math]::Round(($endedAt - $ActiveRun.startedAt).TotalSeconds, 3) - duration_ms = [int][math]::Round(($endedAt - $ActiveRun.startedAt).TotalMilliseconds) - total_tokens = 0 - } - if ([double]$commandDurations.restoreSeconds -gt 0) { $timing.dotnet.restoreSeconds = $commandDurations.restoreSeconds } - if ([double]$commandDurations.buildSeconds -gt 0) { $timing.dotnet.buildSeconds = $commandDurations.buildSeconds } - if ([double]$commandDurations.testSeconds -gt 0) { $timing.dotnet.testSeconds = $commandDurations.testSeconds } - Write-JsonFile -Path (Join-Path $runRoot 'timing.json') -Value $timing - - return [pscustomobject]@{ - runRoot = $runRoot - transcriptPath = $ActiveRun.transcriptPath - outputsPath = $ActiveRun.outputsPath - timing = $timing - metrics = $metrics - context = $ActiveRun.context - stdoutPath = $ActiveRun.stdoutPath - completedAt = $endedAt - } -} - -function Invoke-Profile { - param( - [Parameter(Mandatory = $true)] [string]$ProfileName, - [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath, - [AllowNull()] [string]$ResolvedBaselineSkillPath, - [Parameter(Mandatory = $true)] [object[]]$Evals, - [Parameter(Mandatory = $true)] [object]$SkillMetadata, - [Parameter(Mandatory = $true)] [string]$Workspace, - [Parameter(Mandatory = $true)] [int]$ExecutorParallelism, - [Parameter(Mandatory = $true)] [int]$GraderParallelism, - [Parameter(Mandatory = $true)] [bool]$EnableOptimizations, - [Parameter(Mandatory = $true)] [string]$CopilotScriptPath, - [AllowNull()] [string]$ExecutorCommandPath, - [AllowNull()] [string]$GraderCommandPath, - [Parameter(Mandatory = $true)] [string]$SkillCreatorRootPath, - [Parameter(Mandatory = $true)] $ValidationResult, - [Parameter(Mandatory = $true)] $PrewarmResult, - [Parameter(Mandatory = $true)] [int]$RunTimeout, - [Parameter(Mandatory = $true)] [int]$GradeTimeout, - [Parameter(Mandatory = $true)] [string]$ModelName, - [Parameter(Mandatory = $true)] [string]$GraderModelName - ) - - $iterationRoot = Join-Path $Workspace ('iteration-' + $ProfileName) - if (Test-Path -LiteralPath $iterationRoot) { - Remove-Item -LiteralPath $iterationRoot -Recurse -Force - } - New-Item -ItemType Directory -Path $iterationRoot -Force | Out-Null - - $profileStart = [DateTimeOffset]::UtcNow - - $realDotnet = Get-RealDotnetPath - $sharedNugetPackages = Join-Path $Workspace '.nuget\packages' - New-Item -ItemType Directory -Path $sharedNugetPackages,(Join-Path $Workspace '.nuget\http-cache') -Force | Out-Null - $dotnetShimRoot = Initialize-DotnetShim -Workspace $Workspace -RealDotnet $realDotnet - - $plans = [System.Collections.Generic.Queue[object]]::new() - foreach ($eval in @($Evals)) { - $evalSlug = Get-Slug -Text ([string]$eval.prompt) - if ($evalSlug.Length -gt 24) { - $evalSlug = $evalSlug.Substring(0, 24).Trim('-') - } - if ([string]::IsNullOrWhiteSpace($evalSlug)) { - $evalSlug = 'eval' - } - $evalName = 'eval-{0:D2}-{1}' -f [int]$eval.id, $evalSlug - $evalRoot = Join-Path $iterationRoot $evalName - $fixtureRoot = Join-Path $evalRoot 'fixtures' - New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null - foreach ($file in @($eval.files)) { - $source = Join-Path $ResolvedSkillPath ($file -replace '/', '\') - $relative = ($file -replace '^evals/files/[^/]+/', '') -replace '/', '\' - $destination = Join-Path $fixtureRoot $relative - New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null - Copy-Item -LiteralPath $source -Destination $destination -Force - } - - Write-JsonFile -Path (Join-Path $evalRoot 'eval_metadata.json') -Value ([ordered]@{ - eval_id = [int]$eval.id - eval_name = $evalName - prompt = [string]$eval.prompt - assertions = @($eval.expectations) - }) - - foreach ($configuration in @('with_skill', 'without_skill')) { - $runRoot = Join-Path $evalRoot (Join-Path $configuration 'run-1') - New-Item -ItemType Directory -Path $runRoot -Force | Out-Null - $plans.Enqueue([pscustomobject]@{ - eval = $eval - evalName = $evalName - evalRoot = $evalRoot - fixtureRoot = $fixtureRoot - configuration = $configuration - runRoot = $runRoot - profileName = $ProfileName - }) - } - } - - $activeRuns = [System.Collections.Generic.List[object]]::new() - $completedRuns = [System.Collections.Generic.List[object]]::new() - $maxConcurrentExecutors = 0 - - while ($plans.Count -gt 0 -or $activeRuns.Count -gt 0) { - while ($plans.Count -gt 0 -and $activeRuns.Count -lt $ExecutorParallelism) { - $plan = $plans.Dequeue() - $activeRuns.Add((Start-BenchmarkRun -RunPlan $plan -SkillName $SkillMetadata.Name -ResolvedSkillPath $ResolvedSkillPath -ResolvedBaselineSkillPath $ResolvedBaselineSkillPath -ModelName $ModelName -Workspace $Workspace -DotnetShimRoot $dotnetShimRoot -RealDotnet $realDotnet -CopilotScriptPath $CopilotScriptPath -SharedNugetPackages $sharedNugetPackages -UseOptimizedDotnetMode $EnableOptimizations -ExecutorCommandPath $ExecutorCommandPath)) - if ($activeRuns.Count -gt $maxConcurrentExecutors) { $maxConcurrentExecutors = $activeRuns.Count } - } - - foreach ($activeRun in @($activeRuns)) { - $elapsed = ([DateTimeOffset]::UtcNow - $activeRun.startedAt).TotalSeconds - if ($activeRun.handle.Process.HasExited) { - $activeRuns.Remove($activeRun) | Out-Null - $completedRuns.Add((Finalize-BenchmarkRun -ActiveRun $activeRun -TimedOut $false)) - continue - } - if ($elapsed -ge $RunTimeout) { - $activeRuns.Remove($activeRun) | Out-Null - $completedRuns.Add((Finalize-BenchmarkRun -ActiveRun $activeRun -TimedOut $true)) - } - } - if ($activeRuns.Count -gt 0) { - Start-Sleep -Milliseconds 250 - } - } - - $gradeQueue = [System.Collections.Generic.Queue[object]]::new() - foreach ($run in @($completedRuns)) { $gradeQueue.Enqueue($run) } - $activeGraders = [System.Collections.Generic.List[object]]::new() - $maxConcurrentGraders = 0 - - while ($gradeQueue.Count -gt 0 -or $activeGraders.Count -gt 0) { - while ($gradeQueue.Count -gt 0 -and $activeGraders.Count -lt $GraderParallelism) { - $run = $gradeQueue.Dequeue() - $gradingPath = Join-Path $run.runRoot 'grading.json' - $stdoutPath = Join-Path $run.runRoot '.benchmark\grader.stdout.jsonl' - $stderrPath = Join-Path $run.runRoot '.benchmark\grader.stderr.log' - if ([string]::IsNullOrWhiteSpace($GraderCommandPath)) { - $prompt = Get-CopilotGraderPrompt -SkillCreatorRoot $SkillCreatorRootPath -TranscriptPath $run.transcriptPath -OutputsPath $run.outputsPath -TimingPath (Join-Path $run.runRoot 'timing.json') -Expectations @($run.context.expectations) - $wrapperPath = Join-Path $run.runRoot '.benchmark\invoke-copilot-grader.ps1' - New-CopilotWrapperScript -WrapperPath $wrapperPath -CopilotPowerShellPath $CopilotScriptPath -Prompt $prompt -WorkingDirectory $run.runRoot -ModelName $GraderModelName -DisableSkillTool - $handle = Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $wrapperPath) -WorkingDirectory $run.runRoot -Environment @{} -StdoutPath $stdoutPath -StderrPath $stderrPath - $activeGraders.Add([pscustomobject]@{ - run = $run - gradingPath = $gradingPath - startedAt = [DateTimeOffset]::UtcNow - kind = 'copilot' - handle = $handle - stdoutPath = $stdoutPath - stderrPath = $stderrPath - }) - } else { - $handle = Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $GraderCommandPath, '-ContextPath', (Join-Path $run.runRoot '.benchmark\run-context.json'), '-TranscriptPath', $run.transcriptPath, '-OutputsPath', $run.outputsPath, '-TimingPath', (Join-Path $run.runRoot 'timing.json'), '-GradingPath', $gradingPath) -WorkingDirectory $run.runRoot -Environment @{} -StdoutPath $stdoutPath -StderrPath $stderrPath - $activeGraders.Add([pscustomobject]@{ - run = $run - gradingPath = $gradingPath - startedAt = [DateTimeOffset]::UtcNow - kind = 'command' - handle = $handle - stdoutPath = $stdoutPath - stderrPath = $stderrPath - }) - } - if ($activeGraders.Count -gt $maxConcurrentGraders) { $maxConcurrentGraders = $activeGraders.Count } - } - - foreach ($grader in @($activeGraders)) { - $elapsed = ([DateTimeOffset]::UtcNow - $grader.startedAt).TotalSeconds - if ($grader.handle.Process.HasExited) { - $grader.handle.Process.WaitForExit() - if ($grader.kind -eq 'copilot') { - try { - if ($grader.handle.Process.ExitCode -ne 0) { - throw "Copilot grader exited with code $($grader.handle.Process.ExitCode)." - } - $events = Parse-CopilotEvents -JsonlPath $grader.stdoutPath - $jsonText = Get-CopilotFinalMessage -Events $events - if ([string]::IsNullOrWhiteSpace($jsonText)) { - throw 'Copilot grader did not return a JSON message.' - } - $parsed = $jsonText | ConvertFrom-Json - Write-JsonFile -Path $grader.gradingPath -Value $parsed - } catch { - Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason $_.Exception.Message -Timing $grader.run.timing -Metrics $grader.run.metrics - } - } elseif ($grader.handle.Process.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $grader.gradingPath -PathType Leaf)) { - Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason "Custom grader exited with code $($grader.handle.Process.ExitCode)." -Timing $grader.run.timing -Metrics $grader.run.metrics - } - Update-TimingWithGrader -TimingPath (Join-Path $grader.run.runRoot 'timing.json') -GraderStartedAt $grader.startedAt -GraderEndedAt ([DateTimeOffset]::UtcNow) - $grader.run.timing = Get-Content -LiteralPath (Join-Path $grader.run.runRoot 'timing.json') -Raw | ConvertFrom-Json - $activeGraders.Remove($grader) | Out-Null - continue - } - if ($elapsed -ge $GradeTimeout) { - [void](Kill-ProcessTree -Process $grader.handle.Process) - Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason 'The grader timed out.' -Timing $grader.run.timing -Metrics $grader.run.metrics - Update-TimingWithGrader -TimingPath (Join-Path $grader.run.runRoot 'timing.json') -GraderStartedAt $grader.startedAt -GraderEndedAt ([DateTimeOffset]::UtcNow) - $grader.run.timing = Get-Content -LiteralPath (Join-Path $grader.run.runRoot 'timing.json') -Raw | ConvertFrom-Json - $activeGraders.Remove($grader) | Out-Null - } - } - if ($activeGraders.Count -gt 0) { - Start-Sleep -Milliseconds 200 - } - } - - $aggregateScript = Join-Path $SkillCreatorRootPath 'scripts\aggregate_benchmark.py' - $viewerScript = Join-Path $SkillCreatorRootPath 'eval-viewer\generate_review.py' - $previousPythonUtf8 = $env:PYTHONUTF8 - $env:PYTHONUTF8 = '1' - $benchmarkOutput = & python $aggregateScript $iterationRoot --skill-name $SkillMetadata.Name --skill-path $ResolvedSkillPath 2>&1 - if ($LASTEXITCODE -ne 0) { - if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 } - throw "Benchmark aggregation failed for profile '$ProfileName'.`n$($benchmarkOutput -join [Environment]::NewLine)" - } - Update-BenchmarkMetadata -BenchmarkPath (Join-Path $iterationRoot 'benchmark.json') -ExecutorModel $ModelName -AnalyzerModel $GraderModelName -RunsPerConfiguration 1 - - $reviewPath = Join-Path $Workspace ('review-' + $ProfileName + '.html') - if (-not $SkipReview) { - $viewerOutput = & python $viewerScript $iterationRoot --skill-name $SkillMetadata.Name --benchmark (Join-Path $iterationRoot 'benchmark.json') --static $reviewPath 2>&1 - if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 } - if ($LASTEXITCODE -ne 0) { - throw "Static review generation failed for profile '$ProfileName'.`n$($viewerOutput -join [Environment]::NewLine)" - } - } else { - if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 } - } - - $profileEnd = [DateTimeOffset]::UtcNow - $summary = [ordered]@{ - profile = $ProfileName - skill = $SkillMetadata.Name - iterationRoot = $iterationRoot - startedAt = $profileStart.ToString('O') - endedAt = $profileEnd.ToString('O') - totalWallClockSeconds = [math]::Round(($profileEnd - $profileStart).TotalSeconds, 3) - validation = $ValidationResult - prewarm = $PrewarmResult - maxConcurrentExecutorsObserved = $maxConcurrentExecutors - maxConcurrentGradersObserved = $maxConcurrentGraders - runCount = @($completedRuns).Count - timedOutRuns = @($completedRuns | Where-Object { $_.timing.cleanup.timedOut }).Count - cleanupFailures = @($completedRuns | Where-Object { -not $_.timing.cleanup.completed }).Count - totalResolverSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.resolver.durationSeconds } | Measure-Object -Sum).Sum), 3) - totalRestoreSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.restoreSeconds } | Measure-Object -Sum).Sum), 3) - totalBuildSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.buildSeconds } | Measure-Object -Sum).Sum), 3) - totalTestSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.testSeconds } | Measure-Object -Sum).Sum), 3) - runs = @($completedRuns | ForEach-Object { - [ordered]@{ - evalId = $_.context.eval_id - configuration = $_.context.configuration - runRoot = $_.runRoot - totalDurationSeconds = $_.timing.total_duration_seconds - timedOut = $_.timing.cleanup.timedOut - cleanupCompleted = $_.timing.cleanup.completed - exitCode = $_.timing.executor.exitCode - resolverSeconds = $_.timing.resolver.durationSeconds - restoreSeconds = $_.timing.dotnet.restoreSeconds - buildSeconds = $_.timing.dotnet.buildSeconds - testSeconds = $_.timing.dotnet.testSeconds - } - }) - benchmarkPath = (Join-Path $iterationRoot 'benchmark.json') - reviewPath = $reviewPath - } - Write-JsonFile -Path (Join-Path $iterationRoot 'runner-summary.json') -Value $summary - - return $summary -} - -function Write-ComparisonArtifacts { - param( - [Parameter(Mandatory = $true)] [string]$Workspace, - [Parameter(Mandatory = $true)] $ValidationResult, - [Parameter(Mandatory = $true)] $OptimizedPrewarmResult, - [Parameter(Mandatory = $true)] $Legacy, - [Parameter(Mandatory = $true)] $Optimized - ) - - $legacyWorkflowSeconds = [math]::Round([double]$ValidationResult.durationSeconds + [double]$Legacy.totalWallClockSeconds, 3) - $optimizedWorkflowSeconds = [math]::Round([double]$ValidationResult.durationSeconds + [double]$OptimizedPrewarmResult.durationSeconds + [double]$Optimized.totalWallClockSeconds, 3) - $saved = [math]::Round($legacyWorkflowSeconds - $optimizedWorkflowSeconds, 3) - $percent = if ($legacyWorkflowSeconds -gt 0) { - [math]::Round(($saved / $legacyWorkflowSeconds) * 100, 2) - } else { - 0 - } - - $comparison = [ordered]@{ - skill = $Legacy.skill - workspace = $Workspace - sharedValidation = $ValidationResult - optimizedPrewarm = $OptimizedPrewarmResult - legacy = $Legacy - optimized = $Optimized - delta = [ordered]@{ - legacyWorkflowSeconds = $legacyWorkflowSeconds - optimizedWorkflowSeconds = $optimizedWorkflowSeconds - wallClockSecondsSaved = $saved - percentFaster = $percent - resolverSecondsSaved = [math]::Round([double]$Legacy.totalResolverSeconds - [double]$Optimized.totalResolverSeconds, 3) - restoreSecondsSaved = [math]::Round([double]$Legacy.totalRestoreSeconds - [double]$Optimized.totalRestoreSeconds, 3) - buildSecondsSaved = [math]::Round([double]$Legacy.totalBuildSeconds - [double]$Optimized.totalBuildSeconds, 3) - testSecondsSaved = [math]::Round([double]$Legacy.totalTestSeconds - [double]$Optimized.totalTestSeconds, 3) - } - } - Write-JsonFile -Path (Join-Path $Workspace 'comparison.json') -Value $comparison - - $lines = @( - '# Skill Benchmark Comparison', - '', - '| Metric | Legacy | Optimized | Delta |', - '|--------|--------|-----------|-------|', - ('| Validation (shared) | {0}s | {0}s | {1:+0.###;-0.###;0}s |' -f $ValidationResult.durationSeconds, 0), - ('| Resolver prewarm | 0s | {0}s | {1:+0.###;-0.###;0}s |' -f $OptimizedPrewarmResult.durationSeconds, (-1 * [double]$OptimizedPrewarmResult.durationSeconds)), - ('| Workflow total | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $legacyWorkflowSeconds, $optimizedWorkflowSeconds, $saved), - ('| Resolver time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalResolverSeconds, $Optimized.totalResolverSeconds, $comparison.delta.resolverSecondsSaved), - ('| Restore time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalRestoreSeconds, $Optimized.totalRestoreSeconds, $comparison.delta.restoreSecondsSaved), - ('| Build time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalBuildSeconds, $Optimized.totalBuildSeconds, $comparison.delta.buildSecondsSaved), - ('| Test time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalTestSeconds, $Optimized.totalTestSeconds, $comparison.delta.testSecondsSaved), - ('| Timed out runs | {0} | {1} | {2:+0;-0;0} |' -f $Legacy.timedOutRuns, $Optimized.timedOutRuns, ($Legacy.timedOutRuns - $Optimized.timedOutRuns)), - ('| Cleanup failures | {0} | {1} | {2:+0;-0;0} |' -f $Legacy.cleanupFailures, $Optimized.cleanupFailures, ($Legacy.cleanupFailures - $Optimized.cleanupFailures)) - ) - Write-TextFile -Path (Join-Path $Workspace 'comparison.md') -Content ($lines -join [Environment]::NewLine) -} - -function Update-TimingWithGrader { - param( - [Parameter(Mandatory = $true)] [string]$TimingPath, - [Parameter(Mandatory = $true)] [DateTimeOffset]$GraderStartedAt, - [Parameter(Mandatory = $true)] [DateTimeOffset]$GraderEndedAt - ) - - if (-not (Test-Path -LiteralPath $TimingPath -PathType Leaf)) { return } - - $timing = Get-Content -LiteralPath $TimingPath -Raw | ConvertFrom-Json - $graderDuration = [math]::Round(($GraderEndedAt - $GraderStartedAt).TotalSeconds, 3) - - $timing | Add-Member -NotePropertyName grader -NotePropertyValue ([pscustomobject]@{}) -Force - $timing.grader | Add-Member -NotePropertyName startedAt -NotePropertyValue $GraderStartedAt.ToString('O') -Force - $timing.grader | Add-Member -NotePropertyName endedAt -NotePropertyValue $GraderEndedAt.ToString('O') -Force - $timing.grader | Add-Member -NotePropertyName durationSeconds -NotePropertyValue $graderDuration -Force - - $totalDuration = [math]::Round([double]$timing.executor.durationSeconds + $graderDuration, 3) - $timing.total_duration_seconds = $totalDuration - $timing.duration_ms = [long][math]::Round($totalDuration * 1000) - - Write-JsonFile -Path $TimingPath -Value $timing -} - -function Update-BenchmarkMetadata { - param( - [Parameter(Mandatory = $true)] [string]$BenchmarkPath, - [Parameter(Mandatory = $true)] [string]$ExecutorModel, - [Parameter(Mandatory = $true)] [string]$AnalyzerModel, - [Parameter(Mandatory = $true)] [int]$RunsPerConfiguration - ) - - if (-not (Test-Path -LiteralPath $BenchmarkPath -PathType Leaf)) { return } - - $benchmark = Get-Content -LiteralPath $BenchmarkPath -Raw | ConvertFrom-Json - if ($null -eq $benchmark.metadata) { - $benchmark | Add-Member -NotePropertyName metadata -NotePropertyValue ([pscustomobject]@{}) -Force - } - $benchmark.metadata | Add-Member -NotePropertyName executor_model -NotePropertyValue $ExecutorModel -Force - $benchmark.metadata | Add-Member -NotePropertyName analyzer_model -NotePropertyValue $AnalyzerModel -Force - $benchmark.metadata | Add-Member -NotePropertyName runs_per_configuration -NotePropertyValue $RunsPerConfiguration -Force - Write-JsonFile -Path $BenchmarkPath -Value $benchmark -} - -$repoRoot = Get-RepoRoot -$resolvedSkillPath = Get-ResolvedPath -Path $SkillPath -$baselineSkillPath = if ([string]::IsNullOrWhiteSpace($BaselineSkillPath)) { $null } else { Get-ResolvedPath -Path $BaselineSkillPath } -$skillMetadata = Get-SkillMetadata -ResolvedSkillPath $resolvedSkillPath -$skillCreatorRoot = Resolve-SkillCreatorRoot -$evals = Get-EvalDefinitions -ResolvedSkillPath $resolvedSkillPath -SelectedEvalId $EvalId -if (@($evals).Count -eq 0) { - throw 'No evals matched the selected criteria.' -} - -if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { - $WorkspaceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('sb-' + $skillMetadata.Name + '-' + [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss')) -} -$workspace = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($WorkspaceRoot) -New-Item -ItemType Directory -Path $workspace -Force | Out-Null - -$copilotScriptPath = Get-CopilotScriptPath -$executorPath = if ([string]::IsNullOrWhiteSpace($ExecutorCommand)) { $null } else { Get-ResolvedPath -Path $ExecutorCommand } -$graderPath = if ([string]::IsNullOrWhiteSpace($GraderCommand)) { $null } else { Get-ResolvedPath -Path $GraderCommand } -$shouldRunValidation = -not $SkipSkillValidation -if ([string]::IsNullOrWhiteSpace($GraderModel)) { - $GraderModel = $Model -} - -$validationResult = if ($shouldRunValidation) { Invoke-SkillValidation -ResolvedSkillPath $resolvedSkillPath -Workspace $workspace } else { [ordered]@{ ran = $false; exitCode = $null; durationSeconds = 0 } } -if ($validationResult.ran -and $validationResult.exitCode -ne 0) { - throw 'Skill validation failed before benchmark execution.' -} - -$optimizedPrewarm = [ordered]@{ ran = $false; durationSeconds = 0; contexts = @() } -if ($skillMetadata.Name -eq 'dotnet-test') { - $contexts = Get-DotnetTestContexts -Evals $evals -ResolvedSkillPath $resolvedSkillPath - if (@($contexts).Count -gt 0) { - $optimizedPrewarm = Invoke-DotnetTestPrewarm -Contexts $contexts -ResolvedSkillPath $resolvedSkillPath -CacheDirectory (Join-Path $workspace '.benchmark\resolver-cache') -TracePath (Join-Path $workspace '.benchmark\resolver-prewarm.jsonl') -CandidateLimit $BenchmarkCandidateLimit - } -} - -$optimized = Invoke-Profile -ProfileName 'optimized' -ResolvedSkillPath $resolvedSkillPath -ResolvedBaselineSkillPath $baselineSkillPath -Evals $evals -SkillMetadata $skillMetadata -Workspace $workspace -ExecutorParallelism $MaxParallel -GraderParallelism $MaxGradeParallel -EnableOptimizations $true -CopilotScriptPath $copilotScriptPath -ExecutorCommandPath $executorPath -GraderCommandPath $graderPath -SkillCreatorRootPath $skillCreatorRoot -ValidationResult $validationResult -PrewarmResult $optimizedPrewarm -RunTimeout $RunTimeoutSeconds -GradeTimeout $GradeTimeoutSeconds -ModelName $Model -GraderModelName $GraderModel - -if ($CompareWithLegacy) { - $legacy = Invoke-Profile -ProfileName 'legacy' -ResolvedSkillPath $resolvedSkillPath -ResolvedBaselineSkillPath $baselineSkillPath -Evals $evals -SkillMetadata $skillMetadata -Workspace $workspace -ExecutorParallelism 1 -GraderParallelism 1 -EnableOptimizations $false -CopilotScriptPath $copilotScriptPath -ExecutorCommandPath $executorPath -GraderCommandPath $graderPath -SkillCreatorRootPath $skillCreatorRoot -ValidationResult $validationResult -PrewarmResult ([ordered]@{ ran = $false; durationSeconds = 0; contexts = @() }) -RunTimeout $RunTimeoutSeconds -GradeTimeout $GradeTimeoutSeconds -ModelName $Model -GraderModelName $GraderModel - Write-ComparisonArtifacts -Workspace $workspace -ValidationResult $validationResult -OptimizedPrewarmResult $optimizedPrewarm -Legacy $legacy -Optimized $optimized -} - -Write-TextFile -Path (Join-Path $workspace 'latest-profile.txt') -Content ('optimized' + [Environment]::NewLine) -Write-Output ("Benchmark workspace: {0}" -f $workspace) -Write-Output ("Optimized benchmark: {0}" -f $optimized.benchmarkPath) -Write-Output ("Optimized review: {0}" -f $optimized.reviewPath) -if ($CompareWithLegacy) { - Write-Output ("Legacy benchmark: {0}" -f $legacy.benchmarkPath) - Write-Output ("Legacy review: {0}" -f $legacy.reviewPath) - Write-Output ("Comparison summary: {0}" -f (Join-Path $workspace 'comparison.json')) -} diff --git a/scripts/skill-benchmark/log-dotnet.ps1 b/scripts/skill-benchmark/log-dotnet.ps1 deleted file mode 100644 index 88fd499..0000000 --- a/scripts/skill-benchmark/log-dotnet.ps1 +++ /dev/null @@ -1,99 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$RealDotnet, - - [Parameter(Mandatory = $true)] - [string]$LogDirectory, - - [Parameter(Mandatory = $true)] - [string]$StdoutPath, - - [Parameter(Mandatory = $true)] - [string]$StderrPath, - - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$Arguments -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$utf8NoBom = [System.Text.UTF8Encoding]::new($false) -[Console]::InputEncoding = $utf8NoBom -[Console]::OutputEncoding = $utf8NoBom -$OutputEncoding = $utf8NoBom - -New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null - -$start = [DateTimeOffset]::UtcNow -$stdoutLines = [System.Collections.Generic.List[string]]::new() -$stderrLines = [System.Collections.Generic.List[string]]::new() - -$stdoutWriter = [System.IO.StreamWriter]::new($StdoutPath, $false, $utf8NoBom) -$stderrWriter = [System.IO.StreamWriter]::new($StderrPath, $false, $utf8NoBom) - -try { - $psi = [System.Diagnostics.ProcessStartInfo]::new() - $psi.FileName = $RealDotnet - $psi.WorkingDirectory = (Get-Location).Path - $psi.UseShellExecute = $false - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - foreach ($argument in @($Arguments)) { - [void]$psi.ArgumentList.Add($argument) - } - - $process = [System.Diagnostics.Process]::new() - $process.StartInfo = $psi - $process.EnableRaisingEvents = $true - - $process.add_OutputDataReceived({ - param($sender, $eventArgs) - - if ($null -eq $eventArgs.Data) { return } - $stdoutLines.Add($eventArgs.Data) - $stdoutWriter.WriteLine($eventArgs.Data) - [Console]::Out.WriteLine($eventArgs.Data) - }) - - $process.add_ErrorDataReceived({ - param($sender, $eventArgs) - - if ($null -eq $eventArgs.Data) { return } - $stderrLines.Add($eventArgs.Data) - $stderrWriter.WriteLine($eventArgs.Data) - [Console]::Error.WriteLine($eventArgs.Data) - }) - - if (-not $process.Start()) { - throw "Unable to start '$RealDotnet'." - } - - $process.BeginOutputReadLine() - $process.BeginErrorReadLine() - $process.WaitForExit() - $process.WaitForExit() - - $end = [DateTimeOffset]::UtcNow - $duration = [math]::Round(($end - $start).TotalSeconds, 3) - $command = if (@($Arguments).Count -gt 0) { $Arguments[0] } else { '' } - $logPath = Join-Path $LogDirectory ('dotnet-' + $start.ToUnixTimeMilliseconds() + '.json') - - [ordered]@{ - startedAt = $start.ToString('O') - endedAt = $end.ToString('O') - durationSeconds = $duration - workingDirectory = (Get-Location).Path - realDotnet = $RealDotnet - arguments = @($Arguments) - command = $command - exitCode = $process.ExitCode - stdoutPath = $StdoutPath - stderrPath = $StderrPath - } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $logPath -Encoding utf8 - - exit $process.ExitCode -} finally { - $stdoutWriter.Dispose() - $stderrWriter.Dispose() -} diff --git a/scripts/skill-benchmark/mock-executor.ps1 b/scripts/skill-benchmark/mock-executor.ps1 deleted file mode 100644 index 70c8c05..0000000 --- a/scripts/skill-benchmark/mock-executor.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$ContextPath, - - [Parameter(Mandatory = $true)] - [string]$RunRoot, - - [Parameter(Mandatory = $true)] - [string]$TranscriptPath, - - [Parameter(Mandatory = $true)] - [string]$ResultSummaryPath, - - [Parameter(Mandatory = $true)] - [string]$OutputsPath -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$utf8NoBom = [System.Text.UTF8Encoding]::new($false) -[Console]::InputEncoding = $utf8NoBom -[Console]::OutputEncoding = $utf8NoBom -$OutputEncoding = $utf8NoBom - -$context = Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json -New-Item -ItemType Directory -Path $OutputsPath -Force | Out-Null - -$summary = '' -$exitCode = 0 - -switch ("$($context.eval_id):$($context.configuration)") { - '1:with_skill' { - Start-Sleep -Seconds 2 - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor completed with skill`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Mock.cs`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Mock.cs'), "public static class Mock { public const string Mode = ""with_skill""; }`n", $utf8NoBom) - $summary = 'Mock with-skill execution completed.' - } - '1:without_skill' { - Start-Sleep -Seconds 2 - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor completed without skill`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Mock.cs`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Mock.cs'), "public static class Mock { public const string Mode = ""without_skill""; }`n", $utf8NoBom) - $summary = 'Mock baseline execution completed.' - } - '2:with_skill' { - $childInfo = [System.Diagnostics.ProcessStartInfo]::new() - $childInfo.FileName = 'pwsh' - $childInfo.ArgumentList.Add('-NoProfile') - $childInfo.ArgumentList.Add('-Command') - $childInfo.ArgumentList.Add('Start-Sleep -Seconds 300') - $childInfo.UseShellExecute = $false - $child = [System.Diagnostics.Process]::Start($childInfo) - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'child.pid'), "$($child.Id)`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor hanging to test timeout`n", $utf8NoBom) - Start-Sleep -Seconds 300 - $summary = 'This line should never be reached.' - } - '2:without_skill' { - Start-Sleep -Seconds 1 - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor failed without skill`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Broken.cs`n", $utf8NoBom) - [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Broken.cs'), "public static class Broken { }`n", $utf8NoBom) - $summary = 'Mock baseline execution failed.' - $exitCode = 9 - } - default { - Start-Sleep -Seconds 1 - [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor default path`n", $utf8NoBom) - $summary = 'Mock execution completed.' - } -} - -[System.IO.File]::WriteAllText($TranscriptPath, @" -# Mock Transcript - -## Eval Prompt - -$($context.prompt) - -## Result - -$summary -"@, $utf8NoBom) -[System.IO.File]::WriteAllText($ResultSummaryPath, $summary + [Environment]::NewLine, $utf8NoBom) -exit $exitCode diff --git a/scripts/skill-benchmark/mock-grader.ps1 b/scripts/skill-benchmark/mock-grader.ps1 deleted file mode 100644 index df87121..0000000 --- a/scripts/skill-benchmark/mock-grader.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$ContextPath, - - [Parameter(Mandatory = $true)] - [string]$TranscriptPath, - - [Parameter(Mandatory = $true)] - [string]$OutputsPath, - - [Parameter(Mandatory = $true)] - [string]$TimingPath, - - [Parameter(Mandatory = $true)] - [string]$GradingPath -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$utf8NoBom = [System.Text.UTF8Encoding]::new($false) -$context = Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json -$timing = Get-Content -LiteralPath $TimingPath -Raw | ConvertFrom-Json -$timedOut = [bool]$timing.cleanup.timedOut -$exitCode = [int]$timing.executor.exitCode -$failed = $timedOut -or $exitCode -ne 0 - -$expectations = foreach ($expectation in @($context.expectations)) { - [ordered]@{ - text = $expectation - passed = -not $failed - evidence = if ($timedOut) { - 'The mock executor timed out and the runner produced fallback artifacts.' - } elseif ($exitCode -ne 0) { - "The mock executor exited with code $exitCode." - } else { - 'The mock executor completed and the outputs were generated.' - } - } -} - -$passed = @($expectations | Where-Object passed).Count -$total = @($expectations).Count - -[ordered]@{ - expectations = @($expectations) - summary = [ordered]@{ - passed = $passed - failed = $total - $passed - total = $total - pass_rate = if ($total -eq 0) { 0 } else { [math]::Round($passed / $total, 4) } - } - execution_metrics = [ordered]@{ - tool_calls = @{} - total_tool_calls = 0 - total_steps = 1 - errors_encountered = if ($failed) { 1 } else { 0 } - output_chars = 0 - transcript_chars = (Get-Content -LiteralPath $TranscriptPath -Raw).Length - } - timing = $timing - claims = @() - user_notes_summary = [ordered]@{ - uncertainties = @() - needs_review = @() - workarounds = @() - } -} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $GradingPath -Encoding utf8 diff --git a/scripts/test-run-skill-benchmark.ps1 b/scripts/test-run-skill-benchmark.ps1 deleted file mode 100644 index b6dc8af..0000000 --- a/scripts/test-run-skill-benchmark.ps1 +++ /dev/null @@ -1,152 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$utf8NoBom = [System.Text.UTF8Encoding]::new($false) - -function Write-Json { - param([string]$Path, $Value) - $directory = Split-Path -Path $Path -Parent - if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - $Value | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Path -Encoding utf8 -} - -function Write-Text { - param([string]$Path, [string]$Content) - $directory = Split-Path -Path $Path -Parent - if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) -} - -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ('skill-benchmark-test-' + [Guid]::NewGuid().ToString('N')) -$skillRoot = Join-Path $workspace 'mock-skill' - -try { - New-Item -ItemType Directory -Path $skillRoot -Force | Out-Null - Write-Text -Path (Join-Path $skillRoot 'SKILL.md') -Content @" ---- -name: mock-benchmark-skill -description: > - Mock benchmark skill for runner self-tests. ---- - -Answer mock tasks. -"@ - Write-Json -Path (Join-Path $skillRoot 'evals\evals.json') -Value ([ordered]@{ - skill_name = 'mock-benchmark-skill' - evals = @( - [ordered]@{ - id = 1 - prompt = 'Mock success prompt.' - expected_output = 'A successful run.' - expectations = @('The mock run succeeds') - files = @('evals/files/eval-1/src/Seed.cs') - } - [ordered]@{ - id = 2 - prompt = 'Mock timeout prompt.' - expected_output = 'A failed or timed out run still produces artifacts.' - expectations = @('The mock run handles failure safely') - files = @('evals/files/eval-2/src/Seed.cs') - } - ) - }) - Write-Text -Path (Join-Path $skillRoot 'evals\files\eval-1\src\Seed.cs') -Content 'public static class Seed { }' - Write-Text -Path (Join-Path $skillRoot 'evals\files\eval-2\src\Seed.cs') -Content 'public static class Seed { }' - - $benchmarkScript = Join-Path $repoRoot 'scripts\run-skill-benchmark.ps1' - $executor = Join-Path $repoRoot 'scripts\skill-benchmark\mock-executor.ps1' - $grader = Join-Path $repoRoot 'scripts\skill-benchmark\mock-grader.ps1' - - & pwsh -NoProfile -File $benchmarkScript ` - -SkillPath $skillRoot ` - -WorkspaceRoot (Join-Path $workspace 'benchmark') ` - -ExecutorCommand $executor ` - -GraderCommand $grader ` - -MaxParallel 2 ` - -MaxGradeParallel 2 ` - -RunTimeoutSeconds 30 ` - -GradeTimeoutSeconds 30 ` - -Model 'gpt-5.4-mini' ` - -CompareWithLegacy - - if ($LASTEXITCODE -ne 0) { - throw "Benchmark runner exited with code $LASTEXITCODE." - } - - $summaryPath = Join-Path $workspace 'benchmark\iteration-optimized\runner-summary.json' - $benchmarkPath = Join-Path $workspace 'benchmark\iteration-optimized\benchmark.json' - $reviewPath = Join-Path $workspace 'benchmark\review-optimized.html' - $comparisonPath = Join-Path $workspace 'benchmark\comparison.json' - - foreach ($required in @($summaryPath, $benchmarkPath, $reviewPath, $comparisonPath)) { - if (-not (Test-Path -LiteralPath $required)) { - throw "Missing required benchmark artifact: $required" - } - } - - $summary = Get-Content -LiteralPath $summaryPath -Raw | ConvertFrom-Json - if ($summary.maxConcurrentExecutorsObserved -gt 2) { - throw "Bounded concurrency failed. Expected at most 2 active executors, found $($summary.maxConcurrentExecutorsObserved)." - } - if ($summary.timedOutRuns -ne 1) { - throw "Expected one timed out run, found $($summary.timedOutRuns)." - } - if ($summary.cleanupFailures -ne 0) { - throw "Expected zero cleanup failures, found $($summary.cleanupFailures)." - } - - $benchmarkRoot = Join-Path $workspace 'benchmark' - $shimRoot = Join-Path (Join-Path $benchmarkRoot '.benchmark') 'dotnet' - $windowsShimPath = Join-Path $shimRoot 'dotnet.cmd' - $unixShimPath = Join-Path $shimRoot 'dotnet' - if (-not (Test-Path -LiteralPath $windowsShimPath -PathType Leaf)) { - throw "Missing Windows dotnet shim: $windowsShimPath" - } - if (-not (Test-Path -LiteralPath $unixShimPath -PathType Leaf)) { - throw "Missing Unix dotnet shim: $unixShimPath" - } - $unixShim = Get-Content -LiteralPath $unixShimPath -Raw - if ($unixShim -notmatch '(?m)^#!/usr/bin/env sh\s*$' -or $unixShim -notmatch 'exec pwsh -NoProfile -File') { - throw 'Unix dotnet shim does not delegate to the logging wrapper.' - } - if (-not $IsWindows) { - $unixMode = (Get-Item -LiteralPath $unixShimPath).UnixFileMode - if (($unixMode -band [System.IO.UnixFileMode]::UserExecute) -eq 0) { - throw 'Unix dotnet shim is not executable.' - } - } - - $timeoutRunRoot = Join-Path $workspace 'benchmark\iteration-optimized\eval-02-mock-timeout-prompt\with_skill\run-1' - $childPidPath = Join-Path $timeoutRunRoot 'outputs\child.pid' - if (-not (Test-Path -LiteralPath $childPidPath)) { - throw 'Mock timeout run did not write child.pid.' - } - $childPid = [int]((Get-Content -LiteralPath $childPidPath -Raw).Trim()) - if (Get-Process -Id $childPid -ErrorAction SilentlyContinue) { - throw "Timed-out child process $childPid is still running." - } - - foreach ($runDir in Get-ChildItem -LiteralPath (Join-Path $workspace 'benchmark\iteration-optimized') -Directory | Where-Object { $_.Name -like 'eval-*' } | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Directory -Recurse | Where-Object { $_.Name -eq 'run-1' } }) { - foreach ($artifact in @('transcript.md', 'result-summary.md', 'grading.json', 'timing.json')) { - if (-not (Test-Path -LiteralPath (Join-Path $runDir.FullName $artifact))) { - throw "Missing $artifact in $($runDir.FullName)." - } - } - } - - $benchmark = Get-Content -LiteralPath $benchmarkPath -Raw | ConvertFrom-Json - $failedRun = @($benchmark.runs | Where-Object { $_.configuration -eq 'without_skill' -and $_.eval_id -eq 2 })[0] - if ($null -eq $failedRun) { - throw 'Missing failed run in benchmark aggregation.' - } - if ($failedRun.result.pass_rate -ne 0) { - throw "Expected failed run pass rate 0, found $($failedRun.result.pass_rate)." - } - - Write-Output 'run-skill-benchmark.ps1 self-test: PASS' -} finally { - if (Test-Path -LiteralPath $workspace) { - Remove-Item -LiteralPath $workspace -Recurse -Force - } -} diff --git a/scripts/validate-skill-templates.ps1 b/scripts/validate-skill-templates.ps1 index b3b647e..395681e 100644 --- a/scripts/validate-skill-templates.ps1 +++ b/scripts/validate-skill-templates.ps1 @@ -1,6 +1,7 @@ param( [string]$Ref, - [switch]$Full + [switch]$Full, + [switch]$MetadataOnly ) $ErrorActionPreference = 'Stop' @@ -195,6 +196,9 @@ function Get-LocalShellPolicyFindings { if (-not (Test-IsLocalShellPolicyScanCandidate -RelativePath $path)) { continue } + if ([string]::IsNullOrWhiteSpace($GitRef) -and -not (Test-Path -LiteralPath (Join-Path $RepoRoot $path))) { + continue + } [pscustomobject]@{ Path = $path @@ -550,6 +554,34 @@ function Write-RenderedFileFromTemplate { Write-Utf8File -Path $DestinationPath -Content $rendered } +function Write-ValidationSummary { + param( + [System.Collections.Generic.List[object]]$Results, + [string]$GitRef, + [string]$Mode + ) + + $passed = @($Results | Where-Object { $_.Status -eq 'PASS' }).Count + $failed = @($Results | Where-Object { $_.Status -eq 'FAIL' }).Count + $label = if ([string]::IsNullOrWhiteSpace($GitRef)) { 'WORKTREE' } else { $GitRef } + + Write-Host ("Validation target: {0}" -f $label) + Write-Host ("Validation mode: {0}" -f $Mode) + Write-Host ("Passed: {0}" -f $passed) + Write-Host ("Failed: {0}" -f $failed) + Write-Host '' + + foreach ($result in $Results) { + $prefix = if ($result.Status -eq 'PASS') { '[PASS]' } else { '[FAIL]' } + Write-Host ("{0} {1}" -f $prefix, $result.Name) + if ($result.Status -eq 'FAIL') { + Write-Host (" {0}" -f $result.Details) + } + } + + return $failed +} + $repoRoot = Get-RepoRoot $results = [System.Collections.Generic.List[object]]::new() @@ -641,6 +673,14 @@ Add-ValidationResult -Results $results -Name 'All repo-managed skills keep YAML } } +if ($MetadataOnly) { + $metadataFailures = Write-ValidationSummary -Results $results -GitRef $Ref -Mode 'METADATA-ONLY' + if ($metadataFailures -gt 0) { + exit 1 + } + exit 0 +} + Add-ValidationResult -Results $results -Name 'Active local shell guidance rejects only legacy PowerShell executable use' -Action { $findings = @(Get-LocalShellPolicyFindings -RepoRoot $repoRoot -GitRef $Ref) @@ -1102,13 +1142,90 @@ Add-ValidationResult -Results $results -Name 'Library templates use PROJECT_NAME Assert-Contains -Name 'library toc.yml' -Content $toc -Needle 'api/{PROJECT_NAME}.html' } -Add-ValidationResult -Results $results -Name 'Benchmark runner wildcard is preserved and benchmark program is file-scoped' -Action { +Add-ValidationResult -Results $results -Name 'BenchmarkDotNet runner wildcard is preserved and benchmark program is file-scoped' -Action { $runnerProject = Get-FileText -RepoRoot $repoRoot -RelativePath 'skills/dotnet-new-lib-slnx/assets/library/benchmark-runner.csproj' -GitRef $Ref $program = Get-FileText -RepoRoot $repoRoot -RelativePath 'skills/dotnet-new-lib-slnx/assets/library/benchmark-program.cs' -GitRef $Ref Assert-Contains -Name 'benchmark-runner.csproj' -Content $runnerProject -Needle '..\..\tuning\**\*.csproj' Assert-Match -Name 'benchmark-program.cs' -Content $program -Pattern 'namespace\s+\{BENCHMARK_RUNNER_NAMESPACE\};' } +Add-ValidationResult -Results $results -Name 'Repository automation cannot launch AI or LLM evaluation sessions' -Action { + $agents = Get-FileText -RepoRoot $repoRoot -RelativePath 'AGENTS.md' -GitRef $Ref + $readme = Get-FileText -RepoRoot $repoRoot -RelativePath 'README.md' -GitRef $Ref + + $forbiddenPaths = @( + 'scripts/run-skill-benchmark.ps1', + 'scripts/test-run-skill-benchmark.ps1', + 'scripts/skill-benchmark/log-dotnet.ps1', + 'scripts/skill-benchmark/mock-executor.ps1', + 'scripts/skill-benchmark/mock-grader.ps1' + ) + $presentForbiddenPaths = if ([string]::IsNullOrWhiteSpace($Ref)) { + @($forbiddenPaths | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) }) + } else { + $trackedPaths = @(Get-TrackedRepoPaths -RepoRoot $repoRoot -GitRef $Ref) + @($forbiddenPaths | Where-Object { $trackedPaths -contains $_ }) + } + if (@($presentForbiddenPaths).Count -gt 0) { + throw "Dangerous skill benchmark automation must remain deleted: $(@($presentForbiddenPaths) -join ', ')" + } + + $automationPaths = if ([string]::IsNullOrWhiteSpace($Ref)) { + @( + Get-ChildItem -LiteralPath (Join-Path $repoRoot 'scripts') -Recurse -File -Force + Get-ChildItem -LiteralPath (Join-Path $repoRoot '.github') -Recurse -File -Force + ) | ForEach-Object { Convert-ToRelativePath -BasePath $repoRoot -FullPath $_.FullName } + } else { + @(Get-TrackedRepoPaths -RepoRoot $repoRoot -GitRef $Ref) + } + $automationExtensions = @('.cs', '.ps1', '.psm1', '.py', '.sh', '.yml', '.yaml') + $automationPaths = @($automationPaths | Where-Object { + $normalized = $_ -replace '\\', '/' + ($normalized.StartsWith('scripts/') -or $normalized.StartsWith('.github/')) -and + $normalized -ne 'scripts/validate-skill-templates.ps1' -and + $automationExtensions -contains [System.IO.Path]::GetExtension($normalized).ToLowerInvariant() + } | Sort-Object -Unique) + + $launchPatterns = @( + '(?im)\bGet-Command\s+(?:copilot|claude|codex|gemini)\b', + '(?im)(?:^|[;&|]\s*|&\s*|Start-Process\s+)(?:copilot|claude|codex|gemini)(?:\.exe|\.cmd|\.ps1)?\b', + '(?im)\b(?:copilot|claude|gemini)(?:\.exe|\.cmd|\.ps1)?\b[^\r\n]{0,120}\s-p\b', + '(?im)\bcodex(?:\.exe|\.cmd|\.ps1)?\s+exec\b' + ) + $scannerCases = @( + [pscustomobject]@{ Name = 'Copilot prompt mode'; Content = 'copilot -p "grade this"'; Expected = $true }, + [pscustomobject]@{ Name = 'Claude prompt mode'; Content = '& claude -p "run eval"'; Expected = $true }, + [pscustomobject]@{ Name = 'Codex execution'; Content = 'codex exec "run eval"'; Expected = $true }, + [pscustomobject]@{ Name = 'Gemini process'; Content = 'Start-Process gemini -ArgumentList "-p", "grade"'; Expected = $true }, + [pscustomobject]@{ Name = 'Documentation prose'; Content = 'Supports Copilot, Claude, Codex, and Gemini skill formats.'; Expected = $false } + ) + foreach ($case in $scannerCases) { + $matched = @($launchPatterns | Where-Object { $case.Content -match $_ }).Count -gt 0 + if ($matched -ne $case.Expected) { + throw "AI/LLM automation scanner failed '$($case.Name)'." + } + } + $launchFindings = foreach ($path in $automationPaths) { + $content = Get-FileText -RepoRoot $repoRoot -RelativePath $path -GitRef $Ref + foreach ($pattern in $launchPatterns) { + if ($content -match $pattern) { + $path + break + } + } + } + if (@($launchFindings).Count -gt 0) { + throw "Repository automation must not launch AI/LLM CLIs: $(@($launchFindings | Sort-Object -Unique) -join ', ')" + } + + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle '## AI/LLM Evaluation Automation Prohibition' + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle 'this repository does not provide an opt-in path around that rule.' + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle 'Model-backed comparisons are not a repository completion gate.' + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle 'This rule is Priority 1.' + Assert-Contains -Name 'README.md' -Content $readme -Needle 'There is no repository opt-in switch.' + Assert-Contains -Name 'README.md' -Content $readme -Needle 'validate-skill-templates.ps1 -MetadataOnly' +} + Add-ValidationResult -Results $results -Name 'dotnet-test encodes role-specific Codebelt xUnit migration and bootstrap contracts' -Action { $skill = Get-FileText -RepoRoot $repoRoot -RelativePath 'skills/dotnet-test/SKILL.md' -GitRef $Ref $forms = Get-FileText -RepoRoot $repoRoot -RelativePath 'skills/dotnet-test/FORMS.md' -GitRef $Ref @@ -1325,6 +1442,9 @@ Add-ValidationResult -Results $results -Name 'Git visual commits skill enforces $readme = Get-FileText -RepoRoot $repoRoot -RelativePath 'README.md' -GitRef $Ref Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle 'automatic trigger for this skill, not as a casual hint.' + Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle '### Invocation Routing Lock' + Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle 'Interpret `Please do a git bot commit yolo` as `git bot commit` identity plus auto-approval for the full current worktree.' + Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle '`yolo` is not the commit message, and it does not request a changelog.' Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle '### Full-Skill Read and Subject Lock' Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle 'Before running any Git command or composing a subject, read this `SKILL.md` completely from the first line through EOF.' Assert-Contains -Name 'git-visual-commits/SKILL.md' -Content $skill -Needle 'If a tool truncates the file, continue from the first unread line until EOF before proceeding.' @@ -1435,8 +1555,16 @@ Add-ValidationResult -Results $results -Name 'Git visual commits skill enforces Assert-Contains -Name 'git-visual-commits/evals/evals.json' -Content $evals -Needle 'Runs scripts/validate-commit-subject.ps1 before showing the corrected subject and again immediately before passing it to Git' Assert-Contains -Name 'git-visual-commits/evals/evals.json' -Content $evals -Needle 'Triggers the single-category context quality gate because more than one file is being placed in one category' Assert-Contains -Name 'git-visual-commits/evals/evals.json' -Content $evals -Needle 'Recognizes exactly one changed file as the explicit exception and skips the single-category context quality gate' + Assert-Contains -Name 'git-visual-commits/evals/evals.json' -Content $evals -Needle 'Please do a git bot commit yolo.' + Assert-Contains -Name 'git-visual-commits/evals/evals.json' -Content $evals -Needle 'Does not replace bot identity with a human-authored commit plus a Co-authored-by trailer' Assert-Contains -Name 'README.md' -Content $readme -Needle '**Single-category context gate**' Assert-Contains -Name 'README.md' -Content $readme -Needle 'Multi-file plans that initially collapse to one category also require a visible full-context quality gate' + Assert-Contains -Name 'README.md' -Content $readme -Needle '**Authoritative command routing**' + Assert-Contains -Name 'README.md' -Content $readme -Needle '**CLI override remains deterministic**' + $agents = Get-FileText -RepoRoot $repoRoot -RelativePath 'AGENTS.md' -GitRef $Ref + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle '### Commit Skill Routing' + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle 'invoke `git-visual-commits` before responding to the request or running Git commands for that commit workflow' + Assert-Contains -Name 'AGENTS.md' -Content $agents -Needle 'Do not route the request to changelog or release-note skills' } Add-ValidationResult -Results $results -Name 'Git visual squash summary skill stays self-contained and shares commit language rules' -Action { @@ -1497,6 +1625,9 @@ Add-ValidationResult -Results $results -Name 'Git keep a changelog skill updates $entityResolverTests = Get-FileText -RepoRoot $repoRoot -RelativePath 'skills/git-keep-a-changelog/scripts/test-resolve-release-entity.ps1' -GitRef $Ref Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'Create or update `CHANGELOG.md` directly, then stop for user review.' + Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'Bare `yolo` / `auto`, `git bot commit yolo`, and other commit-execution requests do not activate this skill' + Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'within an explicit changelog or release-note request' + Assert-NotContains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'Trigger phrases: "finalize", "ready to release", "rtr", "release" (especially with version branches like v0.3.1/...), "yolo", "auto".' Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'If `CHANGELOG.md` does not exist, create a compliant one before' Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'Read full commit subjects and bodies before writing the changelog.' Assert-Contains -Name 'git-keep-a-changelog/SKILL.md' -Content $skill -Needle 'If the current branch starts with a version hint such as `v0.3.0/`,' @@ -1552,6 +1683,8 @@ Add-ValidationResult -Results $results -Name 'Git keep a changelog skill updates Assert-Contains -Name 'git-keep-a-changelog/evals/evals.json' -Content $evals -Needle 'Treats the merge-base as an excluded boundary rather than the first commit of the concrete release' Assert-Contains -Name 'git-keep-a-changelog/evals/evals.json' -Content $evals -Needle 'Does not let yolo mode widen committed history or include the v10.0.9 boundary commit' Assert-Contains -Name 'git-keep-a-changelog/evals/evals.json' -Content $evals -Needle 'Runs scripts/resolve-release-entity.ps1 for the path-backed dotnet-test entity and uses its Added classification' + Assert-Contains -Name 'git-keep-a-changelog/evals/evals.json' -Content $evals -Needle 'Does not select git-keep-a-changelog from bare yolo wording inside a git bot commit request' + Assert-Contains -Name 'README.md' -Content (Get-FileText -RepoRoot $repoRoot -RelativePath 'README.md' -GitRef $Ref) -Needle '**Trigger isolation**' if ([string]::IsNullOrWhiteSpace($Ref)) { & (Join-Path $repoRoot 'skills/git-keep-a-changelog/scripts/test-resolve-release-scope.ps1') | Out-Null @@ -1913,24 +2046,8 @@ if ($Full) { Write-Host '[SKIP] DocFX digest regression suites (use -Full to run skills/dotnet-docfx-digest/scripts/test-quality.ps1 and test-project-scoped.ps1)' } -$passed = @($results | Where-Object { $_.Status -eq 'PASS' }).Count -$failed = @($results | Where-Object { $_.Status -eq 'FAIL' }).Count -$label = if ([string]::IsNullOrWhiteSpace($Ref)) { 'WORKTREE' } else { $Ref } $mode = if ($Full) { 'FULL' } else { 'FAST' } - -Write-Host ("Validation target: {0}" -f $label) -Write-Host ("Validation mode: {0}" -f $mode) -Write-Host ("Passed: {0}" -f $passed) -Write-Host ("Failed: {0}" -f $failed) -Write-Host '' - -foreach ($result in $results) { - $prefix = if ($result.Status -eq 'PASS') { '[PASS]' } else { '[FAIL]' } - Write-Host ("{0} {1}" -f $prefix, $result.Name) - if ($result.Status -eq 'FAIL') { - Write-Host (" {0}" -f $result.Details) - } -} +$failed = Write-ValidationSummary -Results $results -GitRef $Ref -Mode $mode if ($failed -gt 0) { exit 1 From 69be3fc7ffb6e41ab325bae39d6f33b4b50ea930 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 02:44:47 +0200 Subject: [PATCH 04/41] =?UTF-8?q?=E2=9C=A8=20introduce=20dotnet-segregated?= =?UTF-8?q?-assets=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new skill for migrating ASP.NET Core web applications to serve static assets from a separate hardened static-content host (Codebelt Static Content Provider) while keeping conventional wwwroot authoring. This achieves architectural separation of concerns: developers keep editing in the familiar wwwroot location, but deployed static content is decoupled from business logic, enabling independent deployment, scaling, and cache control. The skill includes a deterministic runner that inspects static-asset topology, classifies app-owned vs shared CDN assets, detects risky scenarios (Blazor, RCL, generated assets), proves the publish invariant, and orchestrates local development with a hardened origin container and production image. Includes comprehensive reference docs, eval cases across diverse scenarios (MVC, Blazor, RCL, frontend-build, Cuemon-equipped apps), and validation scripts. --- README.md | 23 + skills/dotnet-segregated-assets/FORMS.md | 113 ++ skills/dotnet-segregated-assets/SKILL.md | 129 ++ .../dotnet-segregated-assets/evals/evals.json | 202 +++ .../Northwind.DesignSystem.csproj | 13 + .../wwwroot/design-system.css | 1 + .../src/Northwind.Web/Northwind.Web.csproj | 13 + .../blazor-rcl/src/Northwind.Web/Program.cs | 5 + .../src/Northwind.Web/wwwroot/css/app.css | 1 + .../files/conventional-mvc/Contoso.Web.csproj | 9 + .../Controllers/HomeController.cs | 8 + .../evals/files/conventional-mvc/Program.cs | 10 + .../Properties/launchSettings.json | 9 + .../conventional-mvc/Views/Home/Index.cshtml | 4 + .../Views/Shared/_Layout.cshtml | 13 + .../Views/_ViewImports.cshtml | 1 + .../conventional-mvc/Views/_ViewStart.cshtml | 1 + .../conventional-mvc/wwwroot/css/site.css | 1 + .../conventional-mvc/wwwroot/favicon.ico | 1 + .../files/conventional-mvc/wwwroot/js/site.js | 1 + .../evals/files/cuemon-app/Program.cs | 13 + .../evals/files/cuemon-app/Tolk.Web.csproj | 12 + .../cuemon-app/Views/Shared/_Layout.cshtml | 8 + .../files/cuemon-app/wwwroot/css/site.css | 1 + .../files/existing-compose/Orders.Web.csproj | 3 + .../evals/files/existing-compose/Program.cs | 4 + .../files/existing-compose/docker-compose.yml | 7 + .../existing-compose/wwwroot/css/site.css | 1 + .../evals/files/frontend-build/Program.cs | 4 + .../frontend-build/Storefront.Web.csproj | 3 + .../evals/files/frontend-build/assets/main.js | 1 + .../evals/files/frontend-build/package.json | 10 + .../evals/files/frontend-build/vite.config.js | 1 + .../files/frontend-build/wwwroot/.gitkeep | 1 + .../evals/files/multi-project/Acme.slnx | 5 + .../src/Acme.Api/Acme.Api.csproj | 3 + .../multi-project/src/Acme.Api/Program.cs | 3 + .../src/Acme.Core/Acme.Core.csproj | 3 + .../multi-project/src/Acme.Core/Class1.cs | 1 + .../src/Acme.Site/Acme.Site.csproj | 3 + .../multi-project/src/Acme.Site/Program.cs | 4 + .../src/Acme.Site/wwwroot/css/site.css | 1 + .../evals/files/no-cuemon/Ledger.Web.csproj | 3 + .../evals/files/no-cuemon/Program.cs | 7 + .../evals/files/no-cuemon/appsettings.json | 1 + .../files/no-cuemon/wwwroot/css/site.css | 1 + .../files/segregated-app/Assets.Dockerfile | 3 + .../files/segregated-app/Fabrikam.Web.csproj | 14 + .../evals/files/segregated-app/Program.cs | 8 + .../Properties/launchSettings.json | 18 + .../compose.segregated-assets.yml | 12 + .../files/segregated-app/wwwroot/css/site.css | 1 + .../files/segregated-app/wwwroot/js/site.js | 1 + .../files/with-cdn/shared-assets/README.md | 4 + .../with-cdn/shared-assets/fonts/brand.woff2 | 1 + .../shared-assets/vendor/design-tokens.css | 1 + .../with-cdn/src/Portal.Web/Portal.Web.csproj | 3 + .../files/with-cdn/src/Portal.Web/Program.cs | 4 + .../src/Portal.Web/wwwroot/css/portal.css | 1 + .../references/app-vs-cdn.md | 48 + .../references/local-development.md | 95 ++ .../references/production-image.md | 94 ++ .../references/static-web-assets-guardrail.md | 40 + .../scripts/segregate-assets.cs | 1301 +++++++++++++++++ .../scripts/test-segregated-assets.ps1 | 87 ++ .../scripts/validate-skill.ps1 | 71 + 66 files changed, 2469 insertions(+) create mode 100644 skills/dotnet-segregated-assets/FORMS.md create mode 100644 skills/dotnet-segregated-assets/SKILL.md create mode 100644 skills/dotnet-segregated-assets/evals/evals.json create mode 100644 skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/Northwind.DesignSystem.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/wwwroot/design-system.css create mode 100644 skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/wwwroot/css/app.css create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Contoso.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Controllers/HomeController.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Properties/launchSettings.json create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Home/Index.cshtml create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Shared/_Layout.cshtml create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewImports.cshtml create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewStart.cshtml create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/favicon.ico create mode 100644 skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/js/site.js create mode 100644 skills/dotnet-segregated-assets/evals/files/cuemon-app/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/cuemon-app/Tolk.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/cuemon-app/Views/Shared/_Layout.cshtml create mode 100644 skills/dotnet-segregated-assets/evals/files/cuemon-app/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/existing-compose/Orders.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/existing-compose/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/existing-compose/docker-compose.yml create mode 100644 skills/dotnet-segregated-assets/evals/files/existing-compose/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/Storefront.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/assets/main.js create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/package.json create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/vite.config.js create mode 100644 skills/dotnet-segregated-assets/evals/files/frontend-build/wwwroot/.gitkeep create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/Acme.slnx create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Acme.Api.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Acme.Core.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Class1.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Acme.Site.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/no-cuemon/Ledger.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/no-cuemon/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/no-cuemon/appsettings.json create mode 100644 skills/dotnet-segregated-assets/evals/files/no-cuemon/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/Assets.Dockerfile create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/Fabrikam.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/Properties/launchSettings.json create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/css/site.css create mode 100644 skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/js/site.js create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/README.md create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/fonts/brand.woff2 create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/vendor/design-tokens.css create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Portal.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/wwwroot/css/portal.css create mode 100644 skills/dotnet-segregated-assets/references/app-vs-cdn.md create mode 100644 skills/dotnet-segregated-assets/references/local-development.md create mode 100644 skills/dotnet-segregated-assets/references/production-image.md create mode 100644 skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md create mode 100644 skills/dotnet-segregated-assets/scripts/segregate-assets.cs create mode 100644 skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 create mode 100644 skills/dotnet-segregated-assets/scripts/validate-skill.ps1 diff --git a/README.md b/README.md index 91ee72e..ec6886e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ npx skills add https://github.com/codebeltnet/agentic --skill dotnet-docfx-diges npx skills add https://github.com/codebeltnet/agentic --skill dotnet-test npx skills add https://github.com/codebeltnet/agentic --skill dotnet-benchmark npx skills add https://github.com/codebeltnet/agentic --skill dotnet-remote-testing +npx skills add https://github.com/codebeltnet/agentic --skill dotnet-segregated-assets npx skills add https://github.com/codebeltnet/agentic --skill agent-smith # npx skills add https://github.com/codebeltnet/agentic --skill another-skill ``` @@ -130,6 +131,7 @@ npx skills add https://github.com/codebeltnet/agentic --skill agent-smith | [dotnet-test](skills/dotnet-test/SKILL.md) | Bootstraps and refactors xUnit projects to Codebelt conventions. It deterministically inspects project roles, target frameworks, xUnit generation, package ownership, inheritance, application entry points—including Bootstrapper `MinimalConsoleProgram`, `MinimalWorkerProgram`, and `MinimalWebProgram` hosts—and every selected `WebApplicationFactory` usage; classifies ordinary unit, ASP.NET Core functional, and console/worker functional tests; modernizes xUnit v2 projects to xUnit v3 plus Microsoft Testing Platform without moving package ownership or changing frameworks; and resolves current stable compatible packages through NuGet-backed isolated compatibility-project restores, including the selected combined package set. Focused web tests use `WebApplicationTestFactory` with an explicit entrypoint-owned `ManagedWebApplicationFixture`, directly or through a narrow `Test`-derived harness; shared web fixtures use `WebApplicationTest` with `ManagedWebApplicationFixture`; focused console/worker tests use `ApplicationTestFactory` with `ManagedApplicationFixture`; and shared non-web fixtures use `ApplicationTest` with `ManagedApplicationFixture`. Deprecated blocking fixtures are migration inputs only and are never emitted because they are scheduled for removal. Functional migrations fail closed unless the chosen Codebelt pattern and managed fixture are present, the legacy or blocking fixture is absent, and test code does not reconstruct the production composition root with its own `WebApplication`, `TestServer`, or `HostBuilder`. Migrations preserve entrypoint-owned startup, host configuration, lazy start, clients, services, configuration, sync/async disposal, isolation, and existing test names, while fresh bootstraps add source-grounded behavior tests. Non-web tests stay in-process and require a resolvable Generic Host; test-only scope reports the exact production adaptation instead of silently rewriting startup or launching a process. | | [dotnet-benchmark](skills/dotnet-benchmark/SKILL.md) | Discovers, prioritizes, and authors trustworthy BenchmarkDotNet experiments for a .NET type following codebelt conventions and using the `Codebelt.Extensions.BenchmarkDotNet.Console` runner. It inspects implementation code, call sites, tests, existing benchmarks, and available profiles instead of benchmarking every public member; ranks likely high-impact operations; selects representative typical, boundary, scaling, and adverse cases; and rejects external-I/O or service-level questions that need profiling, macrobenchmarks, or load tests. It creates fair current-versus-candidate comparisons only when observable work is equivalent, uses baseline-free single-operation characterization when no honest comparator exists, prevents unrelated construction/formatting/equality/hash ratios, requires exact per-case correctness oracles plus a semantic preflight for truthful workload labels, hard-gates interpretation on a complete valid BenchmarkDotNet summary, preserves workload invariants such as selectivity and hit/miss ratios as sizes scale, distinguishes deferred pipeline creation from terminal/materialization work, and performs Release build, discovery listing, and dry execution before any explicit full run. Explicit `yolo` mode auto-accepts routine repo-derived defaults and the proposed plan, then proceeds through build/list/dry validation without confirmation churn; only a separate explicit human instruction can start a full performance run. Its runner preflight recognizes the standard Slim/runtime setup and explains when `SkipBenchmarksWithReports = true` plus a matching `reports/tuning/` artifact deliberately filters a benchmark, preventing needless class renames, disassembly, or tool thrash; after the first valid full result it stops unless deeper diagnostics could change a real engineering decision. Harness setup remains adaptive: it detects `.slnx`/`.sln`, CPM, existing `tuning/` projects, and a reusable `tooling/` runner, onboards only missing pieces, resolves package versions dynamically, and keeps the benchmark class in the SUT namespace. | | [dotnet-remote-testing](skills/dotnet-remote-testing/SKILL.md) | Run .NET tests inside a resolved remote Docker environment and return concise, structured results — Visual Studio's Remote Testing experience (choose an environment → run tests → see results) with the container plumbing hidden behind a deterministic runner (`scripts/remote-test.cs`) the skill orchestrates instead of composing ad-hoc `docker run` commands. It honors Microsoft's existing `testenvironments.json` version-1 contract (`name`, `localRoot`, `dockerImage`, `dockerFile`, with the either/or Docker-source rule), treats that file as authoritative when present, and reports WSL/SSH/unknown types as unsupported rather than converting or silently ignoring them. When no `testenvironments.json` exists it provides a zero-configuration experience built exclusively on official `mcr.microsoft.com/dotnet/sdk` images, discovering the currently supported LTS and STS channels plus the current preview from Microsoft's live `releases-index.json` using `support-phase`/`release-type` (never hardcoded version numbers or even/odd assumptions) and caching that metadata outside the repository for offline reuse. It prefers an exact `latest-sdk` image tag (stripping preview build metadata), validates the tag against Microsoft's registry, and pins each execution to the resolved immutable digest so results are reproducible across environment, image, digest, SDK, and architecture. Execution stages the source into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, mounts a persistent NuGet cache outside the repo, runs restore → build → test with structured TRX collection, classifies failures into distinct kinds (configuration, unsupported environment, Docker unavailable, image resolution, SDK incompatibility, staging, restore, compilation, test-host, test failure, result-processing, cleanup, cancellation, release-metadata) so infrastructure problems are never reported as failing unit tests, and always cleans up transient Docker resources. It never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and never silently falls back to running tests on the host. Docker is the only transport for now, designed so WSL/SSH can be added later without disturbing the deterministic Docker path, which is covered by a comprehensive built-in `--self-test` plus a PowerShell harness. | +| [dotnet-segregated-assets](skills/dotnet-segregated-assets/SKILL.md) | Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional `wwwroot` while deployed static content is served by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) — a separate asset host, not the web application. The skill orchestrates a bundled deterministic runner (`scripts/segregate-assets.cs`) that inspects the static-asset topology, distinguishes App assets (app-owned, authored in `wwwroot`) from shared CDN assets (reusable across applications, never duplicated into `wwwroot`), detects and escalates risky Blazor / Razor Class Library / generated Static Web Assets scenarios instead of blindly excluding them, and proves the publish invariant by publishing to an isolated temp directory. It adds an `http-segregated-assets` HTTP launch profile pointing App URLs at a local read-only origin (scheme-safe, never protocol-relative against an HTTP origin), a hardened local `web-cdn-origin:2.0.0` service mounting `wwwroot` into `/cdnroot` read-only (non-root, read-only root filesystem, no privileged mode, no Docker socket), and a derived production image (`FROM codebeltnet/web-cdn-origin:2.0.0` + `COPY --chown=65532:65532 ./wwwroot/ /cdnroot/`). App-owned `wwwroot` is excluded from web publish with targeted `` metadata rather than the `StaticWebAssetsEnabled` global kill switch, keeping Razor Class Library (`_content`) and framework (`_framework`) assets intact. The motivation is architectural — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading — not HTTP/1.x domain sharding. It reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the app's own base-URL setting otherwise, never adding a Cuemon dependency just to migrate, and reconciles idempotently on re-run. | | [agent-smith](skills/agent-smith/SKILL.md) | Apply a rigorous, consistent, evidence-driven software-craftsmanship standard across a whole engineering task. Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | ### Copyable Install Commands @@ -243,6 +245,11 @@ npx skills add https://github.com/codebeltnet/agentic --skill dotnet-test ```bash npx skills add https://github.com/codebeltnet/agentic --skill dotnet-remote-testing ``` +`dotnet-segregated-assets` + +```bash +npx skills add https://github.com/codebeltnet/agentic --skill dotnet-segregated-assets +``` `agent-smith` ```bash @@ -689,6 +696,22 @@ Cross-platform .NET developers usually get Linux test feedback the slow way: pus - **No plumbing added, ever** — never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and always cleans up transient Docker resources — reporting exact identifiers if any remain - **Target-framework aware** — inspects the projects and `global.json`, refuses to pick an SDK that cannot build the requested target framework, and reports incompatibilities instead of editing the repository to force them - **Deterministic and tested** — the runner ships a comprehensive built-in `--self-test` plus a PowerShell harness covering configuration discovery, release parsing, environment selection, unsupported handling, image resolution, command planning, result parsing, failure classification, cancellation, and cleanup +### Why dotnet-segregated-assets? + +`wwwroot` is where every ASP.NET Core developer expects to author static files — editors, hot reload, and the SDK all assume it. But shipping those files inside the deployed web application couples static delivery to business logic, bloats the app artifact, and puts asset caching on the wrong surface. The right shape is architectural: keep authoring in `wwwroot`, but let a separate, hardened static-content host serve the files in production. + +**dotnet-segregated-assets** keeps that split honest. The skill is the orchestration layer — it understands intent, reads the repository's real conventions, and makes the edits — while the bundled deterministic runner (`scripts/segregate-assets.cs`) inspects the static-asset topology, classifies it, and *proves* the outcome instead of trusting a declaration that merely looks right. + +- **`wwwroot` stays the authoring root** — developers keep editing where they always did; no `approot`/`cdnroot` source folder, and never the removed 1.4 `ADD approot` Dockerfile pattern +- **App is not CDN** — app-owned assets (authored in `wwwroot`, served from a per-app asset host) are separated from shared CDN assets (reusable across applications, never duplicated into any app's `wwwroot`), and the skill always asks whether a CDN equivalent exists +- **Targeted exclusion, not a kill switch** — app-owned `wwwroot` is removed from web publish with ``, verified empirically to leave Razor Class Library (`_content`) and framework (`_framework`) assets intact — exactly what `StaticWebAssetsEnabled=false` would wrongly destroy +- **Proven, not assumed** — `verify --run-publish` publishes to an isolated temp directory and asserts app-owned `wwwroot` files are absent from the artifact; verification output never touches the repository +- **Safety guardrail over broken migrations** — Blazor, Blazor WebAssembly, Razor Class Library, scoped CSS, component JavaScript modules, and frontend-build scenarios are detected and escalated rather than blindly excluded; stopping to request an explicit generated-static-assets design is a successful outcome, not a failure +- **Scheme-safe local topology** — the `http-segregated-assets` profile points App URLs at an `http://localhost:` origin, never a protocol-relative or `https://localhost` URL that an HTTPS page would break against an HTTP-only origin +- **Hardened local origin** — the local `web-cdn-origin:2.0.0` service mounts `wwwroot` into `/cdnroot` read-only as a non-root user, with a read-only root filesystem, no privileged mode, no Docker socket, and only the required port exposed +- **Architectural motivation, stated honestly** — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading; never justified as HTTP/1.x domain sharding or extra browser connection parallelism +- **Adapts, never imposes** — it reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the application's own base-URL setting otherwise, and never adds a Cuemon dependency just to migrate +- **Idempotent and deterministic** — re-running reconciles existing segregation instead of duplicating MSBuild items, launch profiles, Compose services, or Dockerfiles, and the runner ships a built-in `--self-test` plus a PowerShell harness ### Why agent-smith? **agent-smith** applies one coherent engineering standard — *consistency is key* — across a whole task instead of bolting a review onto the end. Invoke it explicitly as `/agent-smith `; it also auto-triggers for engineering work such as architecture, implementation, code review, public API and compatibility analysis, testing, performance, skill authoring, security and DevSecOps, CI/CD, delivery, and governance. diff --git a/skills/dotnet-segregated-assets/FORMS.md b/skills/dotnet-segregated-assets/FORMS.md new file mode 100644 index 0000000..315719a --- /dev/null +++ b/skills/dotnet-segregated-assets/FORMS.md @@ -0,0 +1,113 @@ +# .NET Segregated Static Assets Input Form + +Collect only the fields that are still unresolved after running `segregate-assets.cs inspect` and reading the repository. Most fields have a computed or recommended default — present it first and accept a blank answer as acceptance. Prefer the host's native structured input controls when they are available; otherwise use the deterministic plain-text fallback described under **Presentation rules** without changing field order, defaults, recommended choices, or the final confirmation. + +The single question you must always resolve is `cdn_equivalent`. It changes whether a second local origin is provisioned and how shared assets are referenced, and it must never be assumed. + +## Fields + +### web_project + +- **type:** single-choice +- **prompt:** Which web project should be segregated? +- **choices:** The `Microsoft.NET.Sdk.Web` projects reported by `segregate-assets.cs inspect` +- **default:** The single resolved web project, or the project named by the user (Recommended) +- **required:** true +- **show_when:** `inspect` reports classification `Ambiguous` (more than one web project) + +### cdn_equivalent + +- **type:** single-choice +- **prompt:** Does a shared CDN / reusable-asset equivalent exist for this application (fonts, icon libraries, JavaScript/CSS frameworks, design-system assets shared across applications)? +- **choices:** + - No — only this application's own assets (Recommended) + - Yes — a shared/CDN asset source exists +- **default:** No — only this application's own assets (Recommended) +- **required:** true + +### cdn_source + +- **type:** text +- **prompt:** Where does the shared/CDN asset content live today (repository, artifact, existing host, or local path)? Do not assume it belongs in this application's wwwroot. +- **default:** (none) +- **required:** false +- **show_when:** `cdn_equivalent` is `Yes` + +### asset_configuration + +- **type:** single-choice +- **prompt:** How are App/CDN asset URLs generated in this application? +- **choices:** + - Cuemon App/Cdn tag helpers already present (Recommended when detected) + - The application's own asset base-URL option/setting + - No abstraction yet — introduce a minimal app-owned base-URL setting +- **default:** Auto-detected from inspection (Cuemon when `AppTagHelperOptions`/`CdnTagHelperOptions` are found; otherwise the app's own setting) (Recommended) +- **required:** true + +### app_origin_port + +- **type:** text +- **prompt:** Which host port should the local App Static Content Provider use? +- **choices:** + - `8080` (Recommended) + - A custom free port (resolve collisions against existing launchSettings/Compose) +- **default:** `8080` (Recommended) +- **required:** true + +### cdn_origin_port + +- **type:** text +- **prompt:** Which host port should the local CDN Static Content Provider use? +- **choices:** + - `8081` (Recommended) + - A custom free port different from the App origin port +- **default:** `8081` (Recommended) +- **required:** false +- **show_when:** `cdn_equivalent` is `Yes` + +### deployed_app_host + +- **type:** text +- **prompt:** What is the deployed App asset host (HTTPS), if known? Used only for deployed configuration. +- **default:** (leave as a documented placeholder such as `assets.example.com` when unknown) +- **required:** false + +### deployed_cdn_host + +- **type:** text +- **prompt:** What is the deployed shared/CDN asset host (HTTPS), if known? +- **default:** (leave as a documented placeholder such as `cdn.example.com` when unknown) +- **required:** false +- **show_when:** `cdn_equivalent` is `Yes` + +### production_image + +- **type:** single-choice +- **prompt:** Build a derived `codebeltnet/web-cdn-origin:2.0.0` production asset image for this application's assets? +- **choices:** + - Yes — add a derived asset image (Recommended) + - No — configuration and local topology only +- **default:** Yes — add a derived asset image (Recommended) +- **required:** true + +### confirmation + +- **type:** single-choice +- **prompt:** Apply App-asset segregation (and CDN provisioning when applicable) using the summarized project, ports, hosts, and configuration, then verify the publish invariant? +- **choices:** + - Yes (Recommended) + - No +- **default:** Yes (Recommended) +- **required:** true + +## Presentation rules + +- Run `segregate-assets.cs inspect` first and infer explicit answers from its output and the repository; do not ask questions the inspection already answers. +- Ask one unresolved field at a time. Never bundle multiple questions. +- Present the recommended/default choice first and suffix it with `(Recommended)`. +- For `web_project`, offer the discovered project names as selectable choices; when exactly one web project applies, select it without asking. +- For `cdn_equivalent`, always ask if it is unresolved — never assume shared assets belong in the application's wwwroot. +- For `text` fields with a computed default (ports, hosts), offer the computed value as a selectable choice alongside free text, and treat a blank response as accepting the shown value. +- If native structured input widgets are unavailable, follow this deterministic plain-text fallback instead of improvising your own questioning style: start immediately with `Field: `, then a one-line prompt, then numbered choices (recommended first), and accept a blank reply as the default. Do not add a conversational preamble, and do not switch interaction styles mid-collection. Consistency matters more than creativity during parameter collection. +- Respect `show_when` conditions: skip `cdn_source`, `cdn_origin_port`, and `deployed_cdn_host` entirely when `cdn_equivalent` is `No`; skip `web_project` when only one web project exists. +- After all fields are resolved, summarize the exact project, App/CDN ports, deployed hosts, asset-configuration approach, and whether a production image will be built, then ask `confirmation`. diff --git a/skills/dotnet-segregated-assets/SKILL.md b/skills/dotnet-segregated-assets/SKILL.md new file mode 100644 index 0000000..3dd8d0c --- /dev/null +++ b/skills/dotnet-segregated-assets/SKILL.md @@ -0,0 +1,129 @@ +--- +name: dotnet-segregated-assets +description: > + Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional wwwroot, while deployed static content is served by Codebelt Static Content Provider (codebeltnet/web-cdn-origin:2.0.0), a separate asset host rather than the web app. Use when asked to segregate static assets, move wwwroot off the web app, serve static files from a separate asset host, or stop shipping wwwroot with the app. Distinguishes App assets (app-owned, from wwwroot) from shared CDN assets, adds an http-segregated-assets launch profile, derives a production asset image, and excludes app-owned wwwroot from publish with targeted MSBuild metadata rather than disabling Static Web Assets globally. Escalates risky Blazor, RCL, and generated Static Web Assets scenarios. Do NOT use to build a general-purpose CDN or migrate non-ASP.NET static sites. +compatibility: > + Requires the .NET SDK 10+ and PowerShell 7+. Docker is optional (only for the local origin). +--- + +# .NET Segregated Static Assets + +Keep the developer experience developers already know — author static files in `wwwroot` — while making the **deployed** web application stop serving and stop shipping those files. In production the static content is delivered by **Codebelt Static Content Provider** (`codebeltnet/web-cdn-origin:2.0.0`), a separately built and deployed asset host, not by the ASP.NET Core business application. + +The one invariant everything else follows from: + +> `wwwroot` remains the application's conventional static-content **authoring root**, but it is **not** part of the deployed web application's static-content serving responsibility. + +## Architecture: you orchestrate, the runner inspects and verifies + +The bundled .NET file-based program `scripts/segregate-assets.cs` is the **deterministic layer**. You are the **orchestration layer**: understand intent, resolve the repository's real conventions, make the edits, and resolve the App-vs-CDN semantic choices. Route inspection and verification through the runner instead of guessing: + +``` +dotnet run --file "/scripts/segregate-assets.cs" -- [options] +``` + +Commands: `inspect` (discover web projects, classify static-asset topology, detect risky Static Web Assets, report existing segregation), `plan` (resolve the target project, ports, and the ordered decision list without writing files), `verify` (publish to an isolated temp directory and prove app-owned `wwwroot` is absent, plus validate the local origin topology), and `--self-test`. Add `--json` to any command for machine-readable output. The runner never edits the repository — it inspects and verifies; **you** apply edits using the literal templates in `references/`, adapted to the project. + +## Critical + +- **Do not replace `wwwroot`.** Developers keep authoring there. Never introduce an `approot`, `cdnroot`, or `staticroot` source folder for app-owned assets, and never resurrect the removed 1.4 `ADD approot` / `WORKDIR /cdnroot` Dockerfile pattern. Version 2.0 owns its `/cdnroot`, port, runtime user, and working directory. +- **Exclude app-owned `wwwroot` from publish with targeted metadata, not a global kill switch.** Prefer ``. Do **not** default to `StaticWebAssetsEnabled` = false: that also drops Razor Class Library (`_content/…`) and framework (`_framework/…`) assets and can break the app. See `references/static-web-assets-guardrail.md`. +- **Never claim a declaration works because it looks right — prove it.** Application-owned files from source `wwwroot` must be absent from the publish artifact. Confirm with `verify --run-publish` against an isolated temp output; never write verification output into the repository. +- **App is not CDN.** App assets are app-owned and authored in `wwwroot`; CDN assets are shared across applications and must never be duplicated into an application's `wwwroot`. Always ask whether a CDN/shared-asset equivalent exists (`FORMS.md`). +- **Keep local URLs scheme-safe.** The local origin speaks HTTP on a host port. Point App asset URLs at `http://localhost:` from an HTTP application profile. Never emit a protocol-relative (`//localhost:`) or `https://localhost:` URL that an HTTPS page would turn into an HTTPS request against an HTTP-only origin. +- **Motivation is architectural, not a connection trick.** The value is segregation of duties, independent deployment and scaling, explicit cache behavior, origin/CDN offloading, and a reduced application artifact — **not** HTTP/1.x domain sharding or extra browser connection parallelism (which is counter-productive on HTTP/2 and HTTP/3). + +## Step 1: Inspect before changing anything + +``` +dotnet run --file "/scripts/segregate-assets.cs" -- inspect --repo-root "" [--project ] --json +``` + +The runner returns candidate web projects, the resolved target, a `classification`, risk signals, and existing-segregation flags. Act on the classification: + +| Classification | Meaning | What you do | +|---|---|---| +| `Simple` | Physical `wwwroot`, no risky generated assets | Apply App-asset segregation (Steps 3–6). | +| `RiskyGeneratedAssets` | Blazor/RCL/scoped-CSS/frontend-build/etc. detected | **Stop and escalate** (Step 2 guardrail). Do not blanket-exclude. | +| `AlreadySegregated` | Publish exclusion + segregated profile present | Reconcile idempotently — do not duplicate. | +| `Ambiguous` | Multiple web projects | Ask which project; pass `--project`. | +| `NoWwwroot` | Web app without `wwwroot` | Only configure CDN consumption if a CDN equivalent exists. | +| `NotAWebApp` | No `Microsoft.NET.Sdk.Web` project | Confirm the target repository. | + +## Step 2: Collect intent and honor the guardrail + +Read `FORMS.md` and infer what you can. The one question you must always resolve is whether a **CDN/shared-asset equivalent exists** — because it changes whether you provision a second origin and how shared assets are referenced. Never assume shared assets belong in the application's `wwwroot`. + +If `inspect` reports `RiskyGeneratedAssets`, treat it as a **compatibility guardrail**. A blanket `wwwroot` publish exclusion or a global Static Web Assets disable can break Blazor Web Apps, Blazor WebAssembly, `_framework`/`_content` assets, Razor Class Libraries, scoped CSS, component JS modules, or frontend-generated output. If you cannot establish a safe, deterministic way to materialize the required generated output into the external asset artifact while preserving correct runtime references, **stop and report that the project needs an explicit generated-static-assets segregation design.** That is a successful safety outcome, not a failure. Details: `references/static-web-assets-guardrail.md`. + +## Step 3: Segregate App assets + +For a `Simple` project, apply these idempotently (skip any the runner already reports as present). All literal templates live in `references/` — read them and adapt paths, ports, and naming to the repository's conventions rather than copying blindly. + +1. **Exclude app-owned `wwwroot` from web publish** — add the targeted `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` item to the web project. (`references/production-image.md`) +2. **Add a segregated launch profile** — a new `http-segregated-assets` profile that keeps the app in Development but points App asset URLs at the local origin over HTTP. Preserve the ordinary Development profile untouched. (`references/local-development.md`) +3. **Provide a local Static Content Provider** — run `codebeltnet/web-cdn-origin:2.0.0` mounting the app's existing `wwwroot` into `/cdnroot` **read-only**, on a host port, with a hardened posture (non-root, read-only root filesystem where practical, no privileged mode, no Docker socket, no extra capabilities, only the required port). Prefer a tiny dedicated Compose file unless the repo already has an orchestration mechanism to extend. (`references/local-development.md`) + +## Step 4: Configure App URL generation + +Adapt to the application's existing URL-generation abstraction; do **not** add a Cuemon dependency just to implement this skill. + +- **If Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` are already present:** set App local `BaseUrl` to the local App origin (`localhost:`) with `Scheme = Http`; set CDN local `BaseUrl` to the local shared origin with `Scheme = Http` when a CDN equivalent exists; use `Scheme = Https` absolute URLs for deployed configuration. The default `Scheme = Relative` emits protocol-relative `//` URLs — unsafe against an HTTP-only local origin, so make the local scheme explicit. +- **If Cuemon is not used:** configure the application's own asset base-URL setting (for example a `SegregatedAssets:App:BaseUrl` / `:Scheme` option the app already reads, or its equivalent) and drive it from the launch profile's environment variables. + +See `references/app-vs-cdn.md`. + +## Step 5: CDN assets (only when an equivalent exists) + +If a shared CDN equivalent exists, determine its existing source/configuration. When its content is locally available, provision a **second** local origin on a different host port: + +``` +localhost: -> web-cdn-origin:2.0.0 -> /wwwroot +localhost: -> web-cdn-origin:2.0.0 -> +``` + +Point CDN asset URLs at that origin locally and at the shared/CDN host in deployment. Never copy CDN assets into the application's `wwwroot`. + +## Step 6: Build the production asset image and verify + +Add a derived image that ships the **actual final asset output** (run the frontend build first if the app generates its `wwwroot`): + +```dockerfile +FROM codebeltnet/web-cdn-origin:2.0.0 + +COPY --chown=65532:65532 ./wwwroot/ /cdnroot/ +``` + +Do not override the base image's `/cdnroot`, port, runtime user (`65532`), or working directory without a demonstrated requirement, and prefer `COPY` over `ADD`. Where CI/CD already builds once and promotes artifacts, emit the static assets as their own artifact and package them into the image rather than rebuilding. Document the integration point; do not redesign CI/CD. (`references/production-image.md`) + +Then prove the invariant: + +``` +dotnet run --file "/scripts/segregate-assets.cs" -- verify --repo-root "" -p "" --run-publish --check-local --json +``` + +`verify` must report the app-owned `wwwroot` files ABSENT from the publish artifact (shared `_content`/`_framework` assets are allowed to remain) and the local topology as scheme-safe and hardened. + +## Step 7: Document the two workflows + +Update the application's documentation to state that deployed static content is intentionally served by Codebelt Static Content Provider, not the ASP.NET Core business application, and that `wwwroot` remains because it is the conventional, tooling-friendly authoring location. Show Normal Development, Segregated Development, and Deployment flows (and a separate parallel flow for shared CDN assets when one exists). Template in `references/production-image.md`. + +## Idempotency + +Running the skill again on a configured app must not create duplicate MSBuild items, launch profiles, Compose services, Dockerfiles, or documentation sections, must not increment ports unnecessarily, and must not overwrite customized URLs or introduce a competing asset-configuration system. Use `inspect` to detect existing segregation and reconcile it. + +## What this skill must never do + +- Replace `wwwroot` with an `approot`/`cdnroot` source folder, or reintroduce the 1.4 `ADD approot` pattern. +- Default to `StaticWebAssetsEnabled` = false, or blindly delete `MapStaticAssets`/`UseStaticFiles` without analysis. +- Duplicate CDN/shared assets into the application's `wwwroot`, or conflate App and CDN assets. +- Add a Cuemon dependency to a project that does not already use it. +- Claim a `commandName: Project` launch profile starts sidecar containers, or claim the separation improves performance through domain sharding. +- Write verification output into the repository, or force a partially broken migration when a generated-static-assets design is required. + +## References + +- `references/app-vs-cdn.md` — App vs CDN semantics, Cuemon tag-helper mapping, and the architectural motivation. +- `references/local-development.md` — the segregated launch profile and the local Static Content Provider (Compose) topology and security posture. +- `references/production-image.md` — the derived `web-cdn-origin` image, publish exclusion, CI/CD integration, and the documentation template. +- `references/static-web-assets-guardrail.md` — detecting and safely handling Blazor/RCL/generated Static Web Assets scenarios. diff --git a/skills/dotnet-segregated-assets/evals/evals.json b/skills/dotnet-segregated-assets/evals/evals.json new file mode 100644 index 0000000..4c6deb3 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/evals.json @@ -0,0 +1,202 @@ +{ + "skill_name": "dotnet-segregated-assets", + "evals": [ + { + "id": 1, + "prompt": "This is a conventional ASP.NET Core MVC app (Contoso.Web) with a normal wwwroot folder. We don't have any shared CDN — it's just this app's own CSS, JS, and favicon. Set it up so the deployed app doesn't serve or ship wwwroot itself; the static files should come from Codebelt Static Content Provider instead, but I still want to keep editing files in wwwroot like normal.", + "expected_output": "The skill runs segregate-assets.cs inspect, classifies the project as Simple, confirms no CDN equivalent, and applies App-asset segregation: a targeted item, an http-segregated-assets launch profile pointing App URLs at http://localhost:8080, a local codebeltnet/web-cdn-origin:2.0.0 service mounting wwwroot into /cdnroot read-only, a derived FROM codebeltnet/web-cdn-origin:2.0.0 production image, and documentation. wwwroot stays as the authoring root.", + "expectations": [ + "Runs the bundled runner (segregate-assets.cs) to inspect topology instead of guessing", + "Keeps wwwroot as the authoring root and does NOT introduce an approot/cdnroot/staticroot source folder", + "Adds a targeted item", + "Adds a new http-segregated-assets HTTP launch profile without altering the existing Development profile", + "Provisions a local codebeltnet/web-cdn-origin:2.0.0 origin mounting wwwroot into /cdnroot read-only", + "Adds a derived production image: FROM codebeltnet/web-cdn-origin:2.0.0 + COPY --chown=65532:65532 ./wwwroot/ /cdnroot/", + "NEGATIVE: does not set false", + "NEGATIVE: does not resurrect the 1.4 ADD approot / WORKDIR /cdnroot Dockerfile pattern", + "NEGATIVE: does not blindly delete MapStaticAssets" + ], + "files": [ + "evals/files/conventional-mvc/Contoso.Web.csproj", + "evals/files/conventional-mvc/Program.cs", + "evals/files/conventional-mvc/Controllers/HomeController.cs", + "evals/files/conventional-mvc/Views/Shared/_Layout.cshtml", + "evals/files/conventional-mvc/Properties/launchSettings.json", + "evals/files/conventional-mvc/wwwroot/css/site.css", + "evals/files/conventional-mvc/wwwroot/js/site.js", + "evals/files/conventional-mvc/wwwroot/favicon.ico" + ] + }, + { + "id": 2, + "prompt": "Portal.Web has its own branding CSS in wwwroot, but we also keep a shared design system (fonts, design tokens, vendored libraries) under shared-assets/ that several of our apps use. Segregate the static assets and make sure the shared stuff is treated as reusable CDN content, not copied into the app.", + "expected_output": "The skill distinguishes App assets (Portal.Web/wwwroot) from CDN assets (shared-assets/), asks/confirms the CDN equivalent exists, configures App-asset segregation for the app's own wwwroot, provisions a second local origin (host port 8081) from the shared-assets root for CDN content, points CDN URLs at that origin locally and at the shared CDN host in deployment, and never duplicates shared-assets into Portal.Web/wwwroot.", + "expectations": [ + "Explicitly determines that a CDN/shared-asset equivalent exists (shared-assets/)", + "Treats shared-assets/ as CDN content with its own source, not part of the app's wwwroot", + "Provisions a second local origin on a different host port (e.g. 8081) for the shared/CDN content", + "Configures App URLs and CDN URLs against their respective origins locally and hosts in deployment", + "NEGATIVE: does not copy or duplicate shared-assets content into Portal.Web/wwwroot", + "NEGATIVE: does not conflate App and CDN assets into a single origin/host" + ], + "files": [ + "evals/files/with-cdn/src/Portal.Web/Portal.Web.csproj", + "evals/files/with-cdn/src/Portal.Web/Program.cs", + "evals/files/with-cdn/src/Portal.Web/wwwroot/css/portal.css", + "evals/files/with-cdn/shared-assets/README.md", + "evals/files/with-cdn/shared-assets/fonts/brand.woff2", + "evals/files/with-cdn/shared-assets/vendor/design-tokens.css" + ] + }, + { + "id": 3, + "prompt": "Tolk.Web already uses Cuemon's app-href and cdn-src tag helpers with AppTagHelperOptions and CdnTagHelperOptions. Wire up the segregated topology so App assets come from a local origin during development and from HTTPS asset hosts in production.", + "expected_output": "The skill detects the existing Cuemon AppTagHelperOptions/CdnTagHelperOptions, sets App local BaseUrl to localhost: with Scheme = Http (never leaving the default Relative scheme against an HTTP-only origin), sets CDN local BaseUrl similarly when applicable, and uses Scheme = Https absolute URLs for deployed configuration, bound from the http-segregated-assets launch profile. It reuses the existing Cuemon abstraction rather than inventing a parallel one.", + "expectations": [ + "Detects and reuses the existing Cuemon AppTagHelperOptions/CdnTagHelperOptions configuration", + "Sets the App local Scheme explicitly to Http (not the default Relative) with BaseUrl localhost:", + "Uses Https absolute base URLs for deployed configuration", + "Drives the local values from the http-segregated-assets launch profile environment variables", + "NEGATIVE: does not emit a protocol-relative //localhost or https://localhost URL against the HTTP-only local origin", + "NEGATIVE: does not introduce a second competing asset-configuration system alongside Cuemon" + ], + "files": [ + "evals/files/cuemon-app/Tolk.Web.csproj", + "evals/files/cuemon-app/Program.cs", + "evals/files/cuemon-app/Views/Shared/_Layout.cshtml", + "evals/files/cuemon-app/wwwroot/css/site.css" + ] + }, + { + "id": 4, + "prompt": "Ledger.Web is a plain ASP.NET Core app — no Cuemon, no fancy tag helpers. It already reads an Assets:BaseUrl setting to prefix its asset URLs. Segregate its static assets to the Static Content Provider without adding new dependencies we don't need.", + "expected_output": "The skill adapts to the app's existing Assets:BaseUrl abstraction, drives it from the http-segregated-assets launch profile (http://localhost:) locally and deployed config in production, and applies the standard App-asset segregation (publish exclusion, local origin, production image) — without adding a Cuemon package reference.", + "expectations": [ + "Reuses the application's own Assets:BaseUrl configuration to prefix asset URLs", + "Applies the standard App-asset segregation using the existing abstraction", + "NEGATIVE: does not add a Cuemon (Cuemon.AspNetCore.Razor.TagHelpers) dependency to an app that does not already use it", + "NEGATIVE: does not introduce a second/competing asset-configuration system" + ], + "files": [ + "evals/files/no-cuemon/Ledger.Web.csproj", + "evals/files/no-cuemon/Program.cs", + "evals/files/no-cuemon/appsettings.json", + "evals/files/no-cuemon/wwwroot/css/site.css" + ] + }, + { + "id": 5, + "prompt": "Orders.Web already has a docker-compose.yml with our Postgres dev database. Add the local Static Content Provider for the app's wwwroot without throwing away or rewriting our existing compose setup.", + "expected_output": "The skill recognizes the existing docker-compose.yml orchestration and extends it (or adds a dedicated compose overlay) with an app-assets service based on codebeltnet/web-cdn-origin:2.0.0 mounting wwwroot into /cdnroot read-only, preserving the existing db service, instead of replacing the established mechanism.", + "expectations": [ + "Recognizes and preserves the existing docker-compose.yml and its db service", + "Extends the existing orchestration mechanism (or adds a clean overlay) rather than replacing it", + "Adds a hardened codebeltnet/web-cdn-origin:2.0.0 service mounting wwwroot into /cdnroot read-only", + "NEGATIVE: does not delete or overwrite the existing db service / compose conventions" + ], + "files": [ + "evals/files/existing-compose/Orders.Web.csproj", + "evals/files/existing-compose/Program.cs", + "evals/files/existing-compose/docker-compose.yml", + "evals/files/existing-compose/wwwroot/css/site.css" + ] + }, + { + "id": 6, + "prompt": "In this Acme solution, segregate the static assets for our website. There are a few projects in here.", + "expected_output": "The skill runs inspect, finds multiple Microsoft.NET.Sdk.Web projects (Acme.Api and Acme.Site), reports the ambiguity, and asks which web project to target (or resolves Acme.Site as the site) before applying changes to only that project — not to Acme.Api or the Acme.Core class library.", + "expectations": [ + "Runs inspect and detects more than one candidate web project", + "Resolves the target web project (asks or selects Acme.Site) instead of guessing or acting on all projects", + "Does not apply segregation to the Acme.Core class library or the unrelated Acme.Api", + "NEGATIVE: does not silently pick the wrong project or modify multiple projects without resolving the target" + ], + "files": [ + "evals/files/multi-project/Acme.slnx", + "evals/files/multi-project/src/Acme.Api/Acme.Api.csproj", + "evals/files/multi-project/src/Acme.Site/Acme.Site.csproj", + "evals/files/multi-project/src/Acme.Site/Program.cs", + "evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css", + "evals/files/multi-project/src/Acme.Core/Acme.Core.csproj" + ] + }, + { + "id": 7, + "prompt": "Storefront.Web builds its final wwwroot with a Vite bundler (npm run build writes into wwwroot). Segregate the static assets and make sure the production asset image ships the built output, not our source inputs.", + "expected_output": "The skill detects the frontend build pipeline (package.json + vite), preserves it, and ensures the derived web-cdn-origin image ships the actual generated wwwroot output (build runs before the image is assembled), rather than stale source inputs under assets/. It treats generated output as the artifact and documents the CI integration point.", + "expectations": [ + "Detects the frontend build pipeline (package.json build + bundler) rather than treating wwwroot as static source", + "Ensures the production asset image ships the generated wwwroot output, produced by running the build first", + "Preserves the existing generation pipeline and documents where it feeds the asset artifact", + "NEGATIVE: does not package stale source inputs (assets/) instead of the built output", + "NEGATIVE: does not delete or bypass the frontend build" + ], + "files": [ + "evals/files/frontend-build/Storefront.Web.csproj", + "evals/files/frontend-build/Program.cs", + "evals/files/frontend-build/package.json", + "evals/files/frontend-build/vite.config.js", + "evals/files/frontend-build/assets/main.js" + ] + }, + { + "id": 8, + "prompt": "Segregate the static assets for Northwind.Web. Just exclude wwwroot from publish and point everything at the asset host.", + "expected_output": "The skill runs inspect, detects that Northwind.Web references a Razor Class Library (Northwind.DesignSystem) that contributes _content/ static web assets, classifies the scenario as RiskyGeneratedAssets, and refuses to apply a blanket wwwroot exclusion or disable StaticWebAssetsEnabled. It explains that generated/RCL Static Web Assets would break, and either designs an explicit safe segregation or stops and reports that an explicit generated-static-assets segregation design is required.", + "expectations": [ + "Runs inspect and detects the Razor Class Library / generated Static Web Assets scenario (_content assets)", + "Classifies as risky and treats it as a compatibility guardrail", + "Explains that a blanket exclusion or global disable would break RCL/framework (_content/_framework) assets", + "Escalates safely: designs an explicit safe approach or stops and reports that a generated-static-assets segregation design is required (a successful safety outcome)", + "NEGATIVE: does not apply a blanket as if it were a simple physical wwwroot", + "NEGATIVE: does not set false", + "NEGATIVE: does not blindly delete MapStaticAssets or exclude _content/_framework assets" + ], + "files": [ + "evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj", + "evals/files/blazor-rcl/src/Northwind.Web/Program.cs", + "evals/files/blazor-rcl/src/Northwind.Web/wwwroot/css/app.css", + "evals/files/blazor-rcl/src/Northwind.DesignSystem/Northwind.DesignSystem.csproj", + "evals/files/blazor-rcl/src/Northwind.DesignSystem/wwwroot/design-system.css" + ] + }, + { + "id": 9, + "prompt": "Fabrikam.Web has already been set up for segregated assets. Prove that the app-owned wwwroot files really are absent from the published web application.", + "expected_output": "The skill runs segregate-assets.cs verify with --run-publish, which publishes Fabrikam.Web to an isolated temporary directory and confirms the application-owned wwwroot files (css/site.css, js/site.js) are ABSENT from the publish artifact, reporting PASS. It does not write verification output into the repository.", + "expectations": [ + "Runs the runner's verify command with --run-publish (isolated temp output) rather than eyeballing the csproj", + "Confirms application-owned wwwroot files are absent from the publish artifact and reports the invariant as satisfied", + "Allows shared _content/_framework assets to remain (they are not app-owned)", + "NEGATIVE: does not write publish/verification output into the repository", + "NEGATIVE: does not claim success from the declaration alone without publishing" + ], + "files": [ + "evals/files/segregated-app/Fabrikam.Web.csproj", + "evals/files/segregated-app/Program.cs", + "evals/files/segregated-app/Properties/launchSettings.json", + "evals/files/segregated-app/compose.segregated-assets.yml", + "evals/files/segregated-app/Assets.Dockerfile", + "evals/files/segregated-app/wwwroot/css/site.css", + "evals/files/segregated-app/wwwroot/js/site.js" + ] + }, + { + "id": 10, + "prompt": "Run the segregated-assets setup on Fabrikam.Web again — I think someone already did it but I want to be sure it's configured.", + "expected_output": "The skill runs inspect, detects existing segregation (publish exclusion + http-segregated-assets profile + compose service + derived Dockerfile), classifies it as AlreadySegregated, and reconciles idempotently — it does not create duplicate MSBuild items, launch profiles, compose services, or Dockerfiles, does not increment ports, and does not overwrite the existing configuration.", + "expectations": [ + "Runs inspect and detects the existing segregation (AlreadySegregated)", + "Reconciles idempotently without creating duplicate csproj items, profiles, compose services, or Dockerfiles", + "Does not increment ports unnecessarily or overwrite customized URLs", + "NEGATIVE: does not add a second competing asset configuration or duplicate the existing setup" + ], + "files": [ + "evals/files/segregated-app/Fabrikam.Web.csproj", + "evals/files/segregated-app/Properties/launchSettings.json", + "evals/files/segregated-app/compose.segregated-assets.yml", + "evals/files/segregated-app/Assets.Dockerfile" + ] + } + ] +} diff --git a/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/Northwind.DesignSystem.csproj b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/Northwind.DesignSystem.csproj new file mode 100644 index 0000000..eabcb10 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/Northwind.DesignSystem.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + true + + + + + + + diff --git a/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/wwwroot/design-system.css b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/wwwroot/design-system.css new file mode 100644 index 0000000..85b54a1 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.DesignSystem/wwwroot/design-system.css @@ -0,0 +1 @@ +/* shared design system tokens */ diff --git a/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj new file mode 100644 index 0000000..9de55b5 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Program.cs b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Program.cs new file mode 100644 index 0000000..92d5342 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/Program.cs @@ -0,0 +1,5 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "Northwind"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/wwwroot/css/app.css b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/wwwroot/css/app.css new file mode 100644 index 0000000..389d4e1 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/blazor-rcl/src/Northwind.Web/wwwroot/css/app.css @@ -0,0 +1 @@ +body { margin: 0; } diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Contoso.Web.csproj b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Contoso.Web.csproj new file mode 100644 index 0000000..aac4605 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Contoso.Web.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Controllers/HomeController.cs b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Controllers/HomeController.cs new file mode 100644 index 0000000..3679a9f --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Controllers/HomeController.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Contoso.Web.Controllers; + +public sealed class HomeController : Controller +{ + public IActionResult Index() => View(); +} diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Program.cs b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Program.cs new file mode 100644 index 0000000..9cc7ce2 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Program.cs @@ -0,0 +1,10 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.MapStaticAssets(); +app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); + +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Properties/launchSettings.json b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Properties/launchSettings.json new file mode 100644 index 0000000..6c041b4 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "applicationUrl": "http://localhost:5080", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + } + } +} diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Home/Index.cshtml b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Home/Index.cshtml new file mode 100644 index 0000000..8576917 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Home/Index.cshtml @@ -0,0 +1,4 @@ +@{ + ViewData["Title"] = "Home"; +} +

Contoso

diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Shared/_Layout.cshtml b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..04ad706 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/Shared/_Layout.cshtml @@ -0,0 +1,13 @@ + + + + + @ViewData["Title"] - Contoso + + + + + @RenderBody() + + + diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewImports.cshtml b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewImports.cshtml new file mode 100644 index 0000000..5895dfb --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewStart.cshtml b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewStart.cshtml new file mode 100644 index 0000000..e2417aa --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/Views/_ViewStart.cshtml @@ -0,0 +1 @@ +@{ Layout = "_Layout"; } diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/css/site.css new file mode 100644 index 0000000..e2e9353 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/css/site.css @@ -0,0 +1 @@ +body { font-family: system-ui; } diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/favicon.ico b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/favicon.ico new file mode 100644 index 0000000..49dea8c --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/favicon.ico @@ -0,0 +1 @@ +ICO diff --git a/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/js/site.js b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/js/site.js new file mode 100644 index 0000000..622f5ca --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/conventional-mvc/wwwroot/js/site.js @@ -0,0 +1 @@ +console.log('contoso'); diff --git a/skills/dotnet-segregated-assets/evals/files/cuemon-app/Program.cs b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Program.cs new file mode 100644 index 0000000..13834ba --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Program.cs @@ -0,0 +1,13 @@ +using Cuemon.AspNetCore.Razor.TagHelpers; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllersWithViews(); + +// Deployed configuration binds absolute HTTPS bases; local Development overrides via the segregated launch profile. +builder.Services.Configure(builder.Configuration.GetSection("App")); +builder.Services.Configure(builder.Configuration.GetSection("Cdn")); + +var app = builder.Build(); +app.MapStaticAssets(); +app.MapDefaultControllerRoute(); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/cuemon-app/Tolk.Web.csproj b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Tolk.Web.csproj new file mode 100644 index 0000000..d2da664 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Tolk.Web.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/skills/dotnet-segregated-assets/evals/files/cuemon-app/Views/Shared/_Layout.cshtml b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..d855006 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/cuemon-app/Views/Shared/_Layout.cshtml @@ -0,0 +1,8 @@ + + + + + + +@RenderBody() + diff --git a/skills/dotnet-segregated-assets/evals/files/cuemon-app/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/cuemon-app/wwwroot/css/site.css new file mode 100644 index 0000000..139ad30 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/cuemon-app/wwwroot/css/site.css @@ -0,0 +1 @@ +body{} diff --git a/skills/dotnet-segregated-assets/evals/files/existing-compose/Orders.Web.csproj b/skills/dotnet-segregated-assets/evals/files/existing-compose/Orders.Web.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/existing-compose/Orders.Web.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/existing-compose/Program.cs b/skills/dotnet-segregated-assets/evals/files/existing-compose/Program.cs new file mode 100644 index 0000000..b2eb537 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/existing-compose/Program.cs @@ -0,0 +1,4 @@ +var app = WebApplication.CreateBuilder(args).Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "orders"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/existing-compose/docker-compose.yml b/skills/dotnet-segregated-assets/evals/files/existing-compose/docker-compose.yml new file mode 100644 index 0000000..679c510 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/existing-compose/docker-compose.yml @@ -0,0 +1,7 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: dev + ports: + - "5432:5432" diff --git a/skills/dotnet-segregated-assets/evals/files/existing-compose/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/existing-compose/wwwroot/css/site.css new file mode 100644 index 0000000..139ad30 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/existing-compose/wwwroot/css/site.css @@ -0,0 +1 @@ +body{} diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/Program.cs b/skills/dotnet-segregated-assets/evals/files/frontend-build/Program.cs new file mode 100644 index 0000000..c25c0cb --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/Program.cs @@ -0,0 +1,4 @@ +var app = WebApplication.CreateBuilder(args).Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "storefront"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/Storefront.Web.csproj b/skills/dotnet-segregated-assets/evals/files/frontend-build/Storefront.Web.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/Storefront.Web.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/assets/main.js b/skills/dotnet-segregated-assets/evals/files/frontend-build/assets/main.js new file mode 100644 index 0000000..6085baa --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/assets/main.js @@ -0,0 +1 @@ +// source input compiled into wwwroot by the frontend build diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/package.json b/skills/dotnet-segregated-assets/evals/files/frontend-build/package.json new file mode 100644 index 0000000..b5827e8 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/package.json @@ -0,0 +1,10 @@ +{ + "name": "storefront-assets", + "private": true, + "scripts": { + "build": "vite build --outDir wwwroot" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/vite.config.js b/skills/dotnet-segregated-assets/evals/files/frontend-build/vite.config.js new file mode 100644 index 0000000..63610ea --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/vite.config.js @@ -0,0 +1 @@ +export default { build: { outDir: 'wwwroot', emptyOutDir: true } }; diff --git a/skills/dotnet-segregated-assets/evals/files/frontend-build/wwwroot/.gitkeep b/skills/dotnet-segregated-assets/evals/files/frontend-build/wwwroot/.gitkeep new file mode 100644 index 0000000..d3f5a12 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/frontend-build/wwwroot/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/Acme.slnx b/skills/dotnet-segregated-assets/evals/files/multi-project/Acme.slnx new file mode 100644 index 0000000..786034a --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/Acme.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Acme.Api.csproj b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Acme.Api.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Acme.Api.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Program.cs b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Program.cs new file mode 100644 index 0000000..674851e --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Api/Program.cs @@ -0,0 +1,3 @@ +var app = WebApplication.CreateBuilder(args).Build(); +app.MapGet("/health", () => "ok"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Acme.Core.csproj b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Acme.Core.csproj new file mode 100644 index 0000000..462e0fe --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Acme.Core.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Class1.cs b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Class1.cs new file mode 100644 index 0000000..3a1350d --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Core/Class1.cs @@ -0,0 +1 @@ +namespace Acme.Core; public sealed class Marker; diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Acme.Site.csproj b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Acme.Site.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Acme.Site.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Program.cs b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Program.cs new file mode 100644 index 0000000..0c12835 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/Program.cs @@ -0,0 +1,4 @@ +var app = WebApplication.CreateBuilder(args).Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "site"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css new file mode 100644 index 0000000..139ad30 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css @@ -0,0 +1 @@ +body{} diff --git a/skills/dotnet-segregated-assets/evals/files/no-cuemon/Ledger.Web.csproj b/skills/dotnet-segregated-assets/evals/files/no-cuemon/Ledger.Web.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/no-cuemon/Ledger.Web.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/no-cuemon/Program.cs b/skills/dotnet-segregated-assets/evals/files/no-cuemon/Program.cs new file mode 100644 index 0000000..1f1172a --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/no-cuemon/Program.cs @@ -0,0 +1,7 @@ +var builder = WebApplication.CreateBuilder(args); +// The application already prefixes asset URLs from its own configuration section. +var assetBase = builder.Configuration["Assets:BaseUrl"] ?? ""; +var app = builder.Build(); +app.MapStaticAssets(); +app.MapGet("/asset-base", () => assetBase); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/no-cuemon/appsettings.json b/skills/dotnet-segregated-assets/evals/files/no-cuemon/appsettings.json new file mode 100644 index 0000000..47c031e --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/no-cuemon/appsettings.json @@ -0,0 +1 @@ +{ "Assets": { "BaseUrl": "" } } diff --git a/skills/dotnet-segregated-assets/evals/files/no-cuemon/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/no-cuemon/wwwroot/css/site.css new file mode 100644 index 0000000..139ad30 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/no-cuemon/wwwroot/css/site.css @@ -0,0 +1 @@ +body{} diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/Assets.Dockerfile b/skills/dotnet-segregated-assets/evals/files/segregated-app/Assets.Dockerfile new file mode 100644 index 0000000..9083df8 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/Assets.Dockerfile @@ -0,0 +1,3 @@ +FROM codebeltnet/web-cdn-origin:2.0.0 + +COPY --chown=65532:65532 ./wwwroot/ /cdnroot/ diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/Fabrikam.Web.csproj b/skills/dotnet-segregated-assets/evals/files/segregated-app/Fabrikam.Web.csproj new file mode 100644 index 0000000..960281d --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/Fabrikam.Web.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/Program.cs b/skills/dotnet-segregated-assets/evals/files/segregated-app/Program.cs new file mode 100644 index 0000000..e2c7b8a --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/Program.cs @@ -0,0 +1,8 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +if (app.Environment.IsDevelopment()) +{ + app.MapStaticAssets(); +} +app.MapGet("/", () => "Fabrikam"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/Properties/launchSettings.json b/skills/dotnet-segregated-assets/evals/files/segregated-app/Properties/launchSettings.json new file mode 100644 index 0000000..2bedec2 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "applicationUrl": "http://localhost:5100", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "http-segregated-assets": { + "commandName": "Project", + "applicationUrl": "http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SegregatedAssets__App__BaseUrl": "http://localhost:8080", + "SegregatedAssets__App__Scheme": "Http" + } + } + } +} diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml b/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml new file mode 100644 index 0000000..6687d94 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml @@ -0,0 +1,12 @@ +services: + app-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + ports: + - "8080:8080" + volumes: + - ./wwwroot:/cdnroot:ro diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/css/site.css new file mode 100644 index 0000000..360ae74 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/css/site.css @@ -0,0 +1 @@ +body { color: #222; } diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/js/site.js b/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/js/site.js new file mode 100644 index 0000000..607b99e --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/wwwroot/js/site.js @@ -0,0 +1 @@ +console.log('fabrikam'); diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/README.md b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/README.md new file mode 100644 index 0000000..9654aea --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/README.md @@ -0,0 +1,4 @@ +# Shared CDN assets + +Reusable, versioned frontend dependencies (fonts, icon libraries, design tokens) consumed by multiple applications. This is the CDN/shared-asset equivalent — it must not be duplicated into any application's wwwroot. + diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/fonts/brand.woff2 b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/fonts/brand.woff2 new file mode 100644 index 0000000..9ac207e --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/fonts/brand.woff2 @@ -0,0 +1 @@ +FONT diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/vendor/design-tokens.css b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/vendor/design-tokens.css new file mode 100644 index 0000000..e9fc878 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/shared-assets/vendor/design-tokens.css @@ -0,0 +1 @@ +/* shared design tokens consumed by multiple apps */ diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Portal.Web.csproj b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Portal.Web.csproj new file mode 100644 index 0000000..116f357 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Portal.Web.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Program.cs b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Program.cs new file mode 100644 index 0000000..d887786 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/Program.cs @@ -0,0 +1,4 @@ +var app = WebApplication.CreateBuilder(args).Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "portal"); +app.Run(); diff --git a/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/wwwroot/css/portal.css b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/wwwroot/css/portal.css new file mode 100644 index 0000000..06dc316 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/with-cdn/src/Portal.Web/wwwroot/css/portal.css @@ -0,0 +1 @@ +/* application-specific branding */ diff --git a/skills/dotnet-segregated-assets/references/app-vs-cdn.md b/skills/dotnet-segregated-assets/references/app-vs-cdn.md new file mode 100644 index 0000000..6a9072e --- /dev/null +++ b/skills/dotnet-segregated-assets/references/app-vs-cdn.md @@ -0,0 +1,48 @@ +# App assets versus CDN assets + +This distinction is the heart of the skill. Two static-asset roles look similar on disk but have different owners, lifecycles, and deployment surfaces. Getting the role right determines where an asset is authored, whether it may live in an application's `wwwroot`, and which host serves it in production. + +## App assets + +App assets are application-specific files owned by exactly one application: its own CSS and JavaScript, images and branding, favicons, and any application-specific fonts or media. Their source normally stays in the web project's `wwwroot`, because that is the conventional, tooling-friendly authoring location that editors, hot reload, and the SDK already understand. What changes is delivery: after deployment these assets are served from a separately built and deployed static-content image based on `codebeltnet/web-cdn-origin:2.0.0`, on a host tied to that application (for example an assets host). They change on the application's own lifecycle. + +Conceptually this is Cuemon's `AppTagHelperOptions` and the `app-*` tag helpers (`AppImageTagHelper`, `AppLinkTagHelper`, `AppScriptTagHelper`, used through `app-src`/`app-href`): assets that live outside the application process but are tied to that one application. + +## CDN assets + +CDN assets are reusable static content consumed by multiple applications: common fonts, icon libraries, JavaScript libraries and packages, CSS frameworks, shared design-system assets, reusable images, and other organization-wide, versioned frontend dependencies. They must **not** be copied into every application's `wwwroot`. They usually have their own source repository or artifact, and they may ultimately be fronted by a true CDN (CloudFront, Cloudflare, Azure Front Door, Google Cloud CDN) with `web-cdn-origin` as the origin behind it. + +Conceptually this is Cuemon's `CdnTagHelperOptions` and the `cdn-*` tag helpers: assets on a surface that has a CDN role. + +Always ask whether a CDN/shared equivalent exists (see `FORMS.md`). If none exists, configure only App-asset segregation. If one exists, find its existing source and configuration and reference it; never duplicate it into the application's `wwwroot`. + +## Mapping to a URL-generation abstraction + +The skill only needs an abstraction that turns an asset path into a base-qualified URL whose base differs between local Development and deployment. Adapt to whatever the application already uses. **Do not add a Cuemon dependency to an application that does not already use it** merely to implement this skill. + +### When Cuemon tag helpers are already present + +`Cuemon.AspNetCore.Razor.TagHelpers` exposes an abstract `TagHelperOptions` with two properties that matter here: + +- `Scheme` — a `ProtocolUriScheme` of `None`, `Http`, `Https`, or `Relative`. The default is `Relative`, which formats the base URL as protocol-relative (`//host/…`). `Http` formats as `http://host/…`, `Https` as `https://host/…`, and `None` as a bare `host/…`. +- `BaseUrl` — the host (and optional path) portion, for example `localhost:8080` or `assets.example.com`. + +`AppTagHelperOptions` configures the `app-*` helpers and `CdnTagHelperOptions` configures the `cdn-*` helpers. Both default to `Scheme = Relative` and `BaseUrl = null`. + +Configure them like this: + +- **App, local Development (segregated profile):** `BaseUrl = localhost:`, `Scheme = Http`. Setting the scheme explicitly to `Http` is critical: the default `Relative` scheme emits `//localhost:/…`, which a browser resolves using the page's scheme. On an HTTPS page that becomes `https://localhost:/…` and fails against an HTTP-only local origin. Keep the segregated application profile itself HTTP so there is no mixed-content mismatch. +- **CDN, local Development:** `BaseUrl = localhost:`, `Scheme = Http` — but only when a CDN equivalent exists and its content is available locally. +- **Deployed (App and CDN):** absolute HTTPS URLs — `BaseUrl = assets.example.com` / `cdn.example.com`, `Scheme = Https`. + +Bind these from configuration so the launch profile's environment variables drive the local values and deployed configuration supplies the HTTPS values. + +### When Cuemon is not used + +Configure the application's own asset base-URL abstraction instead — an options/setting the app already reads to prefix asset URLs (for example a `SegregatedAssets:App:BaseUrl` and `SegregatedAssets:App:Scheme` pair, or the equivalent the app already has). Drive the local value from the segregated launch profile's environment variables (`http://localhost:`) and supply the deployed HTTPS value from deployed configuration. Introduce only a minimal app-owned setting; do not add a second competing asset-configuration system. + +## Why segregate at all + +The motivation is architectural, not a browser-connection trick. Segregating static delivery gives you segregation of duties (static delivery is isolated from application and business logic and its failure modes), independent deployment and scaling for assets, explicit and correct cache behavior on a dedicated surface, origin/CDN offloading so the application stays small and cheap, reusable shared assets, and a reduced application artifact surface. + +Do **not** justify the design as HTTP/1.x domain sharding or claim that additional domains improve modern browser performance through extra connection parallelism. On HTTP/2 and HTTP/3 that technique is usually counter-productive because it prevents connection coalescing. The benefits above are about architecture, operability, and edge caching — not connection count. diff --git a/skills/dotnet-segregated-assets/references/local-development.md b/skills/dotnet-segregated-assets/references/local-development.md new file mode 100644 index 0000000..f2aacaa --- /dev/null +++ b/skills/dotnet-segregated-assets/references/local-development.md @@ -0,0 +1,95 @@ +# Local development topology + +The goal is to preserve the ordinary fast edit/run/debug loop and add a second, opt-in way to run the application against the segregated production-like topology. Developers keep editing `wwwroot`; nothing about their normal Development profile changes. + +## Two profiles, one source folder + +Keep the application's existing Development launch profile exactly as it is. Add a new profile — `http-segregated-assets` (or the repository's equivalent naming pattern if one already exists) — that keeps the application in the Development environment but points App asset URLs at the local Static Content Provider instead of back at the application. + +Prefer an **HTTP** application profile because the local origin is exposed over HTTP. That avoids the protocol-relative and mixed-content traps described in `references/app-vs-cdn.md`: an HTTP page requesting an `http://localhost:` origin is consistent, whereas an HTTPS page requesting a protocol-relative `//localhost:` URL becomes an HTTPS request against an HTTP-only origin and fails. + +Example `Properties/launchSettings.json` profile (adapt the environment-variable keys to the application's real asset abstraction — the keys below are illustrative for an app without Cuemon): + +```json +{ + "profiles": { + "http-segregated-assets": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SegregatedAssets__App__BaseUrl": "http://localhost:8080", + "SegregatedAssets__App__Scheme": "Http" + } + } + } +} +``` + +For a Cuemon application, set the equivalent App options instead (`AppTagHelperOptions.BaseUrl = localhost:8080`, `Scheme = Http`), bound from these environment variables. When a CDN equivalent exists, add the matching CDN variables pointing at the second origin (`http://localhost:8081`). + +A `commandName: Project` profile only launches the application and sets configuration; it does **not** start sidecar containers. Keep process orchestration explicit and deterministic — start the local origin separately (below). Do not claim the profile itself spins up the origin. + +## Local Static Content Provider + +Use the published image directly for local development — do not rebuild an asset image on every source edit. Mount the application's existing `wwwroot` into `/cdnroot` **read-only** so edits are visible immediately (the image serves physical files from `/cdnroot`, and its `CdnOrigin:ContentRoot` already defaults to `/cdnroot`). + +Prefer a tiny dedicated Compose file when repository conventions permit, because relative bind mounts give a cross-platform, repeatable developer command. If the repository already has an established orchestration mechanism that can express the same topology cleanly, extend that instead of adding Compose. + +Preserve the security posture the image supports wherever Docker permits: non-root runtime (the image already runs as user `65532`), a read-only content mount, a read-only root filesystem where practical, no privileged mode, no Docker socket mount, no unnecessary capabilities, and only the required host port exposed. + +Example `compose.segregated-assets.yml` (App only): + +```yaml +services: + app-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + ports: + - "8080:8080" + volumes: + - ./src/Web/wwwroot:/cdnroot:ro +``` + +Run it with `docker compose -f compose.segregated-assets.yml up`, then launch the application with the `http-segregated-assets` profile. Adapt the relative `./src/Web/wwwroot` path to the actual web project location. + +## Second origin for CDN assets + +When a CDN equivalent exists and its content is available locally, provision a **second** origin instance on a different host port from its own shared-asset root: + +```text +localhost:8080 -> web-cdn-origin:2.0.0 -> /wwwroot +localhost:8081 -> web-cdn-origin:2.0.0 -> +``` + +```yaml +services: + app-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + ports: ["8080:8080"] + volumes: + - ./src/Web/wwwroot:/cdnroot:ro + cdn-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + ports: ["8081:8080"] + volumes: + - ../shared-assets:/cdnroot:ro +``` + +Resolve port collisions from the repository's existing configuration rather than blindly overwriting ports. If `8080`/`8081` are already used, pick free ports and keep the launch profile, Compose file, and any documentation consistent. + +## Validating the local topology + +`segregate-assets.cs verify --check-local` parses `launchSettings.json` and the Compose file and reports whether the segregated profile is HTTP, points at an `http://localhost:` origin, avoids protocol-relative/`https://localhost` URLs, and whether the origin service uses the published image, a read-only `/cdnroot` mount, a read-only root filesystem, no privileged mode, and no Docker socket. Fix any finding it reports before considering the local topology done. diff --git a/skills/dotnet-segregated-assets/references/production-image.md b/skills/dotnet-segregated-assets/references/production-image.md new file mode 100644 index 0000000..0daba07 --- /dev/null +++ b/skills/dotnet-segregated-assets/references/production-image.md @@ -0,0 +1,94 @@ +# Production asset image, publish exclusion, and documentation + +Two things happen at deployment: the application-owned static assets are shipped as their own immutable image based on `codebeltnet/web-cdn-origin:2.0.0`, and the deployed web application stops carrying a duplicate copy of those files. + +## Derived asset image + +When application-owned assets ship as a container image, use `web-cdn-origin:2.0.0` as the base. The normal derived image is conceptually no more complicated than copying the final `wwwroot` output into the image's content root: + +```dockerfile +FROM codebeltnet/web-cdn-origin:2.0.0 + +COPY --chown=65532:65532 ./wwwroot/ /cdnroot/ +``` + +Version 2.0 owns its `/cdnroot`, its port (`8080`), its runtime user (`65532`), and its application working directory. Do not override them without a demonstrated requirement, and prefer `COPY` over `ADD` when no `ADD` behavior is needed. Adapt only the source path and ownership when actual build conventions require it. + +Do **not** reintroduce the old 1.4 pattern (setting `ASPNETCORE_HTTP_PORTS`, `WORKDIR /cdnroot`, and `ADD approot .`). Version 2.0 already establishes those conventions, and re-declaring them fights the base image. + +If the application's frontend build generates the final files rather than storing them directly in source `wwwroot`, identify and preserve that generation pipeline and build it before the image is assembled: the image must contain the **actual final asset output**, not stale source inputs. + +## CI/CD integration + +Where CI/CD already builds once and promotes artifacts, preserve that model. Static assets can be emitted as their own build artifact and subsequently packaged into the Static Content Provider image, rather than being rebuilt during image publication. Document the integration point (where the asset artifact is produced and where it is packaged), but do not redesign CI/CD unless explicitly asked. + +## Excluding application-owned wwwroot from web publish + +The deployed web application must not carry a duplicate copy of its application-owned `wwwroot` files. Prefer **targeted** handling of the application's own `wwwroot` using supported MSBuild item metadata: + +```xml + + + +``` + +Do **not** default to `false`. That global switch disables the entire Static Web Assets system, which also drops assets supplied by Razor Class Libraries (`_content/…`) and framework assets (`_framework/…`) and can break application or framework functionality. The targeted `Content Update` item only affects the application's own `wwwroot`; Razor Class Library and framework static web assets keep flowing to publish through their own items. + +Do not assume the declaration is sufficient merely because it looks correct — the interaction between the `Content` items and the Static Web Assets publish pipeline must be confirmed empirically. + +### Verified behavior + +Against a conventional `Microsoft.NET.Sdk.Web` application on .NET 10 that calls `MapStaticAssets`: + +- **Baseline (no exclusion):** `dotnet publish` produces `publish/wwwroot/…` containing every `wwwroot` file plus pre-compressed `.br`/`.gz` variants and a `*.staticwebassets.endpoints.json` manifest — the duplicate copy to eliminate. +- **With `Content Update="wwwroot/**" CopyToPublishDirectory="Never"`:** `publish/wwwroot` is absent entirely; the endpoints manifest remains but is empty (`{"Version":1,"ManifestType":"Publish","Endpoints":[]}`). App-owned assets are gone, and the Static Web Assets system stays enabled. +- **Same exclusion with a referenced Razor Class Library:** the app's own `wwwroot/*` is absent, while the RCL's `wwwroot/_content//…` (and its `.br`/`.gz`) **survives** in publish. This is exactly the outcome the global disable would wrongly destroy. + +## Verifying the publish invariant + +Prove the invariant instead of trusting the declaration. `verify` publishes to an isolated temporary directory and asserts application-owned `wwwroot` files are absent (shared `_content`/`_framework` assets may remain): + +``` +dotnet run --file "/scripts/segregate-assets.cs" -- verify --repo-root "" -p "" --run-publish --check-local --json +``` + +The required invariant for a supported ordinary MVC/Razor application is: **application-owned files from source `wwwroot` are absent from the deployed web application publish artifact.** Never write verification output into the repository — the runner publishes into a temp directory it owns and removes. + +## ASP.NET Core static serving + +Inspect how the application currently serves static assets — `MapStaticAssets`, `UseStaticFiles`, custom file providers or endpoints, Blazor static assets, Razor Class Library assets, generated/scoped CSS, and frontend-generated assets — before changing anything. Do not mechanically delete static-file calls. For ordinary MVC/Razor applications, prefer limiting application-owned static serving to local Development when that can be done safely and without changing unrelated behavior. The production invariant that matters is that application-owned assets are requested from the external App asset host and are not deployed as duplicate files with the web application. Generated Static Web Assets scenarios are handled in `references/static-web-assets-guardrail.md`. + +## Documentation template + +Update the application's documentation to state that deployed static content is intentionally served by Codebelt Static Content Provider rather than the ASP.NET Core business application, and that `wwwroot` remains in the project specifically because it is the conventional, tooling-friendly authoring location. Document both workflows: + +```text +Normal Development +developer edits wwwroot + | + v +application's normal Development experience + +Segregated Development +developer edits wwwroot + | + v +read-only bind mount + | + v +web-cdn-origin:2.0.0 + | + v +browser requests external App asset URL + +Deployment +wwwroot / static asset artifact + | + v +derived web-cdn-origin image + | + v +asset host / optional CDN +``` + +When a shared CDN asset source exists, show it as a separate parallel flow (shared-cdn-root -> web-cdn-origin image / CDN) rather than merging it with the App asset flow. diff --git a/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md b/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md new file mode 100644 index 0000000..717f394 --- /dev/null +++ b/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md @@ -0,0 +1,40 @@ +# Static Web Assets compatibility guardrail + +The safe migration is a *physical* `wwwroot` served by the application: plain files the developer authored. The dangerous migration is one that treats **generated** or **contributed** Static Web Assets as if they were plain physical files and either excludes them from publish or disables the Static Web Assets system. That can break Blazor runtime loading, Razor Class Library assets, scoped CSS, component JavaScript modules, and frontend-generated output. This guardrail keeps the skill from producing a partially broken migration. + +## Detect before deciding + +`segregate-assets.cs inspect` reports risk signals. Treat any of these as `RiskyGeneratedAssets` and do **not** apply a blanket `wwwroot` publish exclusion or disable Static Web Assets: + +- `BLAZOR_WEBASSEMBLY` — `Microsoft.NET.Sdk.BlazorWebAssembly` or a `Microsoft.AspNetCore.Components.WebAssembly` reference. The published app depends on `_framework/` runtime assets. +- `BLAZOR_WEB_APP` — Razor components (`*.razor`) with `AddRazorComponents`/`MapRazorComponents` or a `Components.Web` reference. Depends on `_framework/blazor.web.js` and generated component assets. +- `RAZOR_CLASS_LIBRARY_ASSETS` — a referenced `Microsoft.NET.Sdk.Razor` project that contributes a `wwwroot`, published under `_content//…`. +- `SCOPED_CSS` — `*.razor.css` / `*.cshtml.css` files, which the build bundles into a generated `.styles.css` static web asset. +- `RAZOR_COMPONENT_JS` — collocated `*.razor.js` JavaScript modules emitted as static web assets. +- `FRAMEWORK_ASSETS_REFERENCE` / `CONTENT_ASSETS_REFERENCE` — markup that references `_framework/` or `_content/` paths. +- `STATIC_WEB_ASSETS_DISABLED` — `StaticWebAssetsEnabled` is already globally disabled; this may already be breaking RCL/framework assets. +- `FRONTEND_BUILD_PIPELINE` — a `package.json` build (webpack/vite/rollup/esbuild/etc.) that generates the final `wwwroot`; the image must ship generated output, not source inputs. + +You can also inspect manually for the same signals: `_framework`, `_content`, generated static web asset manifests, scoped CSS, component JS modules, and build-time frontend generation. + +## Why the blanket approaches are wrong here + +`false` is a global kill switch: it disables asset discovery, the manifest, and `MapStaticAssets`, so Razor Class Library (`_content/…`) and framework (`_framework/…`) assets stop being published too. A blanket `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` is safe for a *physical* app `wwwroot`, but it does not, on its own, relocate generated framework/component assets to an external origin — those assets still need to be served for the app to run. + +The empirical evidence in `references/production-image.md` shows the difference: the targeted exclusion removes the app's own `wwwroot/*` while a referenced RCL's `wwwroot/_content//…` survives in publish. That survival is correct and required — and it is exactly what a global disable would destroy. + +## Safe outcomes + +For a `Simple` classification (physical `wwwroot`, no risk signals), apply the standard App-asset segregation. Prefer limiting application-owned static serving to local Development when that can be done without changing unrelated behavior; the production invariant is that application-owned assets come from the external App host and are not duplicated into the web publish artifact. + +For `RiskyGeneratedAssets`, decide whether a safe, deterministic segregation exists for that specific project: + +- If the required generated output can be materialized into the external static-content artifact **and** the application's runtime references still resolve correctly (for example, the generated files are produced by the build and then packaged into the derived `web-cdn-origin` image with matching URLs), design that explicitly and verify it — do not improvise it as a side effect of a publish-exclusion glob. +- If that cannot be established safely and deterministically, **stop and report** that the project requires an explicit generated-static-assets segregation design rather than producing a partially broken migration. This is a successful safety outcome, not a skill failure. State which signals were found and what a correct design would need to preserve (`_framework`/`_content` resolution, scoped-CSS bundle, component JS modules, or the frontend build output). + +## Never do this + +- Never disable `StaticWebAssetsEnabled` globally to make a wwwroot exclusion "work". +- Never blindly delete `MapStaticAssets` or `UseStaticFiles`; understand what they serve first. +- Never exclude `_content`/`_framework` assets or treat them as app-owned files. +- Never force a migration through when the runner reports a risky scenario you cannot segregate safely — escalate instead. diff --git a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs new file mode 100644 index 0000000..6baed41 --- /dev/null +++ b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs @@ -0,0 +1,1301 @@ +#:property TargetFramework=net10.0 +#:property Nullable=enable +#:property LangVersion=latest +#:property PublishAot=false + +// dotnet-segregated-assets deterministic runner. +// +// This file is the *execution / inspection layer* for the dotnet-segregated-assets skill. The AI skill +// is the *orchestration layer*: it understands intent, resolves repository conventions, makes the +// repository-appropriate edits, and resolves the App-vs-CDN semantic choices. Everything that must be +// deterministic — discovering candidate web projects, classifying the static-asset topology, detecting +// risky Static Web Assets scenarios that must NOT be blindly excluded, checking idempotency, validating +// the local Static Content Provider topology, and proving that application-owned wwwroot files are absent +// from the deployed web-application publish artifact — lives here so it is repeatable instead of +// re-improvised on every call. +// +// The runner is deliberately conservative. It NEVER rewrites Program.cs, csproj, launchSettings.json, +// Dockerfiles, or Compose files in the target repository. `inspect`/`plan` are read-only; `verify` only +// writes into an isolated temp directory that it owns. Making a repository edit is the agent's job, using +// the literal templates in references/ adapted to the project's real conventions. + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +return SegregateAssetsProgram.Run(args); + +internal static class SegregateAssetsProgram +{ + internal const string ToolName = "dotnet-segregated-assets"; + internal const string OriginImage = "codebeltnet/web-cdn-origin:2.0.0"; + internal const int OriginContainerPort = 8080; + internal const string OriginContentRoot = "/cdnroot"; + internal const string OriginUser = "65532"; + internal const string SegregatedProfileName = "http-segregated-assets"; + + internal static readonly JsonSerializerOptions JsonOut = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public static int Run(string[] args) + { + Options options; + try + { + options = Options.Parse(args); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"{ToolName}: {ex.Message}"); + Options.PrintUsage(Console.Error); + return (int)ExitCode.InvalidArguments; + } + + if (options.ShowHelp) + { + Options.PrintUsage(Console.Out); + return (int)ExitCode.Success; + } + + try + { + return options.Command switch + { + Command.SelfTest => SelfTest.Run(options), + Command.Inspect => Commands.Inspect(options), + Command.Plan => Commands.Plan(options), + Command.Verify => Commands.Verify(options), + _ => Fail(options, ExitCode.InvalidArguments, "No command specified. Use inspect, plan, verify, or --self-test."), + }; + } + catch (Exception ex) + { + return Fail(options, ExitCode.UnexpectedError, $"Unhandled error: {ex.Message}"); + } + } + + internal static int Fail(Options options, ExitCode code, string message) + { + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize(new { tool = ToolName, ok = false, failureKind = code.ToString(), message }, JsonOut)); + } + else + { + Console.Error.WriteLine($"{ToolName}: {message}"); + } + return (int)code; + } + + internal static void Emit(Options options, object payload, Func human) + { + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize(payload, JsonOut)); + } + else + { + Console.WriteLine(human()); + } + } +} + +internal enum ExitCode +{ + Success = 0, + InvalidArguments = 64, + InspectionError = 65, + VerificationFailed = 66, + RiskyStaticAssets = 67, + PublishError = 68, + SelfTestFailed = 70, + UnexpectedError = 71, +} + +internal enum Command { None, Inspect, Plan, Verify, SelfTest } + +internal sealed class Options +{ + public Command Command { get; private set; } = Command.None; + public bool Json { get; private set; } + public bool ShowHelp { get; private set; } + public bool FailOnRisk { get; private set; } + public bool RunPublish { get; private set; } + public bool CheckLocal { get; private set; } + public string RepoRoot { get; private set; } = Directory.GetCurrentDirectory(); + public string? Project { get; private set; } + public string? PublishDir { get; private set; } + public bool CdnEquivalent { get; private set; } + public int AppPort { get; private set; } = 8080; + public int CdnPort { get; private set; } = 8081; + + public static Options Parse(string[] args) + { + var o = new Options(); + if (args.Length == 0) { o.ShowHelp = true; return o; } + + var positional = new List(); + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + switch (a) + { + case "-h" or "--help": o.ShowHelp = true; break; + case "--self-test": o.Command = Command.SelfTest; break; + case "--json": o.Json = true; break; + case "--fail-on-risk": o.FailOnRisk = true; break; + case "--run-publish": o.RunPublish = true; break; + case "--check-local": o.CheckLocal = true; break; + case "--cdn-equivalent": o.CdnEquivalent = true; break; + case "--repo-root": o.RepoRoot = RequireValue(args, ref i, a); break; + case "-p" or "--project": o.Project = RequireValue(args, ref i, a); break; + case "--publish-dir": o.PublishDir = RequireValue(args, ref i, a); break; + case "--app-port": o.AppPort = ParseInt(RequireValue(args, ref i, a), a); break; + case "--cdn-port": o.CdnPort = ParseInt(RequireValue(args, ref i, a), a); break; + default: + if (a.StartsWith('-')) throw new ArgumentException($"Unknown option '{a}'."); + positional.Add(a); + break; + } + } + + foreach (var token in positional) + { + var parsed = token.ToLowerInvariant() switch + { + "inspect" => Command.Inspect, + "plan" => Command.Plan, + "verify" => Command.Verify, + _ => Command.None, + }; + if (parsed == Command.None) throw new ArgumentException($"Unknown command '{token}'."); + if (o.Command != Command.None && o.Command != Command.SelfTest && o.Command != parsed) + throw new ArgumentException("Specify exactly one command."); + o.Command = parsed; + } + + o.RepoRoot = Path.GetFullPath(o.RepoRoot); + return o; + } + + private static string RequireValue(string[] args, ref int i, string name) + { + if (i + 1 >= args.Length) throw new ArgumentException($"Option '{name}' requires a value."); + return args[++i]; + } + + private static int ParseInt(string value, string name) => + int.TryParse(value, out var n) ? n : throw new ArgumentException($"Option '{name}' requires an integer, got '{value}'."); + + public static void PrintUsage(TextWriter w) + { + w.WriteLine($""" + {SegregateAssetsProgram.ToolName} — segregate ASP.NET Core static assets to Codebelt Static Content Provider. + + Usage: + dotnet run --file segregate-assets.cs -- [options] + + Commands: + inspect Discover candidate web projects, classify static-asset topology, detect risky + Static Web Assets scenarios, and report existing segregation (idempotency). + plan Resolve the target project, ports, and the ordered set of segregation decisions + without writing any files. + verify Prove application-owned wwwroot files are absent from the publish artifact and, + with --check-local, validate the local Static Content Provider topology. + --self-test Run the built-in deterministic tests (no dotnet/docker/network required). + + Options: + --repo-root Repository root to inspect (default: current directory). + -p, --project Target web project (.csproj), relative to repo root or absolute. + --publish-dir Existing publish output to inspect for verify. + --run-publish Run `dotnet publish -c Release` into an isolated temp dir for verify. + --check-local Validate launchSettings.json and the local Compose origin topology. + --cdn-equivalent A shared CDN/asset equivalent exists (affects plan output). + --app-port Local App Static Content Provider host port (default: 8080). + --cdn-port Local CDN Static Content Provider host port (default: 8081). + --fail-on-risk Exit non-zero when risky Static Web Assets scenarios are detected. + --json Emit machine-readable JSON. + -h, --help Show this help. + """); + } +} + +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +internal sealed record WebProjectInfo( + string Path, + string RelativePath, + string? Sdk, + bool IsWebApp, + bool HasWwwroot); + +internal sealed record RiskSignal(string Code, string Detail); + +internal sealed record ExistingSegregation( + bool PublishExclusion, + bool SegregatedLaunchProfile, + bool ComposeService, + bool DerivedDockerfile, + bool CuemonAppOptions) +{ + public bool Any => PublishExclusion || SegregatedLaunchProfile || ComposeService || DerivedDockerfile || CuemonAppOptions; + public bool Complete => PublishExclusion && SegregatedLaunchProfile; +} + +internal sealed record InspectionResult( + string Tool, + string RepoRoot, + IReadOnlyList WebProjects, + string? SelectedProject, + string Classification, + IReadOnlyList RiskSignals, + ExistingSegregation ExistingSegregation, + bool CuemonPresent, + string Recommendation) +{ + public bool Ok => true; +} + +// --------------------------------------------------------------------------- +// Detectors (pure over the filesystem) +// --------------------------------------------------------------------------- + +internal static class ProjectScanner +{ + private static readonly Regex SdkAttr = new("]*\\bSdk\\s*=\\s*\"(?[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static string? ReadSdk(string csprojText) + { + var m = SdkAttr.Match(csprojText); + return m.Success ? m.Groups["sdk"].Value : null; + } + + public static bool IsWebSdk(string? sdk) => + sdk is not null && sdk.Contains("Microsoft.NET.Sdk.Web", StringComparison.OrdinalIgnoreCase); + + public static IReadOnlyList Discover(string repoRoot) + { + var projects = new List(); + foreach (var csproj in EnumerateProjects(repoRoot)) + { + string text; + try { text = File.ReadAllText(csproj); } catch { continue; } + var sdk = ReadSdk(text); + var isWeb = IsWebSdk(sdk); + var dir = Path.GetDirectoryName(csproj)!; + var hasWwwroot = Directory.Exists(Path.Combine(dir, "wwwroot")); + if (!isWeb) continue; // only web-application projects are candidates + projects.Add(new WebProjectInfo( + csproj, + Rel(repoRoot, csproj), + sdk, + isWeb, + hasWwwroot)); + } + return projects + .OrderBy(p => p.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public static IEnumerable EnumerateProjects(string root) + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var file in Directory.EnumerateFiles(root, "*.csproj", options)) + { + var normalized = file.Replace('\\', '/'); + if (normalized.Contains("/bin/", StringComparison.OrdinalIgnoreCase)) continue; + if (normalized.Contains("/obj/", StringComparison.OrdinalIgnoreCase)) continue; + yield return file; + } + } + + public static string Rel(string root, string path) + { + var rel = Path.GetRelativePath(root, path); + return rel.Replace('\\', '/'); + } +} + +internal static class StaticWebAssetRiskDetector +{ + // Detects generated / static-web-asset scenarios that must NOT be blindly excluded from publish. + public static IReadOnlyList Detect(string projectPath, string repoRoot) + { + var signals = new List(); + var dir = Path.GetDirectoryName(projectPath)!; + string csproj; + try { csproj = File.ReadAllText(projectPath); } catch { csproj = string.Empty; } + var sdk = ProjectScanner.ReadSdk(csproj) ?? string.Empty; + + void Add(string code, string detail) + { + if (!signals.Any(s => s.Code == code)) signals.Add(new RiskSignal(code, detail)); + } + + if (sdk.Contains("BlazorWebAssembly", StringComparison.OrdinalIgnoreCase)) + Add("BLAZOR_WEBASSEMBLY", "Project SDK is Microsoft.NET.Sdk.BlazorWebAssembly."); + + if (Regex.IsMatch(csproj, "Microsoft\\.AspNetCore\\.Components\\.WebAssembly", RegexOptions.IgnoreCase)) + Add("BLAZOR_WEBASSEMBLY", "PackageReference to Microsoft.AspNetCore.Components.WebAssembly."); + + var programFiles = SafeFiles(dir, "Program.cs"); + var programText = string.Concat(programFiles.Select(SafeRead)); + var razorFiles = SafeFiles(dir, "*.razor"); + if (razorFiles.Count > 0 && + (Regex.IsMatch(programText, "AddRazorComponents|MapRazorComponents", RegexOptions.IgnoreCase) || + Regex.IsMatch(csproj, "Microsoft\\.AspNetCore\\.Components\\.Web\\b", RegexOptions.IgnoreCase))) + Add("BLAZOR_WEB_APP", "Razor components (*.razor) with AddRazorComponents/MapRazorComponents or Components.Web reference."); + + if (SafeFiles(dir, "*.razor.css").Count > 0 || SafeFiles(dir, "*.cshtml.css").Count > 0) + Add("SCOPED_CSS", "Scoped CSS files (*.razor.css / *.cshtml.css) generate a bundled .styles.css static web asset."); + + if (SafeFiles(dir, "*.razor.js").Count > 0) + Add("RAZOR_COMPONENT_JS", "Collocated JavaScript modules (*.razor.js) are emitted as static web assets."); + + // Reference to framework/content asset paths inside markup or code. + foreach (var ext in new[] { "*.cshtml", "*.razor", "*.html", "*.cs" }) + { + foreach (var f in SafeFiles(dir, ext)) + { + var t = SafeRead(f); + if (t.Contains("_framework/", StringComparison.OrdinalIgnoreCase) || t.Contains("blazor.web.js", StringComparison.OrdinalIgnoreCase) || t.Contains("blazor.webassembly.js", StringComparison.OrdinalIgnoreCase)) + Add("FRAMEWORK_ASSETS_REFERENCE", "Markup references _framework/ (Blazor runtime static web assets)."); + if (t.Contains("_content/", StringComparison.OrdinalIgnoreCase)) + Add("CONTENT_ASSETS_REFERENCE", "Markup references _content/ (Razor Class Library static web assets)."); + } + } + + // Referenced Razor Class Libraries that contribute wwwroot static web assets. + foreach (var refProj in ResolveProjectReferences(projectPath, csproj)) + { + string refText; + try { refText = File.ReadAllText(refProj); } catch { continue; } + var refSdk = ProjectScanner.ReadSdk(refText) ?? string.Empty; + var refDir = Path.GetDirectoryName(refProj)!; + var isRazorSdk = refSdk.Contains("Microsoft.NET.Sdk.Razor", StringComparison.OrdinalIgnoreCase); + if ((isRazorSdk || Regex.IsMatch(refText, "AddRazorSupportForMvc|StaticWebAssetBasePath|RazorLangVersion", RegexOptions.IgnoreCase)) + && Directory.Exists(Path.Combine(refDir, "wwwroot"))) + Add("RAZOR_CLASS_LIBRARY_ASSETS", $"Referenced project '{ProjectScanner.Rel(repoRoot, refProj)}' contributes wwwroot static web assets (published under _content/)."); + } + + if (Regex.IsMatch(csproj, "\\s*false\\s*", RegexOptions.IgnoreCase)) + Add("STATIC_WEB_ASSETS_DISABLED", "StaticWebAssetsEnabled is already globally disabled — this can break RCL/framework assets."); + + if (HasFrontendBuildPipeline(dir)) + Add("FRONTEND_BUILD_PIPELINE", "A frontend build (package.json + bundler) generates final wwwroot output; the image must ship generated output, not source inputs."); + + return signals; + } + + private static bool HasFrontendBuildPipeline(string dir) + { + var packageJson = Path.Combine(dir, "package.json"); + if (!File.Exists(packageJson)) return false; + var text = SafeRead(packageJson); + var hasBuildScript = Regex.IsMatch(text, "\"scripts\"\\s*:\\s*\\{[^}]*\"(build|bundle|dist)\"", RegexOptions.IgnoreCase | RegexOptions.Singleline); + var hasBundler = Regex.IsMatch(text, "webpack|vite|rollup|esbuild|parcel|gulp|@angular|react-scripts", RegexOptions.IgnoreCase); + return hasBuildScript || hasBundler; + } + + public static IReadOnlyList ResolveProjectReferences(string projectPath, string csprojText) + { + var dir = Path.GetDirectoryName(projectPath)!; + var refs = new List(); + foreach (Match m in Regex.Matches(csprojText, "]*Include\\s*=\\s*\"(?[^\"]+)\"", RegexOptions.IgnoreCase)) + { + var inc = m.Groups["inc"].Value.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + var full = Path.GetFullPath(Path.Combine(dir, inc)); + if (File.Exists(full)) refs.Add(full); + } + return refs; + } + + private static List SafeFiles(string dir, string pattern) + { + try + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + return Directory.EnumerateFiles(dir, pattern, options) + .Where(f => { var n = f.Replace('\\', '/'); return !n.Contains("/bin/") && !n.Contains("/obj/") && !n.Contains("/node_modules/"); }) + .ToList(); + } + catch { return new List(); } + } + + private static string SafeRead(string file) + { + try { return File.ReadAllText(file); } catch { return string.Empty; } + } +} + +internal static class IdempotencyDetector +{ + public static ExistingSegregation Detect(string projectPath, string repoRoot) + { + var dir = Path.GetDirectoryName(projectPath)!; + var csproj = SafeRead(projectPath); + + var publishExclusion = Regex.IsMatch( + csproj, + "]*Update\\s*=\\s*\"wwwroot[\\\\/][*][*][^\"]*\"[^>]*CopyToPublishDirectory\\s*=\\s*\"Never\"", + RegexOptions.IgnoreCase) + || Regex.IsMatch(csproj, "CopyToPublishDirectory\\s*=\\s*\"Never\"[^>]*Update\\s*=\\s*\"wwwroot", RegexOptions.IgnoreCase); + + var launchSettings = Path.Combine(dir, "Properties", "launchSettings.json"); + var segregatedProfile = File.Exists(launchSettings) && + SafeRead(launchSettings).Contains(SegregateAssetsProgram.SegregatedProfileName, StringComparison.OrdinalIgnoreCase); + + var composeService = EnumerateComposeFiles(repoRoot) + .Select(SafeRead) + .Any(t => t.Contains("codebeltnet/web-cdn-origin", StringComparison.OrdinalIgnoreCase)); + + var derivedDockerfile = EnumerateDockerfiles(repoRoot) + .Select(SafeRead) + .Any(t => Regex.IsMatch(t, "^\\s*FROM\\s+codebeltnet/web-cdn-origin", RegexOptions.IgnoreCase | RegexOptions.Multiline)); + + var cuemonAppOptions = Regex.IsMatch( + string.Concat(SafeFiles(dir, "*.cs").Select(SafeRead)), + "AppTagHelperOptions|CdnTagHelperOptions", + RegexOptions.IgnoreCase); + + return new ExistingSegregation(publishExclusion, segregatedProfile, composeService, derivedDockerfile, cuemonAppOptions); + } + + public static IEnumerable EnumerateComposeFiles(string root) + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var f in Directory.EnumerateFiles(root, "*.y*ml", options)) + { + var name = Path.GetFileName(f).ToLowerInvariant(); + var n = f.Replace('\\', '/'); + if (n.Contains("/bin/") || n.Contains("/obj/")) continue; + if (name.Contains("compose") || name.Contains("docker-compose")) yield return f; + } + } + + public static IEnumerable EnumerateDockerfiles(string root) + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var f in Directory.EnumerateFiles(root, "*", options)) + { + var name = Path.GetFileName(f); + var n = f.Replace('\\', '/'); + if (n.Contains("/bin/") || n.Contains("/obj/")) continue; + if (name.Equals("Dockerfile", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("Dockerfile.", StringComparison.OrdinalIgnoreCase) || + name.EndsWith(".Dockerfile", StringComparison.OrdinalIgnoreCase)) + yield return f; + } + } + + private static List SafeFiles(string dir, string pattern) + { + try + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + return Directory.EnumerateFiles(dir, pattern, options) + .Where(f => { var n = f.Replace('\\', '/'); return !n.Contains("/bin/") && !n.Contains("/obj/"); }) + .ToList(); + } + catch { return new List(); } + } + + private static string SafeRead(string file) + { + try { return File.ReadAllText(file); } catch { return string.Empty; } + } +} + +internal static class Classifier +{ + public const string NotAWebApp = "NotAWebApp"; + public const string Ambiguous = "Ambiguous"; + public const string NoWwwroot = "NoWwwroot"; + public const string AlreadySegregated = "AlreadySegregated"; + public const string RiskyGeneratedAssets = "RiskyGeneratedAssets"; + public const string Simple = "Simple"; + + public static string Classify( + IReadOnlyList webProjects, + WebProjectInfo? selected, + IReadOnlyList risks, + ExistingSegregation existing) + { + if (webProjects.Count == 0) return NotAWebApp; + if (selected is null) return Ambiguous; + if (existing.Complete) return AlreadySegregated; + if (!selected.HasWwwroot && risks.Count == 0) return NoWwwroot; + if (risks.Count > 0) return RiskyGeneratedAssets; + return Simple; + } + + public static string Recommend(string classification, ExistingSegregation existing) => classification switch + { + NotAWebApp => "No Microsoft.NET.Sdk.Web project found. Nothing to segregate; confirm the target repository.", + Ambiguous => "Multiple web projects found. Ask which web project to segregate (pass --project).", + NoWwwroot => "No wwwroot found. Configure only the CDN/shared-asset consumption if a CDN equivalent exists; otherwise nothing to do.", + AlreadySegregated => "Segregation is already present. Reconcile existing configuration; do not create duplicate items, profiles, services, or Dockerfiles.", + RiskyGeneratedAssets => "Generated/Static Web Assets detected. Do NOT apply a blanket wwwroot publish exclusion or disable StaticWebAssetsEnabled. Escalate: an explicit generated-static-assets segregation design is required.", + Simple => existing.Any + ? "Simple physical wwwroot with partial existing segregation. Complete the missing pieces idempotently." + : "Simple physical wwwroot. Apply App-asset segregation: targeted publish exclusion, segregated launch profile, local origin, derived production image, and documentation.", + _ => "Review findings.", + }; +} + +internal static class LaunchProfileValidator +{ + public sealed record Result(bool ProfileExists, bool IsHttp, bool HasHttpLocalOrigin, bool HasUnsafeProtocol, IReadOnlyList Findings); + + // Validates the segregated launch profile in launchSettings.json JSON text. + public static Result Validate(string launchSettingsJson, string profileName) + { + var findings = new List(); + JsonDocument doc; + try { doc = JsonDocument.Parse(launchSettingsJson); } + catch (Exception ex) { return new Result(false, false, false, false, new[] { $"launchSettings.json is not valid JSON: {ex.Message}" }); } + + using (doc) + { + if (!doc.RootElement.TryGetProperty("profiles", out var profiles) || + !profiles.TryGetProperty(profileName, out var profile)) + { + findings.Add($"Profile '{profileName}' is not present."); + return new Result(false, false, false, false, findings); + } + + var appUrl = profile.TryGetProperty("applicationUrl", out var u) ? (u.GetString() ?? string.Empty) : string.Empty; + var isHttp = appUrl.Contains("http://", StringComparison.OrdinalIgnoreCase) && !appUrl.Contains("https://", StringComparison.OrdinalIgnoreCase); + if (!isHttp) + findings.Add("Segregated profile applicationUrl should be HTTP-only so an HTTP local origin is not requested from an HTTPS page."); + + var envValues = new List(); + if (profile.TryGetProperty("environmentVariables", out var env) && env.ValueKind == JsonValueKind.Object) + { + foreach (var p in env.EnumerateObject()) + envValues.Add($"{p.Name}={p.Value.GetString() ?? string.Empty}"); + } + var envBlob = string.Join("\n", envValues); + + var hasHttpLocalOrigin = Regex.IsMatch(envBlob, "http://localhost:\\d+", RegexOptions.IgnoreCase); + if (!hasHttpLocalOrigin) + findings.Add("Segregated profile should point App asset URLs at an http://localhost: origin."); + + var hasUnsafeProtocol = + Regex.IsMatch(envBlob, "(^|=)//localhost", RegexOptions.IgnoreCase | RegexOptions.Multiline) || + Regex.IsMatch(envBlob, "https://localhost", RegexOptions.IgnoreCase); + if (hasUnsafeProtocol) + findings.Add("Segregated profile uses a protocol-relative (//localhost) or https://localhost URL that would break an HTTP-only local origin."); + + return new Result(true, isHttp, hasHttpLocalOrigin, hasUnsafeProtocol, findings); + } + } +} + +internal static class ComposeValidator +{ + public sealed record Result(bool UsesOriginImage, bool ReadOnlyMount, bool ReadOnlyRootFs, bool NonPrivileged, bool NoDockerSocket, IReadOnlyList Findings); + + // Lightweight, line-oriented validation of the local origin service posture. + public static Result Validate(string composeText) + { + var findings = new List(); + + var usesOrigin = composeText.Contains(SegregateAssetsProgram.OriginImage, StringComparison.OrdinalIgnoreCase); + if (!usesOrigin) findings.Add($"Compose does not reference {SegregateAssetsProgram.OriginImage}."); + + var readOnlyMount = Regex.IsMatch(composeText, ":/cdnroot:ro\\b", RegexOptions.IgnoreCase) || + Regex.IsMatch(composeText, "target:\\s*/cdnroot[\\s\\S]{0,120}read_only:\\s*true", RegexOptions.IgnoreCase); + if (!readOnlyMount) findings.Add("wwwroot should be mounted into /cdnroot read-only (':/cdnroot:ro')."); + + var readOnlyRootFs = Regex.IsMatch(composeText, "read_only:\\s*true", RegexOptions.IgnoreCase); + if (!readOnlyRootFs) findings.Add("Prefer a read-only root filesystem (read_only: true) where practical."); + + var nonPrivileged = !Regex.IsMatch(composeText, "privileged:\\s*true", RegexOptions.IgnoreCase); + if (!nonPrivileged) findings.Add("Do not run the origin in privileged mode."); + + var noDockerSocket = !composeText.Contains("docker.sock", StringComparison.OrdinalIgnoreCase); + if (!noDockerSocket) findings.Add("Do not mount the Docker socket into the origin container."); + + return new Result(usesOrigin, readOnlyMount, readOnlyRootFs, nonPrivileged, noDockerSocket, findings); + } +} + +internal static class PublishLeakDetector +{ + public sealed record Result(bool Passed, IReadOnlyList LeakedAppAssets, IReadOnlyList PreservedSharedAssets); + + // App-owned wwwroot files must be absent from the publish artifact. RCL/framework assets under + // _content/ and _framework/ are allowed (and expected) to survive. + public static Result Detect(string sourceWwwroot, string publishDir) + { + var leaked = new List(); + var preserved = new List(); + + var publishWwwroot = Path.Combine(publishDir, "wwwroot"); + if (Directory.Exists(publishWwwroot)) + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var f in Directory.EnumerateFiles(publishWwwroot, "*", options)) + { + var rel = Path.GetRelativePath(publishWwwroot, f).Replace('\\', '/'); + if (rel.StartsWith("_content/", StringComparison.OrdinalIgnoreCase) || + rel.StartsWith("_framework/", StringComparison.OrdinalIgnoreCase)) + { + preserved.Add(rel); + continue; + } + leaked.Add(rel); + } + } + + // Cross-check specifically against the application-owned source files (ignoring compressed variants). + var sourceRel = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(sourceWwwroot)) + { + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var f in Directory.EnumerateFiles(sourceWwwroot, "*", options)) + sourceRel.Add(Path.GetRelativePath(sourceWwwroot, f).Replace('\\', '/')); + } + + var appLeaked = leaked + .Where(r => sourceRel.Contains(r) || sourceRel.Contains(StripCompression(r))) + .OrderBy(r => r, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // If no source list is available, any non-shared file under publish/wwwroot is treated as a leak. + var effectiveLeaks = sourceRel.Count > 0 ? appLeaked : leaked.OrderBy(r => r, StringComparer.OrdinalIgnoreCase).ToList(); + + return new Result(effectiveLeaks.Count == 0, effectiveLeaks, preserved.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList()); + } + + private static string StripCompression(string rel) => + rel.EndsWith(".br", StringComparison.OrdinalIgnoreCase) || rel.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) + ? rel[..rel.LastIndexOf('.')] + : rel; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +internal static class Commands +{ + public static InspectionResult Inspect(string repoRoot, string? projectOption) + { + var webProjects = ProjectScanner.Discover(repoRoot); + WebProjectInfo? selected = null; + if (projectOption is not null) + { + var full = Path.GetFullPath(Path.Combine(repoRoot, projectOption)); + selected = webProjects.FirstOrDefault(p => string.Equals(p.Path, full, StringComparison.OrdinalIgnoreCase)) + ?? webProjects.FirstOrDefault(p => string.Equals(p.RelativePath, projectOption.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)); + } + else if (webProjects.Count == 1) + { + selected = webProjects[0]; + } + + var risks = selected is not null ? StaticWebAssetRiskDetector.Detect(selected.Path, repoRoot) : Array.Empty(); + var existing = selected is not null ? IdempotencyDetector.Detect(selected.Path, repoRoot) : new ExistingSegregation(false, false, false, false, false); + var classification = Classifier.Classify(webProjects, selected, risks, existing); + var cuemonPresent = existing.CuemonAppOptions || + (selected is not null && File.Exists(selected.Path) && File.ReadAllText(selected.Path).Contains("Cuemon.AspNetCore", StringComparison.OrdinalIgnoreCase)); + var recommendation = Classifier.Recommend(classification, existing); + + return new InspectionResult( + SegregateAssetsProgram.ToolName, + repoRoot, + webProjects, + selected?.RelativePath, + classification, + risks, + existing, + cuemonPresent, + recommendation); + } + + public static int Inspect(Options options) + { + var result = Inspect(options.RepoRoot, options.Project); + SegregateAssetsProgram.Emit(options, result, () => RenderInspection(result)); + if (options.FailOnRisk && result.Classification == Classifier.RiskyGeneratedAssets) + return (int)ExitCode.RiskyStaticAssets; + return (int)ExitCode.Success; + } + + public static int Plan(Options options) + { + var inspection = Inspect(options.RepoRoot, options.Project); + var decisions = BuildPlan(inspection, options); + var payload = new + { + tool = SegregateAssetsProgram.ToolName, + repoRoot = inspection.RepoRoot, + selectedProject = inspection.SelectedProject, + classification = inspection.Classification, + cdnEquivalent = options.CdnEquivalent, + appPort = options.AppPort, + cdnPort = options.CdnEquivalent ? options.CdnPort : (int?)null, + originImage = SegregateAssetsProgram.OriginImage, + decisions, + recommendation = inspection.Recommendation, + }; + SegregateAssetsProgram.Emit(options, payload, () => RenderPlan(inspection, options, decisions)); + return (int)ExitCode.Success; + } + + public static int Verify(Options options) + { + var inspection = Inspect(options.RepoRoot, options.Project); + var selected = inspection.SelectedProject; + if (selected is null) + return SegregateAssetsProgram.Fail(options, ExitCode.InspectionError, + inspection.Classification == Classifier.Ambiguous + ? "Multiple web projects found; pass --project to choose one." + : "No web project resolved; pass --project."); + + var projectPath = Path.GetFullPath(Path.Combine(options.RepoRoot, selected)); + var sourceWwwroot = Path.Combine(Path.GetDirectoryName(projectPath)!, "wwwroot"); + + string? publishDir = options.PublishDir is not null ? Path.GetFullPath(options.PublishDir) : null; + string? tempDir = null; + try + { + if (publishDir is null && options.RunPublish) + { + tempDir = Path.Combine(Path.GetTempPath(), $"segregated-verify-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var (code, output) = RunDotnetPublish(projectPath, tempDir); + if (code != 0) + return SegregateAssetsProgram.Fail(options, ExitCode.PublishError, $"dotnet publish failed (exit {code}).\n{output}"); + publishDir = tempDir; + } + + if (publishDir is null) + return SegregateAssetsProgram.Fail(options, ExitCode.InvalidArguments, + "verify needs an existing --publish-dir or --run-publish to produce one."); + + var leak = PublishLeakDetector.Detect(sourceWwwroot, publishDir); + + LaunchProfileValidator.Result? launch = null; + ComposeValidator.Result? compose = null; + if (options.CheckLocal) + { + var launchSettings = Path.Combine(Path.GetDirectoryName(projectPath)!, "Properties", "launchSettings.json"); + if (File.Exists(launchSettings)) + launch = LaunchProfileValidator.Validate(File.ReadAllText(launchSettings), SegregateAssetsProgram.SegregatedProfileName); + + var composeFile = IdempotencyDetector.EnumerateComposeFiles(options.RepoRoot) + .FirstOrDefault(f => File.ReadAllText(f).Contains("codebeltnet/web-cdn-origin", StringComparison.OrdinalIgnoreCase)); + if (composeFile is not null) + compose = ComposeValidator.Validate(File.ReadAllText(composeFile)); + } + + var localOk = (launch is null || (!launch.HasUnsafeProtocol && launch.HasHttpLocalOrigin && launch.IsHttp)) && + (compose is null || (compose.UsesOriginImage && compose.ReadOnlyMount && compose.NonPrivileged && compose.NoDockerSocket)); + var ok = leak.Passed && (!options.CheckLocal || localOk); + + var payload = new + { + tool = SegregateAssetsProgram.ToolName, + ok, + selectedProject = selected, + publishInspected = publishDir, + publishInvariant = leak.Passed ? "application-owned wwwroot is ABSENT from the publish artifact" : "application-owned wwwroot LEAKED into the publish artifact", + leakedAppAssets = leak.LeakedAppAssets, + preservedSharedAssets = leak.PreservedSharedAssets, + launch, + compose, + }; + SegregateAssetsProgram.Emit(options, payload, () => RenderVerify(selected, publishDir!, leak, launch, compose, ok)); + return ok ? (int)ExitCode.Success : (int)ExitCode.VerificationFailed; + } + finally + { + if (tempDir is not null && Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ } + } + } + } + + private static (int Code, string Output) RunDotnetPublish(string projectPath, string outputDir) + { + var psi = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(projectPath)!, + }; + psi.ArgumentList.Add("publish"); + psi.ArgumentList.Add(projectPath); + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add("Release"); + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add(outputDir); + psi.ArgumentList.Add("--nologo"); + + using var proc = Process.Start(psi)!; + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + return (proc.ExitCode, stdout + stderr); + } + + private static List BuildPlan(InspectionResult inspection, Options options) + { + var e = inspection.ExistingSegregation; + string StatusFor(bool present) => present ? "already-present" : "create"; + + if (inspection.Classification == Classifier.RiskyGeneratedAssets) + { + return new List + { + new { step = "escalate", status = "blocked", detail = "Risky Static Web Assets detected. Do not apply a blanket wwwroot publish exclusion or disable StaticWebAssetsEnabled. Request an explicit generated-static-assets segregation design." }, + }; + } + if (inspection.Classification is Classifier.NotAWebApp or Classifier.Ambiguous or Classifier.NoWwwroot) + { + return new List + { + new { step = "resolve", status = "blocked", detail = inspection.Recommendation }, + }; + } + + var plan = new List + { + new { step = "publish-exclusion", status = StatusFor(e.PublishExclusion), detail = "Add to the web project (targeted; do NOT disable StaticWebAssetsEnabled)." }, + new { step = "segregated-launch-profile", status = StatusFor(e.SegregatedLaunchProfile), detail = $"Add the '{SegregateAssetsProgram.SegregatedProfileName}' HTTP launch profile pointing App asset URLs at http://localhost:{options.AppPort}." }, + new { step = "local-origin", status = StatusFor(e.ComposeService), detail = $"Provide a local {SegregateAssetsProgram.OriginImage} service mounting wwwroot into /cdnroot read-only on host port {options.AppPort}." }, + new { step = "production-image", status = StatusFor(e.DerivedDockerfile), detail = $"Add a derived Dockerfile: FROM {SegregateAssetsProgram.OriginImage} + COPY --chown={SegregateAssetsProgram.OriginUser}:{SegregateAssetsProgram.OriginUser} ./wwwroot/ {SegregateAssetsProgram.OriginContentRoot}/." }, + new { step = "documentation", status = "create-or-update", detail = "Document that deployed static content is served by Codebelt Static Content Provider, and that wwwroot remains the authoring root." }, + }; + + if (options.CdnEquivalent) + { + plan.Add(new { step = "cdn-equivalent", status = "create", detail = $"A CDN/shared equivalent exists: provision a second local origin on host port {options.CdnPort} from its own shared-asset root; do NOT duplicate CDN assets into the application's wwwroot." }); + } + else + { + plan.Add(new { step = "cdn-equivalent", status = "skip", detail = "No CDN/shared equivalent: configure only App-asset segregation." }); + } + + return plan; + } + + private static string RenderInspection(InspectionResult r) + { + var sb = new StringBuilder(); + sb.AppendLine($"Inspection: {r.RepoRoot}"); + sb.AppendLine($"Classification: {r.Classification}"); + sb.AppendLine($"Selected project: {r.SelectedProject ?? "(none)"}"); + sb.AppendLine($"Cuemon present: {r.CuemonPresent}"); + sb.AppendLine(); + sb.AppendLine($"Web projects ({r.WebProjects.Count}):"); + foreach (var p in r.WebProjects) + sb.AppendLine($" - {p.RelativePath} [sdk={p.Sdk}] wwwroot={p.HasWwwroot}"); + if (r.RiskSignals.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Risk signals (do NOT blindly exclude):"); + foreach (var s in r.RiskSignals) sb.AppendLine($" ! {s.Code}: {s.Detail}"); + } + var e = r.ExistingSegregation; + sb.AppendLine(); + sb.AppendLine("Existing segregation:"); + sb.AppendLine($" publish-exclusion={e.PublishExclusion} launch-profile={e.SegregatedLaunchProfile} compose={e.ComposeService} dockerfile={e.DerivedDockerfile} cuemon={e.CuemonAppOptions}"); + sb.AppendLine(); + sb.AppendLine($"Recommendation: {r.Recommendation}"); + return sb.ToString().TrimEnd(); + } + + private static string RenderPlan(InspectionResult r, Options o, List decisions) + { + var sb = new StringBuilder(); + sb.AppendLine($"Plan for {r.SelectedProject ?? "(unresolved)"} [{r.Classification}]"); + sb.AppendLine($"App origin port: {o.AppPort}" + (o.CdnEquivalent ? $" CDN origin port: {o.CdnPort}" : " (no CDN equivalent)")); + sb.AppendLine(); + foreach (var d in decisions) + { + var json = JsonSerializer.Serialize(d, SegregateAssetsProgram.JsonOut); + using var doc = JsonDocument.Parse(json); + var step = doc.RootElement.GetProperty("step").GetString(); + var status = doc.RootElement.GetProperty("status").GetString(); + var detail = doc.RootElement.GetProperty("detail").GetString(); + sb.AppendLine($" [{status}] {step}: {detail}"); + } + return sb.ToString().TrimEnd(); + } + + private static string RenderVerify(string project, string publishDir, PublishLeakDetector.Result leak, + LaunchProfileValidator.Result? launch, ComposeValidator.Result? compose, bool ok) + { + var sb = new StringBuilder(); + sb.AppendLine($"Verify: {project}"); + sb.AppendLine($"Publish inspected: {publishDir}"); + sb.AppendLine(leak.Passed + ? "PASS application-owned wwwroot is ABSENT from the publish artifact." + : "FAIL application-owned wwwroot LEAKED into the publish artifact:"); + foreach (var f in leak.LeakedAppAssets) sb.AppendLine($" leaked: {f}"); + if (leak.PreservedSharedAssets.Count > 0) + sb.AppendLine($" preserved shared/framework assets: {leak.PreservedSharedAssets.Count} (e.g. {leak.PreservedSharedAssets[0]})"); + if (launch is not null) + { + sb.AppendLine(); + sb.AppendLine($"Local launch profile: exists={launch.ProfileExists} http={launch.IsHttp} httpLocalOrigin={launch.HasHttpLocalOrigin} unsafeProtocol={launch.HasUnsafeProtocol}"); + foreach (var f in launch.Findings) sb.AppendLine($" - {f}"); + } + if (compose is not null) + { + sb.AppendLine($"Local origin compose: originImage={compose.UsesOriginImage} roMount={compose.ReadOnlyMount} roRootFs={compose.ReadOnlyRootFs} nonPrivileged={compose.NonPrivileged} noDockerSocket={compose.NoDockerSocket}"); + foreach (var f in compose.Findings) sb.AppendLine($" - {f}"); + } + sb.AppendLine(); + sb.AppendLine(ok ? "RESULT: PASS" : "RESULT: FAIL"); + return sb.ToString().TrimEnd(); + } +} + +// --------------------------------------------------------------------------- +// Self-test — hermetic; creates synthetic fixtures in a temp directory and asserts +// the deterministic detectors. Requires no dotnet build, no Docker, and no network. +// --------------------------------------------------------------------------- + +internal static class SelfTest +{ + private static int _passed; + private static readonly List _failures = new(); + + public static int Run(Options options) + { + var root = Path.Combine(Path.GetTempPath(), $"segregated-selftest-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + TestWebSdkDetection(); + TestSimpleWebAppClassification(root); + TestBlazorWebAssemblyIsRisky(root); + TestRazorClassLibraryIsRisky(root); + TestScopedCssIsRisky(root); + TestFrontendBuildIsRisky(root); + TestAmbiguousMultiProject(root); + TestNoWwwroot(root); + TestIdempotencyDetection(root); + TestAlreadySegregatedClassification(root); + TestLaunchProfileValidatorSafe(); + TestLaunchProfileValidatorRejectsProtocolRelative(); + TestLaunchProfileValidatorRejectsHttpsLocal(); + TestComposeValidatorSafe(); + TestComposeValidatorRejectsPrivilegedAndSocket(); + TestPublishLeakDetected(); + TestPublishLeakCleanWithPreservedSharedAssets(); + } + finally + { + try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } + } + + var total = _passed + _failures.Count; + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize(new + { + tool = SegregateAssetsProgram.ToolName, + ok = _failures.Count == 0, + passed = _passed, + failed = _failures.Count, + total, + failures = _failures, + }, SegregateAssetsProgram.JsonOut)); + } + else + { + foreach (var f in _failures) Console.Error.WriteLine($"FAIL: {f}"); + Console.WriteLine($"{SegregateAssetsProgram.ToolName} self-test: {_passed}/{total} passed."); + } + return _failures.Count == 0 ? (int)ExitCode.Success : (int)ExitCode.SelfTestFailed; + } + + private static void TestWebSdkDetection() + { + Assert("web SDK detected", ProjectScanner.IsWebSdk(ProjectScanner.ReadSdk(""))); + Assert("class-library SDK not web", !ProjectScanner.IsWebSdk(ProjectScanner.ReadSdk(""))); + Assert("razor SDK not web", !ProjectScanner.IsWebSdk(ProjectScanner.ReadSdk(""))); + } + + private static void TestSimpleWebAppClassification(string root) + { + var dir = NewProject(root, "simple", webApp: true, wwwroot: true); + var inspection = Commands.Inspect(dir, null); + Assert("simple: classified Simple", inspection.Classification == Classifier.Simple); + Assert("simple: no risk signals", inspection.RiskSignals.Count == 0); + Assert("simple: selected resolved", inspection.SelectedProject is not null); + } + + private static void TestBlazorWebAssemblyIsRisky(string root) + { + var dir = NewProject(root, "blazorwasm", webApp: true, wwwroot: true, extraCsproj: + ""); + var inspection = Commands.Inspect(dir, null); + Assert("blazor-wasm: risky classification", inspection.Classification == Classifier.RiskyGeneratedAssets); + Assert("blazor-wasm: has BLAZOR_WEBASSEMBLY", inspection.RiskSignals.Any(s => s.Code == "BLAZOR_WEBASSEMBLY")); + } + + private static void TestRazorClassLibraryIsRisky(string root) + { + var appDir = NewProject(root, "rcl-app", webApp: true, wwwroot: true); + var libDir = NewProject(root, "rcl-lib", webApp: false, wwwroot: true, sdk: "Microsoft.NET.Sdk.Razor"); + var appCsproj = Directory.GetFiles(appDir, "*.csproj").First(); + var libCsproj = Directory.GetFiles(libDir, "*.csproj").First(); + var rel = Path.GetRelativePath(appDir, libCsproj); + File.WriteAllText(appCsproj, $""" + + net10.0 + + + """); + var risks = StaticWebAssetRiskDetector.Detect(appCsproj, root); + Assert("rcl: RAZOR_CLASS_LIBRARY_ASSETS detected", risks.Any(s => s.Code == "RAZOR_CLASS_LIBRARY_ASSETS")); + } + + private static void TestScopedCssIsRisky(string root) + { + var dir = NewProject(root, "scopedcss", webApp: true, wwwroot: true); + File.WriteAllText(Path.Combine(dir, "Index.cshtml.css"), "h1{color:red}"); + var csproj = Directory.GetFiles(dir, "*.csproj").First(); + var risks = StaticWebAssetRiskDetector.Detect(csproj, root); + Assert("scoped-css: SCOPED_CSS detected", risks.Any(s => s.Code == "SCOPED_CSS")); + } + + private static void TestFrontendBuildIsRisky(string root) + { + var dir = NewProject(root, "frontend", webApp: true, wwwroot: true); + File.WriteAllText(Path.Combine(dir, "package.json"), + "{ \"scripts\": { \"build\": \"vite build\" }, \"devDependencies\": { \"vite\": \"^5.0.0\" } }"); + var csproj = Directory.GetFiles(dir, "*.csproj").First(); + var risks = StaticWebAssetRiskDetector.Detect(csproj, root); + Assert("frontend: FRONTEND_BUILD_PIPELINE detected", risks.Any(s => s.Code == "FRONTEND_BUILD_PIPELINE")); + } + + private static void TestAmbiguousMultiProject(string root) + { + var multiRoot = Path.Combine(root, "multi"); + Directory.CreateDirectory(multiRoot); + NewProject(multiRoot, "web-a", webApp: true, wwwroot: true); + NewProject(multiRoot, "web-b", webApp: true, wwwroot: true); + var inspection = Commands.Inspect(multiRoot, null); + Assert("multi: ambiguous without --project", inspection.Classification == Classifier.Ambiguous); + Assert("multi: two web projects", inspection.WebProjects.Count == 2); + var chosen = Commands.Inspect(multiRoot, inspection.WebProjects[0].RelativePath); + Assert("multi: resolves with --project", chosen.SelectedProject is not null && chosen.Classification == Classifier.Simple); + } + + private static void TestNoWwwroot(string root) + { + var dir = NewProject(root, "nowwwroot", webApp: true, wwwroot: false); + var inspection = Commands.Inspect(dir, null); + Assert("no-wwwroot: classified NoWwwroot", inspection.Classification == Classifier.NoWwwroot); + } + + private static void TestIdempotencyDetection(string root) + { + var dir = NewProject(root, "idem", webApp: true, wwwroot: true); + var csproj = Directory.GetFiles(dir, "*.csproj").First(); + File.WriteAllText(csproj, """ + + net10.0 + + + + + """); + Directory.CreateDirectory(Path.Combine(dir, "Properties")); + File.WriteAllText(Path.Combine(dir, "Properties", "launchSettings.json"), """ + { "profiles": { "http-segregated-assets": { "commandName": "Project" } } } + """); + File.WriteAllText(Path.Combine(dir, "compose.segregated-assets.yml"), + "services:\n app-assets:\n image: codebeltnet/web-cdn-origin:2.0.0\n"); + File.WriteAllText(Path.Combine(dir, "Assets.Dockerfile"), + "FROM codebeltnet/web-cdn-origin:2.0.0\nCOPY --chown=65532:65532 ./wwwroot/ /cdnroot/\n"); + var existing = IdempotencyDetector.Detect(csproj, dir); + Assert("idem: publish exclusion present", existing.PublishExclusion); + Assert("idem: launch profile present", existing.SegregatedLaunchProfile); + Assert("idem: compose service present", existing.ComposeService); + Assert("idem: derived dockerfile present", existing.DerivedDockerfile); + } + + private static void TestAlreadySegregatedClassification(string root) + { + var dir = NewProject(root, "done", webApp: true, wwwroot: true); + var csproj = Directory.GetFiles(dir, "*.csproj").First(); + File.WriteAllText(csproj, """ + + net10.0 + + + """); + Directory.CreateDirectory(Path.Combine(dir, "Properties")); + File.WriteAllText(Path.Combine(dir, "Properties", "launchSettings.json"), + "{ \"profiles\": { \"http-segregated-assets\": { \"commandName\": \"Project\" } } }"); + var inspection = Commands.Inspect(dir, null); + Assert("already: classified AlreadySegregated", inspection.Classification == Classifier.AlreadySegregated); + } + + private static void TestLaunchProfileValidatorSafe() + { + var json = """ + { "profiles": { "http-segregated-assets": { + "commandName": "Project", + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SegregatedAssets__App__BaseUrl": "http://localhost:8080", + "SegregatedAssets__App__Scheme": "Http" + } + } } } + """; + var r = LaunchProfileValidator.Validate(json, "http-segregated-assets"); + Assert("launch-safe: profile exists", r.ProfileExists); + Assert("launch-safe: http", r.IsHttp); + Assert("launch-safe: http local origin", r.HasHttpLocalOrigin); + Assert("launch-safe: no unsafe protocol", !r.HasUnsafeProtocol); + } + + private static void TestLaunchProfileValidatorRejectsProtocolRelative() + { + var json = """ + { "profiles": { "http-segregated-assets": { + "applicationUrl": "http://localhost:5080", + "environmentVariables": { "SegregatedAssets__App__BaseUrl": "//localhost:8080" } + } } } + """; + var r = LaunchProfileValidator.Validate(json, "http-segregated-assets"); + Assert("launch-protorel: flagged unsafe", r.HasUnsafeProtocol); + } + + private static void TestLaunchProfileValidatorRejectsHttpsLocal() + { + var json = """ + { "profiles": { "http-segregated-assets": { + "applicationUrl": "https://localhost:5443", + "environmentVariables": { "SegregatedAssets__App__BaseUrl": "https://localhost:8080" } + } } } + """; + var r = LaunchProfileValidator.Validate(json, "http-segregated-assets"); + Assert("launch-httpslocal: not http", !r.IsHttp); + Assert("launch-httpslocal: flagged unsafe", r.HasUnsafeProtocol); + } + + private static void TestComposeValidatorSafe() + { + var compose = """ + services: + app-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + read_only: true + cap_drop: [ALL] + ports: ["8080:8080"] + volumes: + - ./src/Web/wwwroot:/cdnroot:ro + """; + var r = ComposeValidator.Validate(compose); + Assert("compose-safe: origin image", r.UsesOriginImage); + Assert("compose-safe: read-only mount", r.ReadOnlyMount); + Assert("compose-safe: read-only rootfs", r.ReadOnlyRootFs); + Assert("compose-safe: non-privileged", r.NonPrivileged); + Assert("compose-safe: no docker socket", r.NoDockerSocket); + } + + private static void TestComposeValidatorRejectsPrivilegedAndSocket() + { + var compose = """ + services: + app-assets: + image: codebeltnet/web-cdn-origin:2.0.0 + privileged: true + volumes: + - ./src/Web/wwwroot:/cdnroot:ro + - /var/run/docker.sock:/var/run/docker.sock + """; + var r = ComposeValidator.Validate(compose); + Assert("compose-bad: privileged flagged", !r.NonPrivileged); + Assert("compose-bad: docker socket flagged", !r.NoDockerSocket); + } + + private static void TestPublishLeakDetected() + { + var probe = Path.Combine(Path.GetTempPath(), $"segregated-leak-{Guid.NewGuid():N}"); + var src = Path.Combine(probe, "src", "wwwroot"); + var pub = Path.Combine(probe, "pub"); + Directory.CreateDirectory(Path.Combine(src, "css")); + Directory.CreateDirectory(Path.Combine(pub, "wwwroot", "css")); + File.WriteAllText(Path.Combine(src, "app.js"), "x"); + File.WriteAllText(Path.Combine(src, "css", "site.css"), "y"); + File.WriteAllText(Path.Combine(pub, "wwwroot", "app.js"), "x"); + File.WriteAllText(Path.Combine(pub, "wwwroot", "css", "site.css"), "y"); + try + { + var r = PublishLeakDetector.Detect(src, pub); + Assert("leak: detected as failed", !r.Passed); + Assert("leak: two app assets leaked", r.LeakedAppAssets.Count == 2); + } + finally { try { Directory.Delete(probe, true); } catch { } } + } + + private static void TestPublishLeakCleanWithPreservedSharedAssets() + { + var probe = Path.Combine(Path.GetTempPath(), $"segregated-clean-{Guid.NewGuid():N}"); + var src = Path.Combine(probe, "src", "wwwroot"); + var pub = Path.Combine(probe, "pub"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(Path.Combine(pub, "wwwroot", "_content", "Lib")); + File.WriteAllText(Path.Combine(src, "app.js"), "x"); + File.WriteAllText(Path.Combine(pub, "wwwroot", "_content", "Lib", "shared.css"), "z"); + try + { + var r = PublishLeakDetector.Detect(src, pub); + Assert("clean: passed (app asset absent)", r.Passed); + Assert("clean: shared asset preserved", r.PreservedSharedAssets.Any(p => p.Contains("_content/Lib/shared.css"))); + } + finally { try { Directory.Delete(probe, true); } catch { } } + } + + // --- helpers --- + + private static string NewProject(string root, string name, bool webApp, bool wwwroot, + string? sdk = null, string? extraCsproj = null) + { + var dir = Path.Combine(root, name); + Directory.CreateDirectory(dir); + var resolvedSdk = sdk ?? (webApp ? "Microsoft.NET.Sdk.Web" : "Microsoft.NET.Sdk"); + File.WriteAllText(Path.Combine(dir, $"{name}.csproj"), $""" + + net10.0 + {extraCsproj ?? string.Empty} + + """); + File.WriteAllText(Path.Combine(dir, "Program.cs"), "// entrypoint"); + if (wwwroot) + { + Directory.CreateDirectory(Path.Combine(dir, "wwwroot")); + File.WriteAllText(Path.Combine(dir, "wwwroot", "site.css"), "body{}"); + } + return dir; + } + + private static void Assert(string name, bool condition) + { + if (condition) _passed++; + else _failures.Add(name); + } +} diff --git a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 new file mode 100644 index 0000000..dd2777f --- /dev/null +++ b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 @@ -0,0 +1,87 @@ +#!/usr/bin/env pwsh +# Deterministic test harness for the dotnet-segregated-assets runner. +# Runs the bundled runner's built-in --self-test (hermetic: no dotnet publish, Docker, or network), +# then exercises the real inspect/plan/verify commands against synthetic on-disk fixtures so the +# end-to-end CLI surface is covered too. Everything is created and removed under the system temp +# directory so nothing leaks into the repository. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptRoot = $PSScriptRoot +$runner = Join-Path $scriptRoot 'segregate-assets.cs' +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "Missing runner: $runner" +} + +function Invoke-Runner { + param([Parameter(Mandatory)][string[]]$RunnerArgs) + $output = & dotnet run --file $runner -- @RunnerArgs 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($output -join [Environment]::NewLine) } +} + +$failures = New-Object System.Collections.Generic.List[string] +function Assert-True { + param([string]$Name, [bool]$Condition) + if ($Condition) { Write-Host " [PASS] $Name" } else { $failures.Add($Name); Write-Host " [FAIL] $Name" } +} + +Write-Host 'dotnet-segregated-assets: built-in --self-test' +$selfTest = Invoke-Runner -RunnerArgs @('--self-test', '--json') +Assert-True 'built-in self-test exits 0' ($selfTest.ExitCode -eq 0) +Assert-True 'built-in self-test reports ok' ($selfTest.Output -match '"ok":\s*true') +Assert-True 'built-in self-test has zero failures' ($selfTest.Output -match '"failed":\s*0') + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("segregated-harness-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +try { + # A conventional web app with a physical wwwroot and no CDN equivalent. + $appDir = Join-Path $workspace 'Web' + New-Item -ItemType Directory -Path (Join-Path $appDir 'wwwroot/css') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $appDir 'Web.csproj') -Encoding utf8 -Value @' + + net10.0 + +'@ + Set-Content -LiteralPath (Join-Path $appDir 'Program.cs') -Encoding utf8 -Value '// entrypoint' + Set-Content -LiteralPath (Join-Path $appDir 'wwwroot/css/site.css') -Encoding utf8 -Value 'body{}' + + Write-Host 'inspect: conventional app' + $inspect = Invoke-Runner -RunnerArgs @('inspect', '--repo-root', $workspace, '--json') + Assert-True 'inspect exits 0' ($inspect.ExitCode -eq 0) + Assert-True 'inspect classifies Simple' ($inspect.Output -match '"classification":\s*"Simple"') + + Write-Host 'plan: conventional app, no CDN equivalent' + $plan = Invoke-Runner -RunnerArgs @('plan', '--repo-root', $workspace, '--json') + Assert-True 'plan exits 0' ($plan.ExitCode -eq 0) + Assert-True 'plan proposes publish-exclusion' ($plan.Output -match 'publish-exclusion') + Assert-True 'plan skips CDN equivalent by default' ($plan.Output -match '"cdn-equivalent"[\s\S]*?"status":\s*"skip"') + + # verify against a fabricated publish output that leaked app assets => must fail. + $leakedPub = Join-Path $workspace 'pub-leak' + New-Item -ItemType Directory -Path (Join-Path $leakedPub 'wwwroot/css') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $leakedPub 'wwwroot/css/site.css') -Encoding utf8 -Value 'body{}' + Write-Host 'verify: leaked publish output' + $verifyLeak = Invoke-Runner -RunnerArgs @('verify', '--repo-root', $workspace, '-p', 'Web/Web.csproj', '--publish-dir', $leakedPub, '--json') + Assert-True 'verify detects leak (exit 66)' ($verifyLeak.ExitCode -eq 66) + Assert-True 'verify reports leaked app asset' ($verifyLeak.Output -match 'css/site\.css') + + # verify against a clean publish output (app assets absent, shared _content preserved) => passes. + $cleanPub = Join-Path $workspace 'pub-clean' + New-Item -ItemType Directory -Path (Join-Path $cleanPub 'wwwroot/_content/Lib') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $cleanPub 'wwwroot/_content/Lib/shared.css') -Encoding utf8 -Value '/*shared*/' + Write-Host 'verify: clean publish output' + $verifyClean = Invoke-Runner -RunnerArgs @('verify', '--repo-root', $workspace, '-p', 'Web/Web.csproj', '--publish-dir', $cleanPub, '--json') + Assert-True 'verify passes on clean publish (exit 0)' ($verifyClean.ExitCode -eq 0) + Assert-True 'verify preserves shared _content asset' ($verifyClean.Output -match '_content/Lib/shared\.css') +} +finally { + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } +} + +if ($failures.Count -gt 0) { + Write-Error ("dotnet-segregated-assets harness FAILED: {0} check(s) failed:`n - {1}" -f $failures.Count, ($failures -join "`n - ")) + exit 1 +} + +Write-Host 'dotnet-segregated-assets runner harness: PASS' diff --git a/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 new file mode 100644 index 0000000..87e75f2 --- /dev/null +++ b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 @@ -0,0 +1,71 @@ +#!/usr/bin/env pwsh +# Structural + contract validation for the dotnet-segregated-assets skill. +# Confirms required files exist, SKILL.md and FORMS.md keep their non-negotiable contracts, and the +# deterministic runner harness (which includes the built-in --self-test) passes. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$skillRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +$required = @( + 'SKILL.md', 'FORMS.md', 'evals/evals.json', + 'scripts/segregate-assets.cs', 'scripts/test-segregated-assets.ps1', 'scripts/validate-skill.ps1', + 'references/app-vs-cdn.md', 'references/local-development.md', + 'references/production-image.md', 'references/static-web-assets-guardrail.md' +) +foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $skillRoot $relative) -PathType Leaf)) { + throw "Missing required dotnet-segregated-assets file: $relative" + } +} + +$skill = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'SKILL.md')) +$contracts = @( + 'codebeltnet/web-cdn-origin:2.0.0', + '/cdnroot', + 'wwwroot', + 'authoring root', + 'http-segregated-assets', + 'App assets', + 'CDN assets', + 'CopyToPublishDirectory="Never"', + 'StaticWebAssetsEnabled', + '65532', + 'orchestrat', + 'segregate-assets.cs', + 'domain sharding', + 'generated-static-assets', + 'approot' +) +foreach ($needle in $contracts) { + if (-not $skill.Contains($needle, [System.StringComparison]::Ordinal)) { + throw "SKILL.md is missing required contract: $needle" + } +} + +# The skill must not *recommend* the removed 1.4 pattern or a blanket kill switch. It may name them only +# to warn against them, so require the warning framing (a "not"/"Do not"/"Never"/"removed" cue) to be near +# each anti-pattern rather than forbidding the phrase outright. +$antiPatterns = @('approot', 'StaticWebAssetsEnabled') +foreach ($needle in $antiPatterns) { + $idx = $skill.IndexOf($needle, [System.StringComparison]::Ordinal) + $window = $skill.Substring([Math]::Max(0, $idx - 160), [Math]::Min(320, $skill.Length - [Math]::Max(0, $idx - 160))) + if ($window -notmatch '(?i)\b(do not|don''t|never|not\b|removed|reintroduce|instead of|rather than)') { + throw "SKILL.md mentions '$needle' but not in a clear warning/negative context." + } +} + +$forms = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'FORMS.md')) +if (-not $forms.Contains('### cdn_equivalent', [System.StringComparison]::Ordinal)) { + throw 'FORMS.md must define the cdn_equivalent field (the required CDN/shared-asset question).' +} +if (-not $forms.Contains('plain-text', [System.StringComparison]::Ordinal)) { + throw 'FORMS.md must define the deterministic plain-text fallback interaction.' +} + +& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'test-segregated-assets.ps1') +if ($LASTEXITCODE -ne 0) { + throw "Runner test harness failed with exit code $LASTEXITCODE." +} + +Write-Host 'dotnet-segregated-assets skill validation: PASS' From 806c2b95d579ec053bc4dc532204c87dc2ede063 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 17:29:25 +0200 Subject: [PATCH 05/41] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20tighten=20verificati?= =?UTF-8?q?on=20and=20add=20cdn-equivalent=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder classification logic so RiskyGeneratedAssets takes precedence over AlreadySegregated detection, preventing false negatives on risky static assets in partially segregated projects. Make local verification stricter by requiring both launch profile and Compose service presence. Add CDN-equivalent option to allow projects without wwwroot to consume shared/CDN asset roots. Add TestAlreadySegregatedRiskIsRisky assertion to catch regression. --- .../scripts/segregate-assets.cs | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs index 6baed41..837e66a 100644 --- a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs +++ b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs @@ -531,9 +531,9 @@ public static string Classify( { if (webProjects.Count == 0) return NotAWebApp; if (selected is null) return Ambiguous; - if (existing.Complete) return AlreadySegregated; - if (!selected.HasWwwroot && risks.Count == 0) return NoWwwroot; if (risks.Count > 0) return RiskyGeneratedAssets; + if (existing.Complete) return AlreadySegregated; + if (!selected.HasWwwroot) return NoWwwroot; return Simple; } @@ -800,8 +800,10 @@ public static int Verify(Options options) compose = ComposeValidator.Validate(File.ReadAllText(composeFile)); } - var localOk = (launch is null || (!launch.HasUnsafeProtocol && launch.HasHttpLocalOrigin && launch.IsHttp)) && - (compose is null || (compose.UsesOriginImage && compose.ReadOnlyMount && compose.NonPrivileged && compose.NoDockerSocket)); + var localOk = launch is not null && + !launch.HasUnsafeProtocol && launch.HasHttpLocalOrigin && launch.IsHttp && + compose is not null && + compose.UsesOriginImage && compose.ReadOnlyMount && compose.NonPrivileged && compose.NoDockerSocket; var ok = leak.Passed && (!options.CheckLocal || localOk); var payload = new @@ -864,13 +866,25 @@ private static List BuildPlan(InspectionResult inspection, Options optio new { step = "escalate", status = "blocked", detail = "Risky Static Web Assets detected. Do not apply a blanket wwwroot publish exclusion or disable StaticWebAssetsEnabled. Request an explicit generated-static-assets segregation design." }, }; } - if (inspection.Classification is Classifier.NotAWebApp or Classifier.Ambiguous or Classifier.NoWwwroot) - { - return new List - { - new { step = "resolve", status = "blocked", detail = inspection.Recommendation }, - }; - } + if (inspection.Classification is Classifier.NotAWebApp or Classifier.Ambiguous) + { + return new List + { + new { step = "resolve", status = "blocked", detail = inspection.Recommendation }, + }; + } + if (inspection.Classification == Classifier.NoWwwroot) + { + return options.CdnEquivalent + ? new List + { + new { step = "cdn-equivalent", status = "create", detail = $"Configure shared/CDN asset consumption and provision a local origin on host port {options.CdnPort} from its own shared-asset root." }, + } + : new List + { + new { step = "resolve", status = "blocked", detail = inspection.Recommendation }, + }; + } var plan = new List { @@ -992,6 +1006,7 @@ public static int Run(Options options) TestNoWwwroot(root); TestIdempotencyDetection(root); TestAlreadySegregatedClassification(root); + TestAlreadySegregatedRiskIsRisky(root); TestLaunchProfileValidatorSafe(); TestLaunchProfileValidatorRejectsProtocolRelative(); TestLaunchProfileValidatorRejectsHttpsLocal(); @@ -1151,6 +1166,26 @@ private static void TestAlreadySegregatedClassification(string root) Assert("already: classified AlreadySegregated", inspection.Classification == Classifier.AlreadySegregated); } + private static void TestAlreadySegregatedRiskIsRisky(string root) + { + var dir = NewProject(root, "done-risky", webApp: true, wwwroot: true); + var csproj = Directory.GetFiles(dir, "*.csproj").First(); + File.WriteAllText(csproj, """ + + net10.0 + + + """); + Directory.CreateDirectory(Path.Combine(dir, "Properties")); + File.WriteAllText(Path.Combine(dir, "Properties", "launchSettings.json"), + "{ \"profiles\": { \"http-segregated-assets\": { \"commandName\": \"Project\" } } }"); + File.WriteAllText(Path.Combine(dir, "Index.cshtml.css"), "h1{color:red}"); + + var inspection = Commands.Inspect(dir, null); + Assert("already-risky: existing segregation is complete", inspection.ExistingSegregation.Complete); + Assert("already-risky: risk takes precedence", inspection.Classification == Classifier.RiskyGeneratedAssets); + } + private static void TestLaunchProfileValidatorSafe() { var json = """ From c2bd915f65e7bf54bdfac5af8a1476fe477814d3 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 17:29:47 +0200 Subject: [PATCH 06/41] =?UTF-8?q?=E2=9C=85=20add=20edge-case=20verificatio?= =?UTF-8?q?n=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new eval test cases covering verification edge cases: checking that local verification requires both launch profile and Compose service (not just publish invariant cleanliness), that risky-asset classification blocks execution even when segregation is already complete, and that projects without wwwroot can produce CDN-only work when shared asset roots are available. Add corresponding test fixtures and harness assertions. --- .../dotnet-segregated-assets/evals/evals.json | 54 +++++++++++++++++++ .../already-segregated-risk/Index.cshtml.css | 1 + .../files/already-segregated-risk/Program.cs | 1 + .../Properties/launchSettings.json | 7 +++ .../already-segregated-risk/Risky.Web.csproj | 4 ++ .../wwwroot/css/site.css | 1 + .../scripts/test-segregated-assets.ps1 | 20 +++++++ 7 files changed, 88 insertions(+) create mode 100644 skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Index.cshtml.css create mode 100644 skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Program.cs create mode 100644 skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Properties/launchSettings.json create mode 100644 skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Risky.Web.csproj create mode 100644 skills/dotnet-segregated-assets/evals/files/already-segregated-risk/wwwroot/css/site.css diff --git a/skills/dotnet-segregated-assets/evals/evals.json b/skills/dotnet-segregated-assets/evals/evals.json index 4c6deb3..bd292c5 100644 --- a/skills/dotnet-segregated-assets/evals/evals.json +++ b/skills/dotnet-segregated-assets/evals/evals.json @@ -197,6 +197,60 @@ "evals/files/segregated-app/compose.segregated-assets.yml", "evals/files/segregated-app/Assets.Dockerfile" ] + }, + { + "id": 11, + "prompt": "Ledger.Web has a clean isolated publish directory, but it has no matching http-segregated-assets launch profile and no Static Content Provider Compose service. Run verify with --check-local and report the result. Missing local topology must not be treated as valid just because the publish invariant passes.", + "expected_output": "The runner reports the publish invariant as clean but returns a failed verification result because the required segregated launch profile and origin Compose service are missing.", + "expectations": [ + "Runs verify with --check-local against the selected Ledger.Web project", + "Requires both the matching HTTP launch profile and a matching Static Content Provider Compose service for localOk", + "Reports verification failure when either local topology component is absent, even when no app-owned files leaked", + "NEGATIVE: does not treat missing launch/Compose validation results as a passing local topology" + ], + "files": [ + "evals/files/no-cuemon/Ledger.Web.csproj", + "evals/files/no-cuemon/Program.cs", + "evals/files/no-cuemon/appsettings.json", + "evals/files/no-cuemon/wwwroot/css/site.css" + ] + }, + { + "id": 12, + "prompt": "Risky.Web already has a publish exclusion and an http-segregated-assets profile, but it also contains generated static-asset risk. Inspect it and decide whether it is safe to reconcile.", + "expected_output": "The runner classifies Risky.Web as RiskyGeneratedAssets and keeps the escalation guardrail ahead of AlreadySegregated, so the plan blocks and requests an explicit generated-static-assets design.", + "expectations": [ + "Detects the generated/static-asset risk in the already partially segregated project", + "Classifies the project as RiskyGeneratedAssets even though existing segregation is complete", + "Blocks reconciliation with the explicit generated-static-assets escalation", + "NEGATIVE: does not return AlreadySegregated before evaluating risk" + ], + "files": [ + "evals/files/already-segregated-risk/Risky.Web.csproj", + "evals/files/already-segregated-risk/Program.cs", + "evals/files/already-segregated-risk/Index.cshtml.css", + "evals/files/already-segregated-risk/Properties/launchSettings.json", + "evals/files/already-segregated-risk/wwwroot/css/site.css" + ] + }, + { + "id": 13, + "prompt": "The selected Acme.Api web project has no wwwroot, but the application consumes an explicit shared CDN asset source. Run plan with the CDN-equivalent choice and produce the CDN-only work instead of blocking on the missing local wwwroot.", + "expected_output": "The runner classifies Acme.Api as NoWwwroot and returns a create cdn-equivalent decision for a local origin from the shared-asset root; it does not return a blocked resolve step.", + "expectations": [ + "Selects Acme.Api explicitly rather than planning against the other web project", + "Recognizes the NoWwwroot classification", + "With the CDN-equivalent option enabled, creates the CDN-only local-origin decision", + "NEGATIVE: does not return the no-wwwroot resolve blocker when a CDN equivalent exists" + ], + "files": [ + "evals/files/multi-project/Acme.slnx", + "evals/files/multi-project/src/Acme.Api/Acme.Api.csproj", + "evals/files/multi-project/src/Acme.Api/Program.cs", + "evals/files/multi-project/src/Acme.Site/Acme.Site.csproj", + "evals/files/multi-project/src/Acme.Site/Program.cs", + "evals/files/multi-project/src/Acme.Site/wwwroot/css/site.css" + ] } ] } diff --git a/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Index.cshtml.css b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Index.cshtml.css new file mode 100644 index 0000000..5ce768c --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Index.cshtml.css @@ -0,0 +1 @@ +h1 { color: red; } diff --git a/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Program.cs b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Program.cs new file mode 100644 index 0000000..13b0f83 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Program.cs @@ -0,0 +1 @@ +// entrypoint diff --git a/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Properties/launchSettings.json b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Properties/launchSettings.json new file mode 100644 index 0000000..3183910 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "http-segregated-assets": { + "commandName": "Project" + } + } +} diff --git a/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Risky.Web.csproj b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Risky.Web.csproj new file mode 100644 index 0000000..a3ae334 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/Risky.Web.csproj @@ -0,0 +1,4 @@ + + net10.0 + + diff --git a/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/wwwroot/css/site.css b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/wwwroot/css/site.css new file mode 100644 index 0000000..bc406f0 --- /dev/null +++ b/skills/dotnet-segregated-assets/evals/files/already-segregated-risk/wwwroot/css/site.css @@ -0,0 +1 @@ +body { margin: 0; } diff --git a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 index dd2777f..7dac880 100644 --- a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 +++ b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 @@ -57,6 +57,21 @@ try { Assert-True 'plan proposes publish-exclusion' ($plan.Output -match 'publish-exclusion') Assert-True 'plan skips CDN equivalent by default' ($plan.Output -match '"cdn-equivalent"[\s\S]*?"status":\s*"skip"') + # An explicitly selected web project without wwwroot can still consume a shared/CDN asset root. + $apiDir = Join-Path $workspace 'Api' + New-Item -ItemType Directory -Path $apiDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $apiDir 'Api.csproj') -Encoding utf8 -Value @' + + net10.0 + +'@ + Set-Content -LiteralPath (Join-Path $apiDir 'Program.cs') -Encoding utf8 -Value '// entrypoint' + Write-Host 'plan: no wwwroot with CDN equivalent' + $cdnOnlyPlan = Invoke-Runner -RunnerArgs @('plan', '--repo-root', $workspace, '-p', 'Api/Api.csproj', '--cdn-equivalent', '--json') + Assert-True 'CDN-only plan exits 0' ($cdnOnlyPlan.ExitCode -eq 0) + Assert-True 'CDN-only plan creates CDN-equivalent work' ($cdnOnlyPlan.Output -match '"step":\s*"cdn-equivalent"[\s\S]*?"status":\s*"create"') + Assert-True 'CDN-only plan is not blocked' ($cdnOnlyPlan.Output -notmatch '"status":\s*"blocked"') + # verify against a fabricated publish output that leaked app assets => must fail. $leakedPub = Join-Path $workspace 'pub-leak' New-Item -ItemType Directory -Path (Join-Path $leakedPub 'wwwroot/css') -Force | Out-Null @@ -74,6 +89,11 @@ try { $verifyClean = Invoke-Runner -RunnerArgs @('verify', '--repo-root', $workspace, '-p', 'Web/Web.csproj', '--publish-dir', $cleanPub, '--json') Assert-True 'verify passes on clean publish (exit 0)' ($verifyClean.ExitCode -eq 0) Assert-True 'verify preserves shared _content asset' ($verifyClean.Output -match '_content/Lib/shared\.css') + + Write-Host 'verify: incomplete local topology' + $verifyIncompleteLocal = Invoke-Runner -RunnerArgs @('verify', '--repo-root', $workspace, '-p', 'Web/Web.csproj', '--publish-dir', $cleanPub, '--check-local', '--json') + Assert-True 'verify rejects missing local topology (exit 66)' ($verifyIncompleteLocal.ExitCode -eq 66) + Assert-True 'verify reports incomplete local topology as not ok' ($verifyIncompleteLocal.Output -match '"ok":\s*false') } finally { if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } From e25fb0e313329430986c2807bb6c78ed2a7010ad Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 17:29:59 +0200 Subject: [PATCH 07/41] =?UTF-8?q?=F0=9F=92=AC=20update=20segregated-assets?= =?UTF-8?q?=20feature=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add feature bullet documenting the skill's fail-closed verification and planning behavior: local verification requires both matching HTTP launch profile and origin Compose service, generated-asset risks override existing-segregation detection, and no-wwwroot projects can still produce CDN-only work when shared equivalents exist. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ec6886e..1d16d71 100644 --- a/README.md +++ b/README.md @@ -712,6 +712,7 @@ Cross-platform .NET developers usually get Linux test feedback the slow way: pus - **Architectural motivation, stated honestly** — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading; never justified as HTTP/1.x domain sharding or extra browser connection parallelism - **Adapts, never imposes** — it reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the application's own base-URL setting otherwise, and never adds a Cuemon dependency just to migrate - **Idempotent and deterministic** — re-running reconciles existing segregation instead of duplicating MSBuild items, launch profiles, Compose services, or Dockerfiles, and the runner ships a built-in `--self-test` plus a PowerShell harness +- **Fail-closed verification and planning** — local verification requires both the matching HTTP launch profile and origin Compose service, generated-asset risks override existing-segregation detection, and a no-`wwwroot` project can still produce CDN-only work when a shared equivalent exists ### Why agent-smith? **agent-smith** applies one coherent engineering standard — *consistency is key* — across a whole task instead of bolting a review onto the end. Invoke it explicitly as `/agent-smith `; it also auto-triggers for engineering work such as architecture, implementation, code review, public API and compatibility analysis, testing, performance, skill authoring, security and DevSecOps, CI/CD, delivery, and governance. From ea881b8032b7257fd14f5cc548a325f680b51979 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 18:27:10 +0200 Subject: [PATCH 08/41] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20enforce=20docker-doc?= =?UTF-8?q?umented=20dockerfile=20naming=20for=20derived=20asset=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Docker-documented .Dockerfile naming convention with PascalCase prefix to the skill specification. The convention (e.g., Assets.Dockerfile) allows developers to explicitly select non-default Dockerfiles using the --file option, improving clarity when managing derived container images. Update evals to verify the convention is followed. --- skills/dotnet-segregated-assets/SKILL.md | 2 ++ skills/dotnet-segregated-assets/evals/evals.json | 10 +++++++--- .../references/production-image.md | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/skills/dotnet-segregated-assets/SKILL.md b/skills/dotnet-segregated-assets/SKILL.md index 3dd8d0c..ac98ca8 100644 --- a/skills/dotnet-segregated-assets/SKILL.md +++ b/skills/dotnet-segregated-assets/SKILL.md @@ -88,6 +88,8 @@ Point CDN asset URLs at that origin locally and at the shared/CDN host in deploy Add a derived image that ships the **actual final asset output** (run the frontend build first if the app generates its `wwwroot`): +Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill, use `Assets.Dockerfile`. This follows Docker's documented convention for distinct Dockerfiles; select the non-default file explicitly with `--file` (or the equivalent Compose `dockerfile` property). Do not use `Dockerfile.assets` or the lowercase `assets.Dockerfile` form. + ```dockerfile FROM codebeltnet/web-cdn-origin:2.0.0 diff --git a/skills/dotnet-segregated-assets/evals/evals.json b/skills/dotnet-segregated-assets/evals/evals.json index bd292c5..de731eb 100644 --- a/skills/dotnet-segregated-assets/evals/evals.json +++ b/skills/dotnet-segregated-assets/evals/evals.json @@ -4,7 +4,7 @@ { "id": 1, "prompt": "This is a conventional ASP.NET Core MVC app (Contoso.Web) with a normal wwwroot folder. We don't have any shared CDN — it's just this app's own CSS, JS, and favicon. Set it up so the deployed app doesn't serve or ship wwwroot itself; the static files should come from Codebelt Static Content Provider instead, but I still want to keep editing files in wwwroot like normal.", - "expected_output": "The skill runs segregate-assets.cs inspect, classifies the project as Simple, confirms no CDN equivalent, and applies App-asset segregation: a targeted item, an http-segregated-assets launch profile pointing App URLs at http://localhost:8080, a local codebeltnet/web-cdn-origin:2.0.0 service mounting wwwroot into /cdnroot read-only, a derived FROM codebeltnet/web-cdn-origin:2.0.0 production image, and documentation. wwwroot stays as the authoring root.", + "expected_output": "The skill runs segregate-assets.cs inspect, classifies the project as Simple, confirms no CDN equivalent, and applies App-asset segregation: a targeted item, an http-segregated-assets launch profile pointing App URLs at http://localhost:8080, a local codebeltnet/web-cdn-origin:2.0.0 service mounting wwwroot into /cdnroot read-only, a derived FROM codebeltnet/web-cdn-origin:2.0.0 production image named Assets.Dockerfile (the PascalCase .Dockerfile convention and explicitly selected with Docker --file), and documentation. wwwroot stays as the authoring root.", "expectations": [ "Runs the bundled runner (segregate-assets.cs) to inspect topology instead of guessing", "Keeps wwwroot as the authoring root and does NOT introduce an approot/cdnroot/staticroot source folder", @@ -12,6 +12,8 @@ "Adds a new http-segregated-assets HTTP launch profile without altering the existing Development profile", "Provisions a local codebeltnet/web-cdn-origin:2.0.0 origin mounting wwwroot into /cdnroot read-only", "Adds a derived production image: FROM codebeltnet/web-cdn-origin:2.0.0 + COPY --chown=65532:65532 ./wwwroot/ /cdnroot/", + "Names the derived Dockerfile Assets.Dockerfile using the PascalCase .Dockerfile convention and selects it explicitly with --file", + "NEGATIVE: does not use Dockerfile.assets or lowercase assets.Dockerfile", "NEGATIVE: does not set false", "NEGATIVE: does not resurrect the 1.4 ADD approot / WORKDIR /cdnroot Dockerfile pattern", "NEGATIVE: does not blindly delete MapStaticAssets" @@ -163,11 +165,12 @@ { "id": 9, "prompt": "Fabrikam.Web has already been set up for segregated assets. Prove that the app-owned wwwroot files really are absent from the published web application.", - "expected_output": "The skill runs segregate-assets.cs verify with --run-publish, which publishes Fabrikam.Web to an isolated temporary directory and confirms the application-owned wwwroot files (css/site.css, js/site.js) are ABSENT from the publish artifact, reporting PASS. It does not write verification output into the repository.", + "expected_output": "The skill runs segregate-assets.cs verify with --run-publish, which publishes Fabrikam.Web to an isolated temporary directory and confirms the application-owned wwwroot files (css/site.css, js/site.js) are ABSENT from the publish artifact, reporting PASS. It retains the derived asset image as Assets.Dockerfile under the PascalCase .Dockerfile convention and does not write verification output into the repository.", "expectations": [ "Runs the runner's verify command with --run-publish (isolated temp output) rather than eyeballing the csproj", "Confirms application-owned wwwroot files are absent from the publish artifact and reports the invariant as satisfied", "Allows shared _content/_framework assets to remain (they are not app-owned)", + "Retains the derived asset image as Assets.Dockerfile, not Dockerfile.assets or lowercase assets.Dockerfile", "NEGATIVE: does not write publish/verification output into the repository", "NEGATIVE: does not claim success from the declaration alone without publishing" ], @@ -184,11 +187,12 @@ { "id": 10, "prompt": "Run the segregated-assets setup on Fabrikam.Web again — I think someone already did it but I want to be sure it's configured.", - "expected_output": "The skill runs inspect, detects existing segregation (publish exclusion + http-segregated-assets profile + compose service + derived Dockerfile), classifies it as AlreadySegregated, and reconciles idempotently — it does not create duplicate MSBuild items, launch profiles, compose services, or Dockerfiles, does not increment ports, and does not overwrite the existing configuration.", + "expected_output": "The skill runs inspect, detects existing segregation (publish exclusion + http-segregated-assets profile + compose service + the existing Assets.Dockerfile), classifies it as AlreadySegregated, and reconciles idempotently — it does not create duplicate MSBuild items, launch profiles, compose services, or Dockerfiles, does not increment ports, and does not overwrite the existing configuration.", "expectations": [ "Runs inspect and detects the existing segregation (AlreadySegregated)", "Reconciles idempotently without creating duplicate csproj items, profiles, compose services, or Dockerfiles", "Does not increment ports unnecessarily or overwrite customized URLs", + "Preserves the existing PascalCase Assets.Dockerfile name and does not create Dockerfile.assets or lowercase assets.Dockerfile", "NEGATIVE: does not add a second competing asset configuration or duplicate the existing setup" ], "files": [ diff --git a/skills/dotnet-segregated-assets/references/production-image.md b/skills/dotnet-segregated-assets/references/production-image.md index 0daba07..e796dcc 100644 --- a/skills/dotnet-segregated-assets/references/production-image.md +++ b/skills/dotnet-segregated-assets/references/production-image.md @@ -4,6 +4,8 @@ Two things happen at deployment: the application-owned static assets are shipped ## Derived asset image +Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill the canonical name is `Assets.Dockerfile`. Docker documents this convention for distinct Dockerfiles and the `--file` option for selecting a non-default filename; use `docker build --file Assets.Dockerfile ...` or the equivalent Compose `dockerfile: Assets.Dockerfile` setting. Do not use `Dockerfile.assets` or the lowercase `assets.Dockerfile` form. See the [Dockerfile overview](https://docs.docker.com/build/concepts/dockerfile/). + When application-owned assets ship as a container image, use `web-cdn-origin:2.0.0` as the base. The normal derived image is conceptually no more complicated than copying the final `wwwroot` output into the image's content root: ```dockerfile From aad44adf1d14256a6e3b7ef54e35740e8c1a7c42 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 18:27:19 +0200 Subject: [PATCH 09/41] =?UTF-8?q?=F0=9F=94=A7=20implement=20dockerfile=20n?= =?UTF-8?q?aming=20enforcement=20in=20runner=20and=20validators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DerivedDockerfileName constant and update the runner to use Assets.Dockerfile by convention. Update test assertions to verify the generated plan mentions Assets.Dockerfile. Add validation contract to ensure SKILL.md and production-image.md document the .Dockerfile naming pattern, PascalCase form, Assets.Dockerfile example, --file option, and negative patterns (Dockerfile.assets, lowercase assets.Dockerfile). --- .../scripts/segregate-assets.cs | 5 +++-- .../scripts/test-segregated-assets.ps1 | 1 + .../scripts/validate-skill.ps1 | 13 +++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs index 837e66a..dee935f 100644 --- a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs +++ b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs @@ -31,6 +31,7 @@ internal static class SegregateAssetsProgram { internal const string ToolName = "dotnet-segregated-assets"; internal const string OriginImage = "codebeltnet/web-cdn-origin:2.0.0"; + internal const string DerivedDockerfileName = "Assets.Dockerfile"; internal const int OriginContainerPort = 8080; internal const string OriginContentRoot = "/cdnroot"; internal const string OriginUser = "65532"; @@ -891,7 +892,7 @@ private static List BuildPlan(InspectionResult inspection, Options optio new { step = "publish-exclusion", status = StatusFor(e.PublishExclusion), detail = "Add to the web project (targeted; do NOT disable StaticWebAssetsEnabled)." }, new { step = "segregated-launch-profile", status = StatusFor(e.SegregatedLaunchProfile), detail = $"Add the '{SegregateAssetsProgram.SegregatedProfileName}' HTTP launch profile pointing App asset URLs at http://localhost:{options.AppPort}." }, new { step = "local-origin", status = StatusFor(e.ComposeService), detail = $"Provide a local {SegregateAssetsProgram.OriginImage} service mounting wwwroot into /cdnroot read-only on host port {options.AppPort}." }, - new { step = "production-image", status = StatusFor(e.DerivedDockerfile), detail = $"Add a derived Dockerfile: FROM {SegregateAssetsProgram.OriginImage} + COPY --chown={SegregateAssetsProgram.OriginUser}:{SegregateAssetsProgram.OriginUser} ./wwwroot/ {SegregateAssetsProgram.OriginContentRoot}/." }, + new { step = "production-image", status = StatusFor(e.DerivedDockerfile), detail = $"Add {SegregateAssetsProgram.DerivedDockerfileName} (PascalCase .Dockerfile) and select it with --file: FROM {SegregateAssetsProgram.OriginImage} + COPY --chown={SegregateAssetsProgram.OriginUser}:{SegregateAssetsProgram.OriginUser} ./wwwroot/ {SegregateAssetsProgram.OriginContentRoot}/." }, new { step = "documentation", status = "create-or-update", detail = "Document that deployed static content is served by Codebelt Static Content Provider, and that wwwroot remains the authoring root." }, }; @@ -1140,7 +1141,7 @@ private static void TestIdempotencyDetection(string root) """); File.WriteAllText(Path.Combine(dir, "compose.segregated-assets.yml"), "services:\n app-assets:\n image: codebeltnet/web-cdn-origin:2.0.0\n"); - File.WriteAllText(Path.Combine(dir, "Assets.Dockerfile"), + File.WriteAllText(Path.Combine(dir, SegregateAssetsProgram.DerivedDockerfileName), "FROM codebeltnet/web-cdn-origin:2.0.0\nCOPY --chown=65532:65532 ./wwwroot/ /cdnroot/\n"); var existing = IdempotencyDetector.Detect(csproj, dir); Assert("idem: publish exclusion present", existing.PublishExclusion); diff --git a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 index 7dac880..ca88c96 100644 --- a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 +++ b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 @@ -55,6 +55,7 @@ try { $plan = Invoke-Runner -RunnerArgs @('plan', '--repo-root', $workspace, '--json') Assert-True 'plan exits 0' ($plan.ExitCode -eq 0) Assert-True 'plan proposes publish-exclusion' ($plan.Output -match 'publish-exclusion') + Assert-True 'plan names the PascalCase derived Dockerfile' ($plan.Output -match 'Assets\.Dockerfile') Assert-True 'plan skips CDN equivalent by default' ($plan.Output -match '"cdn-equivalent"[\s\S]*?"status":\s*"skip"') # An explicitly selected web project without wwwroot can still consume a shared/CDN asset root. diff --git a/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 index 87e75f2..2594d5c 100644 --- a/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 +++ b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 @@ -43,6 +43,19 @@ foreach ($needle in $contracts) { } } +$productionImage = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'references/production-image.md')) +$dockerfileContracts = @('.Dockerfile', 'PascalCase', 'Assets.Dockerfile', 'Dockerfile.assets', '--file') +foreach ($source in @( + [pscustomobject]@{ Name = 'SKILL.md'; Text = $skill }, + [pscustomobject]@{ Name = 'references/production-image.md'; Text = $productionImage } +)) { + foreach ($needle in $dockerfileContracts) { + if (-not $source.Text.Contains($needle, [System.StringComparison]::Ordinal)) { + throw "$($source.Name) is missing Dockerfile naming contract: $needle" + } + } +} + # The skill must not *recommend* the removed 1.4 pattern or a blanket kill switch. It may name them only # to warn against them, so require the warning framing (a "not"/"Do not"/"Never"/"removed" cue) to be near # each anti-pattern rather than forbidding the phrase outright. From adae1c85c8592b5595b0ad631cfc60a3e2b78e0e Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 18:27:27 +0200 Subject: [PATCH 10/41] =?UTF-8?q?=F0=9F=92=AC=20document=20dockerfile=20na?= =?UTF-8?q?ming=20convention=20in=20skill=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the dotnet-segregated-assets skill entry in the README.md available skills table to reflect the new Assets.Dockerfile naming convention using Docker's .Dockerfile form with PascalCase. Add bullet point documenting that the derived production asset image uses Docker naming conventions and is explicitly selected with --file. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d16d71..21a9d9f 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ npx skills add https://github.com/codebeltnet/agentic --skill agent-smith | [dotnet-test](skills/dotnet-test/SKILL.md) | Bootstraps and refactors xUnit projects to Codebelt conventions. It deterministically inspects project roles, target frameworks, xUnit generation, package ownership, inheritance, application entry points—including Bootstrapper `MinimalConsoleProgram`, `MinimalWorkerProgram`, and `MinimalWebProgram` hosts—and every selected `WebApplicationFactory` usage; classifies ordinary unit, ASP.NET Core functional, and console/worker functional tests; modernizes xUnit v2 projects to xUnit v3 plus Microsoft Testing Platform without moving package ownership or changing frameworks; and resolves current stable compatible packages through NuGet-backed isolated compatibility-project restores, including the selected combined package set. Focused web tests use `WebApplicationTestFactory` with an explicit entrypoint-owned `ManagedWebApplicationFixture`, directly or through a narrow `Test`-derived harness; shared web fixtures use `WebApplicationTest` with `ManagedWebApplicationFixture`; focused console/worker tests use `ApplicationTestFactory` with `ManagedApplicationFixture`; and shared non-web fixtures use `ApplicationTest` with `ManagedApplicationFixture`. Deprecated blocking fixtures are migration inputs only and are never emitted because they are scheduled for removal. Functional migrations fail closed unless the chosen Codebelt pattern and managed fixture are present, the legacy or blocking fixture is absent, and test code does not reconstruct the production composition root with its own `WebApplication`, `TestServer`, or `HostBuilder`. Migrations preserve entrypoint-owned startup, host configuration, lazy start, clients, services, configuration, sync/async disposal, isolation, and existing test names, while fresh bootstraps add source-grounded behavior tests. Non-web tests stay in-process and require a resolvable Generic Host; test-only scope reports the exact production adaptation instead of silently rewriting startup or launching a process. | | [dotnet-benchmark](skills/dotnet-benchmark/SKILL.md) | Discovers, prioritizes, and authors trustworthy BenchmarkDotNet experiments for a .NET type following codebelt conventions and using the `Codebelt.Extensions.BenchmarkDotNet.Console` runner. It inspects implementation code, call sites, tests, existing benchmarks, and available profiles instead of benchmarking every public member; ranks likely high-impact operations; selects representative typical, boundary, scaling, and adverse cases; and rejects external-I/O or service-level questions that need profiling, macrobenchmarks, or load tests. It creates fair current-versus-candidate comparisons only when observable work is equivalent, uses baseline-free single-operation characterization when no honest comparator exists, prevents unrelated construction/formatting/equality/hash ratios, requires exact per-case correctness oracles plus a semantic preflight for truthful workload labels, hard-gates interpretation on a complete valid BenchmarkDotNet summary, preserves workload invariants such as selectivity and hit/miss ratios as sizes scale, distinguishes deferred pipeline creation from terminal/materialization work, and performs Release build, discovery listing, and dry execution before any explicit full run. Explicit `yolo` mode auto-accepts routine repo-derived defaults and the proposed plan, then proceeds through build/list/dry validation without confirmation churn; only a separate explicit human instruction can start a full performance run. Its runner preflight recognizes the standard Slim/runtime setup and explains when `SkipBenchmarksWithReports = true` plus a matching `reports/tuning/` artifact deliberately filters a benchmark, preventing needless class renames, disassembly, or tool thrash; after the first valid full result it stops unless deeper diagnostics could change a real engineering decision. Harness setup remains adaptive: it detects `.slnx`/`.sln`, CPM, existing `tuning/` projects, and a reusable `tooling/` runner, onboards only missing pieces, resolves package versions dynamically, and keeps the benchmark class in the SUT namespace. | | [dotnet-remote-testing](skills/dotnet-remote-testing/SKILL.md) | Run .NET tests inside a resolved remote Docker environment and return concise, structured results — Visual Studio's Remote Testing experience (choose an environment → run tests → see results) with the container plumbing hidden behind a deterministic runner (`scripts/remote-test.cs`) the skill orchestrates instead of composing ad-hoc `docker run` commands. It honors Microsoft's existing `testenvironments.json` version-1 contract (`name`, `localRoot`, `dockerImage`, `dockerFile`, with the either/or Docker-source rule), treats that file as authoritative when present, and reports WSL/SSH/unknown types as unsupported rather than converting or silently ignoring them. When no `testenvironments.json` exists it provides a zero-configuration experience built exclusively on official `mcr.microsoft.com/dotnet/sdk` images, discovering the currently supported LTS and STS channels plus the current preview from Microsoft's live `releases-index.json` using `support-phase`/`release-type` (never hardcoded version numbers or even/odd assumptions) and caching that metadata outside the repository for offline reuse. It prefers an exact `latest-sdk` image tag (stripping preview build metadata), validates the tag against Microsoft's registry, and pins each execution to the resolved immutable digest so results are reproducible across environment, image, digest, SDK, and architecture. Execution stages the source into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, mounts a persistent NuGet cache outside the repo, runs restore → build → test with structured TRX collection, classifies failures into distinct kinds (configuration, unsupported environment, Docker unavailable, image resolution, SDK incompatibility, staging, restore, compilation, test-host, test failure, result-processing, cleanup, cancellation, release-metadata) so infrastructure problems are never reported as failing unit tests, and always cleans up transient Docker resources. It never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and never silently falls back to running tests on the host. Docker is the only transport for now, designed so WSL/SSH can be added later without disturbing the deterministic Docker path, which is covered by a comprehensive built-in `--self-test` plus a PowerShell harness. | -| [dotnet-segregated-assets](skills/dotnet-segregated-assets/SKILL.md) | Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional `wwwroot` while deployed static content is served by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) — a separate asset host, not the web application. The skill orchestrates a bundled deterministic runner (`scripts/segregate-assets.cs`) that inspects the static-asset topology, distinguishes App assets (app-owned, authored in `wwwroot`) from shared CDN assets (reusable across applications, never duplicated into `wwwroot`), detects and escalates risky Blazor / Razor Class Library / generated Static Web Assets scenarios instead of blindly excluding them, and proves the publish invariant by publishing to an isolated temp directory. It adds an `http-segregated-assets` HTTP launch profile pointing App URLs at a local read-only origin (scheme-safe, never protocol-relative against an HTTP origin), a hardened local `web-cdn-origin:2.0.0` service mounting `wwwroot` into `/cdnroot` read-only (non-root, read-only root filesystem, no privileged mode, no Docker socket), and a derived production image (`FROM codebeltnet/web-cdn-origin:2.0.0` + `COPY --chown=65532:65532 ./wwwroot/ /cdnroot/`). App-owned `wwwroot` is excluded from web publish with targeted `` metadata rather than the `StaticWebAssetsEnabled` global kill switch, keeping Razor Class Library (`_content`) and framework (`_framework`) assets intact. The motivation is architectural — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading — not HTTP/1.x domain sharding. It reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the app's own base-URL setting otherwise, never adding a Cuemon dependency just to migrate, and reconciles idempotently on re-run. | +| [dotnet-segregated-assets](skills/dotnet-segregated-assets/SKILL.md) | Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional `wwwroot` while deployed static content is served by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) — a separate asset host, not the web application. The skill orchestrates a bundled deterministic runner (`scripts/segregate-assets.cs`) that inspects the static-asset topology, distinguishes App assets (app-owned, authored in `wwwroot`) from shared CDN assets (reusable across applications, never duplicated into `wwwroot`), detects and escalates risky Blazor / Razor Class Library / generated Static Web Assets scenarios instead of blindly excluding them, and proves the publish invariant by publishing to an isolated temp directory. It adds an `http-segregated-assets` HTTP launch profile pointing App URLs at a local read-only origin (scheme-safe, never protocol-relative against an HTTP origin), a hardened local `web-cdn-origin:2.0.0` service mounting `wwwroot` into `/cdnroot` read-only (non-root, read-only root filesystem, no privileged mode, no Docker socket), and a derived `Assets.Dockerfile` production image (`FROM codebeltnet/web-cdn-origin:2.0.0` + `COPY --chown=65532:65532 ./wwwroot/ /cdnroot/`) using Docker's PascalCase `.Dockerfile` convention. App-owned `wwwroot` is excluded from web publish with targeted `` metadata rather than the `StaticWebAssetsEnabled` global kill switch, keeping Razor Class Library (`_content`) and framework (`_framework`) assets intact. The motivation is architectural — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading — not HTTP/1.x domain sharding. It reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the app's own base-URL setting otherwise, never adding a Cuemon dependency just to migrate, and reconciles idempotently on re-run. | | [agent-smith](skills/agent-smith/SKILL.md) | Apply a rigorous, consistent, evidence-driven software-craftsmanship standard across a whole engineering task. Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | ### Copyable Install Commands @@ -709,6 +709,7 @@ Cross-platform .NET developers usually get Linux test feedback the slow way: pus - **Safety guardrail over broken migrations** — Blazor, Blazor WebAssembly, Razor Class Library, scoped CSS, component JavaScript modules, and frontend-build scenarios are detected and escalated rather than blindly excluded; stopping to request an explicit generated-static-assets design is a successful outcome, not a failure - **Scheme-safe local topology** — the `http-segregated-assets` profile points App URLs at an `http://localhost:` origin, never a protocol-relative or `https://localhost` URL that an HTTPS page would break against an HTTP-only origin - **Hardened local origin** — the local `web-cdn-origin:2.0.0` service mounts `wwwroot` into `/cdnroot` read-only as a non-root user, with a read-only root filesystem, no privileged mode, no Docker socket, and only the required port exposed +- **Docker naming aligned** — the derived production asset image uses the Docker-documented `.Dockerfile` form with PascalCase `Assets.Dockerfile`, selected explicitly when a non-default Dockerfile is built - **Architectural motivation, stated honestly** — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading; never justified as HTTP/1.x domain sharding or extra browser connection parallelism - **Adapts, never imposes** — it reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the application's own base-URL setting otherwise, and never adds a Cuemon dependency just to migrate - **Idempotent and deterministic** — re-running reconciles existing segregation instead of duplicating MSBuild items, launch profiles, Compose services, or Dockerfiles, and the runner ships a built-in `--self-test` plus a PowerShell harness From 18663d1fceb655da8e0c615756b5ceb22de443dc Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 19:20:26 +0200 Subject: [PATCH 11/41] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20dotnet-se?= =?UTF-8?q?gregated-assets=20skill=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify skill instructions, improve reference documentation, and align eval fixtures to new naming conventions. The skill's intent and scope remain unchanged; these updates enhance clarity for users and maintainers. --- skills/dotnet-segregated-assets/SKILL.md | 32 ++++---- .../dotnet-segregated-assets/evals/evals.json | 53 ++++++------ ...gregated-assets.yml => compose.assets.yml} | 2 +- .../references/app-vs-cdn.md | 2 +- .../references/local-development.md | 6 +- .../references/production-image.md | 20 ++--- .../references/static-web-assets-guardrail.md | 80 +++++++++---------- 7 files changed, 98 insertions(+), 97 deletions(-) rename skills/dotnet-segregated-assets/evals/files/segregated-app/{compose.segregated-assets.yml => compose.assets.yml} (99%) diff --git a/skills/dotnet-segregated-assets/SKILL.md b/skills/dotnet-segregated-assets/SKILL.md index ac98ca8..ffc2df0 100644 --- a/skills/dotnet-segregated-assets/SKILL.md +++ b/skills/dotnet-segregated-assets/SKILL.md @@ -1,7 +1,7 @@ --- name: dotnet-segregated-assets description: > - Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional wwwroot, while deployed static content is served by Codebelt Static Content Provider (codebeltnet/web-cdn-origin:2.0.0), a separate asset host rather than the web app. Use when asked to segregate static assets, move wwwroot off the web app, serve static files from a separate asset host, or stop shipping wwwroot with the app. Distinguishes App assets (app-owned, from wwwroot) from shared CDN assets, adds an http-segregated-assets launch profile, derives a production asset image, and excludes app-owned wwwroot from publish with targeted MSBuild metadata rather than disabling Static Web Assets globally. Escalates risky Blazor, RCL, and generated Static Web Assets scenarios. Do NOT use to build a general-purpose CDN or migrate non-ASP.NET static sites. + Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional wwwroot, while deployed static content is served by Codebelt Static Content Provider (codebeltnet/web-cdn-origin:2.0.0), a separate asset host rather than the web app. Use when asked to segregate static assets, move wwwroot off the web app, serve static files from a separate asset host, or stop shipping wwwroot with the app. Distinguishes App assets (app-owned, from wwwroot) from shared CDN assets, adds an http-segregated-assets launch profile, derives a production asset image, and excludes app-owned wwwroot from publish with targeted MSBuild metadata while preserving the framework asset pipeline. Escalates risky Blazor, RCL, and generated Static Web Assets scenarios. Do NOT use to build a general-purpose CDN or migrate non-ASP.NET static sites. compatibility: > Requires the .NET SDK 10+ and PowerShell 7+. Docker is optional (only for the local origin). --- @@ -26,12 +26,12 @@ Commands: `inspect` (discover web projects, classify static-asset topology, dete ## Critical -- **Do not replace `wwwroot`.** Developers keep authoring there. Never introduce an `approot`, `cdnroot`, or `staticroot` source folder for app-owned assets, and never resurrect the removed 1.4 `ADD approot` / `WORKDIR /cdnroot` Dockerfile pattern. Version 2.0 owns its `/cdnroot`, port, runtime user, and working directory. -- **Exclude app-owned `wwwroot` from publish with targeted metadata, not a global kill switch.** Prefer ``. Do **not** default to `StaticWebAssetsEnabled` = false: that also drops Razor Class Library (`_content/…`) and framework (`_framework/…`) assets and can break the app. See `references/static-web-assets-guardrail.md`. +- **Keep `wwwroot` as the authoring root and `/cdnroot` as the container content root.** Use the base image's established port, runtime user, and working directory. +- **Exclude app-owned `wwwroot` from publish with targeted metadata.** Use `` and preserve Razor Class Library (`_content/…`) and framework (`_framework/…`) asset flow. See `references/static-web-assets-guardrail.md`. - **Never claim a declaration works because it looks right — prove it.** Application-owned files from source `wwwroot` must be absent from the publish artifact. Confirm with `verify --run-publish` against an isolated temp output; never write verification output into the repository. - **App is not CDN.** App assets are app-owned and authored in `wwwroot`; CDN assets are shared across applications and must never be duplicated into an application's `wwwroot`. Always ask whether a CDN/shared-asset equivalent exists (`FORMS.md`). - **Keep local URLs scheme-safe.** The local origin speaks HTTP on a host port. Point App asset URLs at `http://localhost:` from an HTTP application profile. Never emit a protocol-relative (`//localhost:`) or `https://localhost:` URL that an HTTPS page would turn into an HTTPS request against an HTTP-only origin. -- **Motivation is architectural, not a connection trick.** The value is segregation of duties, independent deployment and scaling, explicit cache behavior, origin/CDN offloading, and a reduced application artifact — **not** HTTP/1.x domain sharding or extra browser connection parallelism (which is counter-productive on HTTP/2 and HTTP/3). +- **Motivation is architectural.** The value is segregation of duties, independent deployment and scaling, explicit cache behavior, origin/CDN offloading, and a reduced application artifact. ## Step 1: Inspect before changing anything @@ -44,7 +44,7 @@ The runner returns candidate web projects, the resolved target, a `classificatio | Classification | Meaning | What you do | |---|---|---| | `Simple` | Physical `wwwroot`, no risky generated assets | Apply App-asset segregation (Steps 3–6). | -| `RiskyGeneratedAssets` | Blazor/RCL/scoped-CSS/frontend-build/etc. detected | **Stop and escalate** (Step 2 guardrail). Do not blanket-exclude. | +| `RiskyGeneratedAssets` | Blazor/RCL/scoped-CSS/frontend-build/etc. detected | **Stop and escalate** (Step 2 guardrail). Preserve the generated asset pipeline. | | `AlreadySegregated` | Publish exclusion + segregated profile present | Reconcile idempotently — do not duplicate. | | `Ambiguous` | Multiple web projects | Ask which project; pass `--project`. | | `NoWwwroot` | Web app without `wwwroot` | Only configure CDN consumption if a CDN equivalent exists. | @@ -54,7 +54,7 @@ The runner returns candidate web projects, the resolved target, a `classificatio Read `FORMS.md` and infer what you can. The one question you must always resolve is whether a **CDN/shared-asset equivalent exists** — because it changes whether you provision a second origin and how shared assets are referenced. Never assume shared assets belong in the application's `wwwroot`. -If `inspect` reports `RiskyGeneratedAssets`, treat it as a **compatibility guardrail**. A blanket `wwwroot` publish exclusion or a global Static Web Assets disable can break Blazor Web Apps, Blazor WebAssembly, `_framework`/`_content` assets, Razor Class Libraries, scoped CSS, component JS modules, or frontend-generated output. If you cannot establish a safe, deterministic way to materialize the required generated output into the external asset artifact while preserving correct runtime references, **stop and report that the project needs an explicit generated-static-assets segregation design.** That is a successful safety outcome, not a failure. Details: `references/static-web-assets-guardrail.md`. +If `inspect` reports `RiskyGeneratedAssets`, treat it as a **compatibility guardrail**. Preserve Blazor Web App, Blazor WebAssembly, `_framework`/`_content`, Razor Class Library, scoped CSS, component JS, and frontend-generated output through an explicit asset-artifact design. If you cannot establish a safe, deterministic way to materialize the required generated output while preserving correct runtime references, **stop and report that the project needs an explicit generated-static-assets segregation design.** That is a successful safety outcome, not a failure. Details: `references/static-web-assets-guardrail.md`. ## Step 3: Segregate App assets @@ -62,7 +62,7 @@ For a `Simple` project, apply these idempotently (skip any the runner already re 1. **Exclude app-owned `wwwroot` from web publish** — add the targeted `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` item to the web project. (`references/production-image.md`) 2. **Add a segregated launch profile** — a new `http-segregated-assets` profile that keeps the app in Development but points App asset URLs at the local origin over HTTP. Preserve the ordinary Development profile untouched. (`references/local-development.md`) -3. **Provide a local Static Content Provider** — run `codebeltnet/web-cdn-origin:2.0.0` mounting the app's existing `wwwroot` into `/cdnroot` **read-only**, on a host port, with a hardened posture (non-root, read-only root filesystem where practical, no privileged mode, no Docker socket, no extra capabilities, only the required port). Prefer a tiny dedicated Compose file unless the repo already has an orchestration mechanism to extend. (`references/local-development.md`) +3. **Provide a local Static Content Provider** — run `codebeltnet/web-cdn-origin:2.0.0` mounting the app's existing `wwwroot` into `/cdnroot` **read-only**, on a host port, with a hardened posture (non-root, read-only root filesystem where practical, no privileged mode, no Docker socket, no extra capabilities, only the required port). Prefer a tiny dedicated Compose file unless the repo already has an orchestration mechanism to extend. Name a dedicated file `compose.assets.yml` to pair the local topology with `Assets.Dockerfile`, and invoke it explicitly with `docker compose -f compose.assets.yml ...`. (`references/local-development.md`) ## Step 4: Configure App URL generation @@ -88,7 +88,7 @@ Point CDN asset URLs at that origin locally and at the shared/CDN host in deploy Add a derived image that ships the **actual final asset output** (run the frontend build first if the app generates its `wwwroot`): -Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill, use `Assets.Dockerfile`. This follows Docker's documented convention for distinct Dockerfiles; select the non-default file explicitly with `--file` (or the equivalent Compose `dockerfile` property). Do not use `Dockerfile.assets` or the lowercase `assets.Dockerfile` form. +Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill, use `Assets.Dockerfile`. Select it explicitly with `--file` or the equivalent Compose `dockerfile` property. ```dockerfile FROM codebeltnet/web-cdn-origin:2.0.0 @@ -114,14 +114,14 @@ Update the application's documentation to state that deployed static content is Running the skill again on a configured app must not create duplicate MSBuild items, launch profiles, Compose services, Dockerfiles, or documentation sections, must not increment ports unnecessarily, and must not overwrite customized URLs or introduce a competing asset-configuration system. Use `inspect` to detect existing segregation and reconcile it. -## What this skill must never do - -- Replace `wwwroot` with an `approot`/`cdnroot` source folder, or reintroduce the 1.4 `ADD approot` pattern. -- Default to `StaticWebAssetsEnabled` = false, or blindly delete `MapStaticAssets`/`UseStaticFiles` without analysis. -- Duplicate CDN/shared assets into the application's `wwwroot`, or conflate App and CDN assets. -- Add a Cuemon dependency to a project that does not already use it. -- Claim a `commandName: Project` launch profile starts sidecar containers, or claim the separation improves performance through domain sharding. -- Write verification output into the repository, or force a partially broken migration when a generated-static-assets design is required. +## Boundaries + +- Keep `wwwroot` as the authoring root and `/cdnroot` as the container content root. +- Preserve the generated Static Web Assets pipeline and inspect `MapStaticAssets`/`UseStaticFiles` before changing serving behavior. +- Keep App assets and shared CDN assets separate; never duplicate shared assets into the application's `wwwroot`. +- Reuse the application's existing asset configuration and Cuemon integration; add no dependency solely for this migration. +- Keep sidecar startup explicit, and keep verification output in isolated temporary storage. +- Escalate generated-static-assets projects when a complete, deterministic external-artifact design cannot be proven. ## References diff --git a/skills/dotnet-segregated-assets/evals/evals.json b/skills/dotnet-segregated-assets/evals/evals.json index de731eb..1b1c12a 100644 --- a/skills/dotnet-segregated-assets/evals/evals.json +++ b/skills/dotnet-segregated-assets/evals/evals.json @@ -4,19 +4,19 @@ { "id": 1, "prompt": "This is a conventional ASP.NET Core MVC app (Contoso.Web) with a normal wwwroot folder. We don't have any shared CDN — it's just this app's own CSS, JS, and favicon. Set it up so the deployed app doesn't serve or ship wwwroot itself; the static files should come from Codebelt Static Content Provider instead, but I still want to keep editing files in wwwroot like normal.", - "expected_output": "The skill runs segregate-assets.cs inspect, classifies the project as Simple, confirms no CDN equivalent, and applies App-asset segregation: a targeted item, an http-segregated-assets launch profile pointing App URLs at http://localhost:8080, a local codebeltnet/web-cdn-origin:2.0.0 service mounting wwwroot into /cdnroot read-only, a derived FROM codebeltnet/web-cdn-origin:2.0.0 production image named Assets.Dockerfile (the PascalCase .Dockerfile convention and explicitly selected with Docker --file), and documentation. wwwroot stays as the authoring root.", + "expected_output": "The skill runs segregate-assets.cs inspect, classifies the project as Simple, confirms no CDN equivalent, and applies App-asset segregation: a targeted item, an http-segregated-assets launch profile pointing App URLs at http://localhost:8080, a local codebeltnet/web-cdn-origin:2.0.0 service in the canonical compose.assets.yml file mounting wwwroot into /cdnroot read-only, a derived FROM codebeltnet/web-cdn-origin:2.0.0 production image named Assets.Dockerfile (the PascalCase .Dockerfile convention and explicitly selected with Docker --file), and documentation. wwwroot stays as the authoring root.", "expectations": [ "Runs the bundled runner (segregate-assets.cs) to inspect topology instead of guessing", - "Keeps wwwroot as the authoring root and does NOT introduce an approot/cdnroot/staticroot source folder", + "Keeps wwwroot as the authoring root and uses /cdnroot as the container content root", "Adds a targeted item", - "Adds a new http-segregated-assets HTTP launch profile without altering the existing Development profile", - "Provisions a local codebeltnet/web-cdn-origin:2.0.0 origin mounting wwwroot into /cdnroot read-only", - "Adds a derived production image: FROM codebeltnet/web-cdn-origin:2.0.0 + COPY --chown=65532:65532 ./wwwroot/ /cdnroot/", + "Adds a new http-segregated-assets HTTP launch profile without altering the existing Development profile", + "Provisions a local codebeltnet/web-cdn-origin:2.0.0 origin mounting wwwroot into /cdnroot read-only", + "Names the dedicated local Compose file compose.assets.yml to pair with Assets.Dockerfile", + "Adds a derived production image: FROM codebeltnet/web-cdn-origin:2.0.0 + COPY --chown=65532:65532 ./wwwroot/ /cdnroot/", "Names the derived Dockerfile Assets.Dockerfile using the PascalCase .Dockerfile convention and selects it explicitly with --file", - "NEGATIVE: does not use Dockerfile.assets or lowercase assets.Dockerfile", - "NEGATIVE: does not set false", - "NEGATIVE: does not resurrect the 1.4 ADD approot / WORKDIR /cdnroot Dockerfile pattern", - "NEGATIVE: does not blindly delete MapStaticAssets" + "Preserves Razor Class Library (_content) and framework (_framework) asset flow", + "Uses the base image's established /cdnroot runtime contract", + "Preserves existing static-asset endpoints after inspecting their role" ], "files": [ "evals/files/conventional-mvc/Contoso.Web.csproj", @@ -144,15 +144,14 @@ { "id": 8, "prompt": "Segregate the static assets for Northwind.Web. Just exclude wwwroot from publish and point everything at the asset host.", - "expected_output": "The skill runs inspect, detects that Northwind.Web references a Razor Class Library (Northwind.DesignSystem) that contributes _content/ static web assets, classifies the scenario as RiskyGeneratedAssets, and refuses to apply a blanket wwwroot exclusion or disable StaticWebAssetsEnabled. It explains that generated/RCL Static Web Assets would break, and either designs an explicit safe segregation or stops and reports that an explicit generated-static-assets segregation design is required.", + "expected_output": "The skill runs inspect, detects that Northwind.Web references a Razor Class Library (Northwind.DesignSystem) that contributes _content/ static web assets, classifies the scenario as RiskyGeneratedAssets, preserves the generated/RCL Static Web Assets pipeline, and either designs an explicit safe segregation or stops and reports that an explicit generated-static-assets segregation design is required.", "expectations": [ "Runs inspect and detects the Razor Class Library / generated Static Web Assets scenario (_content assets)", "Classifies as risky and treats it as a compatibility guardrail", - "Explains that a blanket exclusion or global disable would break RCL/framework (_content/_framework) assets", + "Preserves the RCL/framework (_content/_framework) asset flow", "Escalates safely: designs an explicit safe approach or stops and reports that a generated-static-assets segregation design is required (a successful safety outcome)", - "NEGATIVE: does not apply a blanket as if it were a simple physical wwwroot", - "NEGATIVE: does not set false", - "NEGATIVE: does not blindly delete MapStaticAssets or exclude _content/_framework assets" + "Keeps generated/RCL assets distinct from application-owned physical wwwroot content", + "Inspects and preserves existing MapStaticAssets and _content/_framework serving behavior" ], "files": [ "evals/files/blazor-rcl/src/Northwind.Web/Northwind.Web.csproj", @@ -165,12 +164,13 @@ { "id": 9, "prompt": "Fabrikam.Web has already been set up for segregated assets. Prove that the app-owned wwwroot files really are absent from the published web application.", - "expected_output": "The skill runs segregate-assets.cs verify with --run-publish, which publishes Fabrikam.Web to an isolated temporary directory and confirms the application-owned wwwroot files (css/site.css, js/site.js) are ABSENT from the publish artifact, reporting PASS. It retains the derived asset image as Assets.Dockerfile under the PascalCase .Dockerfile convention and does not write verification output into the repository.", + "expected_output": "The skill runs segregate-assets.cs verify with --run-publish, which publishes Fabrikam.Web to an isolated temporary directory and confirms the application-owned wwwroot files (css/site.css, js/site.js) are ABSENT from the publish artifact, reporting PASS. It retains the canonical compose.assets.yml local topology and derived asset image as Assets.Dockerfile under the PascalCase .Dockerfile convention and does not write verification output into the repository.", "expectations": [ - "Runs the runner's verify command with --run-publish (isolated temp output) rather than eyeballing the csproj", - "Confirms application-owned wwwroot files are absent from the publish artifact and reports the invariant as satisfied", - "Allows shared _content/_framework assets to remain (they are not app-owned)", - "Retains the derived asset image as Assets.Dockerfile, not Dockerfile.assets or lowercase assets.Dockerfile", + "Runs the runner's verify command with --run-publish (isolated temp output) rather than eyeballing the csproj", + "Confirms application-owned wwwroot files are absent from the publish artifact and reports the invariant as satisfied", + "Allows shared _content/_framework assets to remain (they are not app-owned)", + "Retains the canonical compose.assets.yml local Compose file", + "Retains the derived asset image as Assets.Dockerfile", "NEGATIVE: does not write publish/verification output into the repository", "NEGATIVE: does not claim success from the declaration alone without publishing" ], @@ -178,7 +178,7 @@ "evals/files/segregated-app/Fabrikam.Web.csproj", "evals/files/segregated-app/Program.cs", "evals/files/segregated-app/Properties/launchSettings.json", - "evals/files/segregated-app/compose.segregated-assets.yml", + "evals/files/segregated-app/compose.assets.yml", "evals/files/segregated-app/Assets.Dockerfile", "evals/files/segregated-app/wwwroot/css/site.css", "evals/files/segregated-app/wwwroot/js/site.js" @@ -187,18 +187,19 @@ { "id": 10, "prompt": "Run the segregated-assets setup on Fabrikam.Web again — I think someone already did it but I want to be sure it's configured.", - "expected_output": "The skill runs inspect, detects existing segregation (publish exclusion + http-segregated-assets profile + compose service + the existing Assets.Dockerfile), classifies it as AlreadySegregated, and reconciles idempotently — it does not create duplicate MSBuild items, launch profiles, compose services, or Dockerfiles, does not increment ports, and does not overwrite the existing configuration.", + "expected_output": "The skill runs inspect, detects existing segregation (publish exclusion + http-segregated-assets profile + canonical compose.assets.yml service + the existing Assets.Dockerfile), classifies it as AlreadySegregated, and reconciles idempotently — it does not create duplicate MSBuild items, launch profiles, compose services, or Dockerfiles, does not increment ports, and does not overwrite the existing configuration.", "expectations": [ - "Runs inspect and detects the existing segregation (AlreadySegregated)", - "Reconciles idempotently without creating duplicate csproj items, profiles, compose services, or Dockerfiles", - "Does not increment ports unnecessarily or overwrite customized URLs", - "Preserves the existing PascalCase Assets.Dockerfile name and does not create Dockerfile.assets or lowercase assets.Dockerfile", + "Runs inspect and detects the existing segregation (AlreadySegregated)", + "Reconciles idempotently without creating duplicate csproj items, profiles, compose services, or Dockerfiles", + "Does not increment ports unnecessarily or overwrite customized URLs", + "Preserves the existing canonical compose.assets.yml name", + "Preserves the existing PascalCase Assets.Dockerfile name", "NEGATIVE: does not add a second competing asset configuration or duplicate the existing setup" ], "files": [ "evals/files/segregated-app/Fabrikam.Web.csproj", "evals/files/segregated-app/Properties/launchSettings.json", - "evals/files/segregated-app/compose.segregated-assets.yml", + "evals/files/segregated-app/compose.assets.yml", "evals/files/segregated-app/Assets.Dockerfile" ] }, diff --git a/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml b/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.assets.yml similarity index 99% rename from skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml rename to skills/dotnet-segregated-assets/evals/files/segregated-app/compose.assets.yml index 6687d94..f8eb079 100644 --- a/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.segregated-assets.yml +++ b/skills/dotnet-segregated-assets/evals/files/segregated-app/compose.assets.yml @@ -9,4 +9,4 @@ services: ports: - "8080:8080" volumes: - - ./wwwroot:/cdnroot:ro + - ./wwwroot:/cdnroot:ro diff --git a/skills/dotnet-segregated-assets/references/app-vs-cdn.md b/skills/dotnet-segregated-assets/references/app-vs-cdn.md index 6a9072e..019eb35 100644 --- a/skills/dotnet-segregated-assets/references/app-vs-cdn.md +++ b/skills/dotnet-segregated-assets/references/app-vs-cdn.md @@ -45,4 +45,4 @@ Configure the application's own asset base-URL abstraction instead — an option The motivation is architectural, not a browser-connection trick. Segregating static delivery gives you segregation of duties (static delivery is isolated from application and business logic and its failure modes), independent deployment and scaling for assets, explicit and correct cache behavior on a dedicated surface, origin/CDN offloading so the application stays small and cheap, reusable shared assets, and a reduced application artifact surface. -Do **not** justify the design as HTTP/1.x domain sharding or claim that additional domains improve modern browser performance through extra connection parallelism. On HTTP/2 and HTTP/3 that technique is usually counter-productive because it prevents connection coalescing. The benefits above are about architecture, operability, and edge caching — not connection count. +The design serves architecture, operability, and edge caching through independent deployment, explicit cache policy, and origin offloading. diff --git a/skills/dotnet-segregated-assets/references/local-development.md b/skills/dotnet-segregated-assets/references/local-development.md index f2aacaa..ffca266 100644 --- a/skills/dotnet-segregated-assets/references/local-development.md +++ b/skills/dotnet-segregated-assets/references/local-development.md @@ -36,11 +36,11 @@ A `commandName: Project` profile only launches the application and sets configur Use the published image directly for local development — do not rebuild an asset image on every source edit. Mount the application's existing `wwwroot` into `/cdnroot` **read-only** so edits are visible immediately (the image serves physical files from `/cdnroot`, and its `CdnOrigin:ContentRoot` already defaults to `/cdnroot`). -Prefer a tiny dedicated Compose file when repository conventions permit, because relative bind mounts give a cross-platform, repeatable developer command. If the repository already has an established orchestration mechanism that can express the same topology cleanly, extend that instead of adding Compose. +Prefer a tiny dedicated Compose file when repository conventions permit, because relative bind mounts give a cross-platform, repeatable developer command. Name that dedicated file `compose.assets.yml` so it pairs with the derived `Assets.Dockerfile`. If the repository already has an established orchestration mechanism that can express the same topology cleanly, extend that instead of adding Compose. Preserve the security posture the image supports wherever Docker permits: non-root runtime (the image already runs as user `65532`), a read-only content mount, a read-only root filesystem where practical, no privileged mode, no Docker socket mount, no unnecessary capabilities, and only the required host port exposed. -Example `compose.segregated-assets.yml` (App only): +Example `compose.assets.yml` (App only): ```yaml services: @@ -57,7 +57,7 @@ services: - ./src/Web/wwwroot:/cdnroot:ro ``` -Run it with `docker compose -f compose.segregated-assets.yml up`, then launch the application with the `http-segregated-assets` profile. Adapt the relative `./src/Web/wwwroot` path to the actual web project location. +Run it with `docker compose -f compose.assets.yml up`, then launch the application with the `http-segregated-assets` profile. Adapt the relative `./src/Web/wwwroot` path to the actual web project location. ## Second origin for CDN assets diff --git a/skills/dotnet-segregated-assets/references/production-image.md b/skills/dotnet-segregated-assets/references/production-image.md index e796dcc..8252964 100644 --- a/skills/dotnet-segregated-assets/references/production-image.md +++ b/skills/dotnet-segregated-assets/references/production-image.md @@ -4,7 +4,7 @@ Two things happen at deployment: the application-owned static assets are shipped ## Derived asset image -Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill the canonical name is `Assets.Dockerfile`. Docker documents this convention for distinct Dockerfiles and the `--file` option for selecting a non-default filename; use `docker build --file Assets.Dockerfile ...` or the equivalent Compose `dockerfile: Assets.Dockerfile` setting. Do not use `Dockerfile.assets` or the lowercase `assets.Dockerfile` form. See the [Dockerfile overview](https://docs.docker.com/build/concepts/dockerfile/). +Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill the canonical name is `Assets.Dockerfile`. Select it with `docker build --file Assets.Dockerfile ...` or the equivalent Compose `dockerfile: Assets.Dockerfile` setting. When a dedicated local Compose file is used, name it `compose.assets.yml`. See the [Dockerfile overview](https://docs.docker.com/build/concepts/dockerfile/). When application-owned assets ship as a container image, use `web-cdn-origin:2.0.0` as the base. The normal derived image is conceptually no more complicated than copying the final `wwwroot` output into the image's content root: @@ -16,7 +16,7 @@ COPY --chown=65532:65532 ./wwwroot/ /cdnroot/ Version 2.0 owns its `/cdnroot`, its port (`8080`), its runtime user (`65532`), and its application working directory. Do not override them without a demonstrated requirement, and prefer `COPY` over `ADD` when no `ADD` behavior is needed. Adapt only the source path and ownership when actual build conventions require it. -Do **not** reintroduce the old 1.4 pattern (setting `ASPNETCORE_HTTP_PORTS`, `WORKDIR /cdnroot`, and `ADD approot .`). Version 2.0 already establishes those conventions, and re-declaring them fights the base image. +Use the base image's established port, `/cdnroot`, runtime user, and working directory so the derived asset image remains compatible with the published origin contract. If the application's frontend build generates the final files rather than storing them directly in source `wwwroot`, identify and preserve that generation pipeline and build it before the image is assembled: the image must contain the **actual final asset output**, not stale source inputs. @@ -34,17 +34,17 @@ The deployed web application must not carry a duplicate copy of its application- ``` -Do **not** default to `false`. That global switch disables the entire Static Web Assets system, which also drops assets supplied by Razor Class Libraries (`_content/…`) and framework assets (`_framework/…`) and can break application or framework functionality. The targeted `Content Update` item only affects the application's own `wwwroot`; Razor Class Library and framework static web assets keep flowing to publish through their own items. +Keep the Static Web Assets pipeline active for Razor Class Library (`_content/…`) and framework (`_framework/…`) content. The targeted `Content Update` item affects only the application's own `wwwroot`; contributed and framework static web assets continue through their own publish items. Do not assume the declaration is sufficient merely because it looks correct — the interaction between the `Content` items and the Static Web Assets publish pipeline must be confirmed empirically. -### Verified behavior - -Against a conventional `Microsoft.NET.Sdk.Web` application on .NET 10 that calls `MapStaticAssets`: - -- **Baseline (no exclusion):** `dotnet publish` produces `publish/wwwroot/…` containing every `wwwroot` file plus pre-compressed `.br`/`.gz` variants and a `*.staticwebassets.endpoints.json` manifest — the duplicate copy to eliminate. -- **With `Content Update="wwwroot/**" CopyToPublishDirectory="Never"`:** `publish/wwwroot` is absent entirely; the endpoints manifest remains but is empty (`{"Version":1,"ManifestType":"Publish","Endpoints":[]}`). App-owned assets are gone, and the Static Web Assets system stays enabled. -- **Same exclusion with a referenced Razor Class Library:** the app's own `wwwroot/*` is absent, while the RCL's `wwwroot/_content//…` (and its `.br`/`.gz`) **survives** in publish. This is exactly the outcome the global disable would wrongly destroy. +### Verified behavior + +Against a conventional `Microsoft.NET.Sdk.Web` application on .NET 10 that calls `MapStaticAssets`: + +- `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` removes application-owned files from the publish artifact while leaving the Static Web Assets manifest available. +- A referenced Razor Class Library's `wwwroot/_content//…` assets and their compressed variants remain in publish. +- Framework assets remain available through the framework Static Web Assets pipeline. ## Verifying the publish invariant diff --git a/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md b/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md index 717f394..f6dfac0 100644 --- a/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md +++ b/skills/dotnet-segregated-assets/references/static-web-assets-guardrail.md @@ -1,40 +1,40 @@ -# Static Web Assets compatibility guardrail - -The safe migration is a *physical* `wwwroot` served by the application: plain files the developer authored. The dangerous migration is one that treats **generated** or **contributed** Static Web Assets as if they were plain physical files and either excludes them from publish or disables the Static Web Assets system. That can break Blazor runtime loading, Razor Class Library assets, scoped CSS, component JavaScript modules, and frontend-generated output. This guardrail keeps the skill from producing a partially broken migration. - -## Detect before deciding - -`segregate-assets.cs inspect` reports risk signals. Treat any of these as `RiskyGeneratedAssets` and do **not** apply a blanket `wwwroot` publish exclusion or disable Static Web Assets: - -- `BLAZOR_WEBASSEMBLY` — `Microsoft.NET.Sdk.BlazorWebAssembly` or a `Microsoft.AspNetCore.Components.WebAssembly` reference. The published app depends on `_framework/` runtime assets. -- `BLAZOR_WEB_APP` — Razor components (`*.razor`) with `AddRazorComponents`/`MapRazorComponents` or a `Components.Web` reference. Depends on `_framework/blazor.web.js` and generated component assets. -- `RAZOR_CLASS_LIBRARY_ASSETS` — a referenced `Microsoft.NET.Sdk.Razor` project that contributes a `wwwroot`, published under `_content//…`. -- `SCOPED_CSS` — `*.razor.css` / `*.cshtml.css` files, which the build bundles into a generated `.styles.css` static web asset. -- `RAZOR_COMPONENT_JS` — collocated `*.razor.js` JavaScript modules emitted as static web assets. -- `FRAMEWORK_ASSETS_REFERENCE` / `CONTENT_ASSETS_REFERENCE` — markup that references `_framework/` or `_content/` paths. -- `STATIC_WEB_ASSETS_DISABLED` — `StaticWebAssetsEnabled` is already globally disabled; this may already be breaking RCL/framework assets. -- `FRONTEND_BUILD_PIPELINE` — a `package.json` build (webpack/vite/rollup/esbuild/etc.) that generates the final `wwwroot`; the image must ship generated output, not source inputs. - -You can also inspect manually for the same signals: `_framework`, `_content`, generated static web asset manifests, scoped CSS, component JS modules, and build-time frontend generation. - -## Why the blanket approaches are wrong here - -`false` is a global kill switch: it disables asset discovery, the manifest, and `MapStaticAssets`, so Razor Class Library (`_content/…`) and framework (`_framework/…`) assets stop being published too. A blanket `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` is safe for a *physical* app `wwwroot`, but it does not, on its own, relocate generated framework/component assets to an external origin — those assets still need to be served for the app to run. - -The empirical evidence in `references/production-image.md` shows the difference: the targeted exclusion removes the app's own `wwwroot/*` while a referenced RCL's `wwwroot/_content//…` survives in publish. That survival is correct and required — and it is exactly what a global disable would destroy. - -## Safe outcomes - -For a `Simple` classification (physical `wwwroot`, no risk signals), apply the standard App-asset segregation. Prefer limiting application-owned static serving to local Development when that can be done without changing unrelated behavior; the production invariant is that application-owned assets come from the external App host and are not duplicated into the web publish artifact. - -For `RiskyGeneratedAssets`, decide whether a safe, deterministic segregation exists for that specific project: - -- If the required generated output can be materialized into the external static-content artifact **and** the application's runtime references still resolve correctly (for example, the generated files are produced by the build and then packaged into the derived `web-cdn-origin` image with matching URLs), design that explicitly and verify it — do not improvise it as a side effect of a publish-exclusion glob. -- If that cannot be established safely and deterministically, **stop and report** that the project requires an explicit generated-static-assets segregation design rather than producing a partially broken migration. This is a successful safety outcome, not a skill failure. State which signals were found and what a correct design would need to preserve (`_framework`/`_content` resolution, scoped-CSS bundle, component JS modules, or the frontend build output). - -## Never do this - -- Never disable `StaticWebAssetsEnabled` globally to make a wwwroot exclusion "work". -- Never blindly delete `MapStaticAssets` or `UseStaticFiles`; understand what they serve first. -- Never exclude `_content`/`_framework` assets or treat them as app-owned files. -- Never force a migration through when the runner reports a risky scenario you cannot segregate safely — escalate instead. +# Static Web Assets compatibility guardrail + +A supported simple migration has a physical `wwwroot` served by the application: plain files the developer authored. Projects that produce or contribute generated Static Web Assets require an explicit asset-artifact design. Their framework assets, Razor Class Library content, scoped CSS, component JavaScript modules, and frontend-generated output must remain available to the application. + +## Detect before deciding + +`segregate-assets.cs inspect` reports risk signals. Treat any of these as `RiskyGeneratedAssets` and preserve the complete generated-asset pipeline: + +- `BLAZOR_WEBASSEMBLY` — `Microsoft.NET.Sdk.BlazorWebAssembly` or a `Microsoft.AspNetCore.Components.WebAssembly` reference. The published app depends on `_framework/` runtime assets. +- `BLAZOR_WEB_APP` — Razor components (`*.razor`) with `AddRazorComponents`/`MapRazorComponents` or a `Components.Web` reference. The app depends on `_framework/blazor.web.js` and generated component assets. +- `RAZOR_CLASS_LIBRARY_ASSETS` — a referenced `Microsoft.NET.Sdk.Razor` project that contributes a `wwwroot`, published under `_content//…`. +- `SCOPED_CSS` — `*.razor.css` / `*.cshtml.css` files, which the build bundles into a generated `.styles.css` static web asset. +- `RAZOR_COMPONENT_JS` — collocated `*.razor.js` JavaScript modules emitted as static web assets. +- `FRAMEWORK_ASSETS_REFERENCE` / `CONTENT_ASSETS_REFERENCE` — markup that references `_framework/` or `_content/` paths. +- `STATIC_WEB_ASSETS_CONFIGURATION` — project-level Static Web Assets configuration requires review before segregation. +- `FRONTEND_BUILD_PIPELINE` — a `package.json` build (webpack/vite/rollup/esbuild/etc.) that generates the final `wwwroot`; the image must ship generated output, not source inputs. + +You can also inspect manually for the same signals: `_framework`, `_content`, generated static web asset manifests, scoped CSS, component JS modules, and build-time frontend generation. + +## Required handling + +The targeted `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` item applies only to application-owned physical `wwwroot` content. Razor Class Library assets, framework assets, generated component assets, and frontend output continue through their respective publish and serving pipelines. + +The empirical evidence in `references/production-image.md` confirms the expected result: application-owned `wwwroot/*` is absent from the web publish artifact while a referenced RCL's `wwwroot/_content//…` remains available. That separation is required for the application to run correctly. + +## Safe outcomes + +For a `Simple` classification (physical `wwwroot`, no risk signals), apply the standard App-asset segregation. Prefer limiting application-owned static serving to local Development when that can be done without changing unrelated behavior; the production invariant is that application-owned assets come from the external App host and are not duplicated into the web publish artifact. + +For `RiskyGeneratedAssets`, decide whether a safe, deterministic segregation exists for that specific project: + +- If the required generated output can be materialized into the external static-content artifact **and** the application's runtime references still resolve correctly (for example, the generated files are produced by the build and then packaged into the derived `web-cdn-origin` image with matching URLs), design that explicitly and verify it. +- If that cannot be established safely and deterministically, **stop and report** that the project requires an explicit generated-static-assets segregation design rather than producing a partially broken migration. State which signals were found and what a correct design would need to preserve (`_framework`/`_content` resolution, scoped-CSS bundle, component JS modules, or the frontend build output). + +## Boundaries + +- Keep the complete Static Web Assets pipeline available to framework and contributed content. +- Inspect `MapStaticAssets`, `UseStaticFiles`, custom providers, and endpoints before changing serving behavior. +- Keep `_content` and `_framework` assets distinct from application-owned `wwwroot` content. +- Escalate projects whose generated output cannot be materialized and verified as a complete external asset artifact. From a73d7426357d59af04e105af376659398dbb122a Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 19:20:34 +0200 Subject: [PATCH 12/41] =?UTF-8?q?=F0=9F=94=A8=20update=20segregation=20aut?= =?UTF-8?q?omation=20and=20validation=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align implementation, testing, and validation tooling with refined skill guidance. These updates maintain deterministic behavior while improving clarity and error handling in the segregation workflow. --- .../scripts/segregate-assets.cs | 23 +++++----- .../scripts/test-segregated-assets.ps1 | 7 +-- .../scripts/validate-skill.ps1 | 46 ++++++++++--------- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs index dee935f..b5123f2 100644 --- a/skills/dotnet-segregated-assets/scripts/segregate-assets.cs +++ b/skills/dotnet-segregated-assets/scripts/segregate-assets.cs @@ -9,7 +9,7 @@ // is the *orchestration layer*: it understands intent, resolves repository conventions, makes the // repository-appropriate edits, and resolves the App-vs-CDN semantic choices. Everything that must be // deterministic — discovering candidate web projects, classifying the static-asset topology, detecting -// risky Static Web Assets scenarios that must NOT be blindly excluded, checking idempotency, validating +// risky Static Web Assets scenarios that require explicit generated-asset handling, checking idempotency, validating // the local Static Content Provider topology, and proving that application-owned wwwroot files are absent // from the deployed web-application publish artifact — lives here so it is repeatable instead of // re-improvised on every call. @@ -29,10 +29,11 @@ internal static class SegregateAssetsProgram { - internal const string ToolName = "dotnet-segregated-assets"; - internal const string OriginImage = "codebeltnet/web-cdn-origin:2.0.0"; + internal const string ToolName = "dotnet-segregated-assets"; + internal const string OriginImage = "codebeltnet/web-cdn-origin:2.0.0"; internal const string DerivedDockerfileName = "Assets.Dockerfile"; - internal const int OriginContainerPort = 8080; + internal const string ComposeFileName = "compose.assets.yml"; + internal const int OriginContainerPort = 8080; internal const string OriginContentRoot = "/cdnroot"; internal const string OriginUser = "65532"; internal const string SegregatedProfileName = "http-segregated-assets"; @@ -387,8 +388,8 @@ void Add(string code, string detail) Add("RAZOR_CLASS_LIBRARY_ASSETS", $"Referenced project '{ProjectScanner.Rel(repoRoot, refProj)}' contributes wwwroot static web assets (published under _content/)."); } - if (Regex.IsMatch(csproj, "\\s*false\\s*", RegexOptions.IgnoreCase)) - Add("STATIC_WEB_ASSETS_DISABLED", "StaticWebAssetsEnabled is already globally disabled — this can break RCL/framework assets."); + if (Regex.IsMatch(csproj, "\\s*false\\s*", RegexOptions.IgnoreCase)) + Add("STATIC_WEB_ASSETS_CONFIGURATION", "Static Web Assets configuration requires explicit generated-asset review before segregation."); if (HasFrontendBuildPipeline(dir)) Add("FRONTEND_BUILD_PIPELINE", "A frontend build (package.json + bundler) generates final wwwroot output; the image must ship generated output, not source inputs."); @@ -544,7 +545,7 @@ public static string Classify( Ambiguous => "Multiple web projects found. Ask which web project to segregate (pass --project).", NoWwwroot => "No wwwroot found. Configure only the CDN/shared-asset consumption if a CDN equivalent exists; otherwise nothing to do.", AlreadySegregated => "Segregation is already present. Reconcile existing configuration; do not create duplicate items, profiles, services, or Dockerfiles.", - RiskyGeneratedAssets => "Generated/Static Web Assets detected. Do NOT apply a blanket wwwroot publish exclusion or disable StaticWebAssetsEnabled. Escalate: an explicit generated-static-assets segregation design is required.", + RiskyGeneratedAssets => "Generated/Static Web Assets detected. Preserve the generated-asset pipeline and establish an explicit generated-static-assets segregation design before proceeding.", Simple => existing.Any ? "Simple physical wwwroot with partial existing segregation. Complete the missing pieces idempotently." : "Simple physical wwwroot. Apply App-asset segregation: targeted publish exclusion, segregated launch profile, local origin, derived production image, and documentation.", @@ -864,7 +865,7 @@ private static List BuildPlan(InspectionResult inspection, Options optio { return new List { - new { step = "escalate", status = "blocked", detail = "Risky Static Web Assets detected. Do not apply a blanket wwwroot publish exclusion or disable StaticWebAssetsEnabled. Request an explicit generated-static-assets segregation design." }, + new { step = "escalate", status = "blocked", detail = "Risky Static Web Assets detected. Preserve the generated-asset pipeline and request an explicit generated-static-assets segregation design." }, }; } if (inspection.Classification is Classifier.NotAWebApp or Classifier.Ambiguous) @@ -889,9 +890,9 @@ private static List BuildPlan(InspectionResult inspection, Options optio var plan = new List { - new { step = "publish-exclusion", status = StatusFor(e.PublishExclusion), detail = "Add to the web project (targeted; do NOT disable StaticWebAssetsEnabled)." }, + new { step = "publish-exclusion", status = StatusFor(e.PublishExclusion), detail = "Add to the web project for application-owned wwwroot content while preserving generated and contributed Static Web Assets." }, new { step = "segregated-launch-profile", status = StatusFor(e.SegregatedLaunchProfile), detail = $"Add the '{SegregateAssetsProgram.SegregatedProfileName}' HTTP launch profile pointing App asset URLs at http://localhost:{options.AppPort}." }, - new { step = "local-origin", status = StatusFor(e.ComposeService), detail = $"Provide a local {SegregateAssetsProgram.OriginImage} service mounting wwwroot into /cdnroot read-only on host port {options.AppPort}." }, + new { step = "local-origin", status = StatusFor(e.ComposeService), detail = $"Provide {SegregateAssetsProgram.ComposeFileName} with a local {SegregateAssetsProgram.OriginImage} service mounting wwwroot into /cdnroot read-only on host port {options.AppPort}." }, new { step = "production-image", status = StatusFor(e.DerivedDockerfile), detail = $"Add {SegregateAssetsProgram.DerivedDockerfileName} (PascalCase .Dockerfile) and select it with --file: FROM {SegregateAssetsProgram.OriginImage} + COPY --chown={SegregateAssetsProgram.OriginUser}:{SegregateAssetsProgram.OriginUser} ./wwwroot/ {SegregateAssetsProgram.OriginContentRoot}/." }, new { step = "documentation", status = "create-or-update", detail = "Document that deployed static content is served by Codebelt Static Content Provider, and that wwwroot remains the authoring root." }, }; @@ -1139,7 +1140,7 @@ private static void TestIdempotencyDetection(string root) File.WriteAllText(Path.Combine(dir, "Properties", "launchSettings.json"), """ { "profiles": { "http-segregated-assets": { "commandName": "Project" } } } """); - File.WriteAllText(Path.Combine(dir, "compose.segregated-assets.yml"), + File.WriteAllText(Path.Combine(dir, SegregateAssetsProgram.ComposeFileName), "services:\n app-assets:\n image: codebeltnet/web-cdn-origin:2.0.0\n"); File.WriteAllText(Path.Combine(dir, SegregateAssetsProgram.DerivedDockerfileName), "FROM codebeltnet/web-cdn-origin:2.0.0\nCOPY --chown=65532:65532 ./wwwroot/ /cdnroot/\n"); diff --git a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 index ca88c96..cc15086 100644 --- a/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 +++ b/skills/dotnet-segregated-assets/scripts/test-segregated-assets.ps1 @@ -53,10 +53,11 @@ try { Write-Host 'plan: conventional app, no CDN equivalent' $plan = Invoke-Runner -RunnerArgs @('plan', '--repo-root', $workspace, '--json') - Assert-True 'plan exits 0' ($plan.ExitCode -eq 0) - Assert-True 'plan proposes publish-exclusion' ($plan.Output -match 'publish-exclusion') + Assert-True 'plan exits 0' ($plan.ExitCode -eq 0) + Assert-True 'plan proposes publish-exclusion' ($plan.Output -match 'publish-exclusion') Assert-True 'plan names the PascalCase derived Dockerfile' ($plan.Output -match 'Assets\.Dockerfile') - Assert-True 'plan skips CDN equivalent by default' ($plan.Output -match '"cdn-equivalent"[\s\S]*?"status":\s*"skip"') + Assert-True 'plan names the canonical Compose file' ($plan.Output -match 'compose\.assets\.yml') + Assert-True 'plan skips CDN equivalent by default' ($plan.Output -match '"cdn-equivalent"[\s\S]*?"status":\s*"skip"') # An explicitly selected web project without wwwroot can still consume a shared/CDN asset root. $apiDir = Join-Path $workspace 'Api' diff --git a/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 index 2594d5c..e1dd8be 100644 --- a/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 +++ b/skills/dotnet-segregated-assets/scripts/validate-skill.ps1 @@ -26,16 +26,15 @@ $contracts = @( 'wwwroot', 'authoring root', 'http-segregated-assets', - 'App assets', - 'CDN assets', - 'CopyToPublishDirectory="Never"', - 'StaticWebAssetsEnabled', - '65532', - 'orchestrat', - 'segregate-assets.cs', - 'domain sharding', - 'generated-static-assets', - 'approot' + 'App assets', + 'CDN assets', + 'CopyToPublishDirectory="Never"', + 'Static Web Assets', + '65532', + 'orchestrat', + 'segregate-assets.cs', + 'generated-static-assets', + 'Boundaries' ) foreach ($needle in $contracts) { if (-not $skill.Contains($needle, [System.StringComparison]::Ordinal)) { @@ -44,7 +43,7 @@ foreach ($needle in $contracts) { } $productionImage = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'references/production-image.md')) -$dockerfileContracts = @('.Dockerfile', 'PascalCase', 'Assets.Dockerfile', 'Dockerfile.assets', '--file') +$dockerfileContracts = @('.Dockerfile', 'PascalCase', 'Assets.Dockerfile', '--file') foreach ($source in @( [pscustomobject]@{ Name = 'SKILL.md'; Text = $skill }, [pscustomobject]@{ Name = 'references/production-image.md'; Text = $productionImage } @@ -56,17 +55,20 @@ foreach ($source in @( } } -# The skill must not *recommend* the removed 1.4 pattern or a blanket kill switch. It may name them only -# to warn against them, so require the warning framing (a "not"/"Do not"/"Never"/"removed" cue) to be near -# each anti-pattern rather than forbidding the phrase outright. -$antiPatterns = @('approot', 'StaticWebAssetsEnabled') -foreach ($needle in $antiPatterns) { - $idx = $skill.IndexOf($needle, [System.StringComparison]::Ordinal) - $window = $skill.Substring([Math]::Max(0, $idx - 160), [Math]::Min(320, $skill.Length - [Math]::Max(0, $idx - 160))) - if ($window -notmatch '(?i)\b(do not|don''t|never|not\b|removed|reintroduce|instead of|rather than)') { - throw "SKILL.md mentions '$needle' but not in a clear warning/negative context." - } -} +$localDevelopment = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'references/local-development.md')) +$composeSources = @( + [pscustomobject]@{ Name = 'SKILL.md'; Text = $skill }, + [pscustomobject]@{ Name = 'references/local-development.md'; Text = $localDevelopment }, + [pscustomobject]@{ Name = 'references/production-image.md'; Text = $productionImage } +) +foreach ($source in $composeSources) { + if (-not $source.Text.Contains('compose.assets.yml', [System.StringComparison]::Ordinal)) { + throw "$($source.Name) is missing Compose naming contract: compose.assets.yml" + } +} +if (-not $localDevelopment.Contains('docker compose -f compose.assets.yml', [System.StringComparison]::Ordinal)) { + throw 'references/local-development.md must show the canonical compose.assets.yml invocation.' +} $forms = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'FORMS.md')) if (-not $forms.Contains('### cdn_equivalent', [System.StringComparison]::Ordinal)) { From 868dd40a9396adf04324316ec12b5b2774198f57 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 19:20:42 +0200 Subject: [PATCH 13/41] =?UTF-8?q?=F0=9F=92=AC=20update=20repository=20read?= =?UTF-8?q?me=20for=20dotnet-segregated-assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect improvements to skill guidance and naming conventions. The updated skill listing clarifies the architectural intent and highlights the deterministic verification approach. --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 21a9d9f..9157a0c 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ npx skills add https://github.com/codebeltnet/agentic --skill agent-smith | [dotnet-test](skills/dotnet-test/SKILL.md) | Bootstraps and refactors xUnit projects to Codebelt conventions. It deterministically inspects project roles, target frameworks, xUnit generation, package ownership, inheritance, application entry points—including Bootstrapper `MinimalConsoleProgram`, `MinimalWorkerProgram`, and `MinimalWebProgram` hosts—and every selected `WebApplicationFactory` usage; classifies ordinary unit, ASP.NET Core functional, and console/worker functional tests; modernizes xUnit v2 projects to xUnit v3 plus Microsoft Testing Platform without moving package ownership or changing frameworks; and resolves current stable compatible packages through NuGet-backed isolated compatibility-project restores, including the selected combined package set. Focused web tests use `WebApplicationTestFactory` with an explicit entrypoint-owned `ManagedWebApplicationFixture`, directly or through a narrow `Test`-derived harness; shared web fixtures use `WebApplicationTest` with `ManagedWebApplicationFixture`; focused console/worker tests use `ApplicationTestFactory` with `ManagedApplicationFixture`; and shared non-web fixtures use `ApplicationTest` with `ManagedApplicationFixture`. Deprecated blocking fixtures are migration inputs only and are never emitted because they are scheduled for removal. Functional migrations fail closed unless the chosen Codebelt pattern and managed fixture are present, the legacy or blocking fixture is absent, and test code does not reconstruct the production composition root with its own `WebApplication`, `TestServer`, or `HostBuilder`. Migrations preserve entrypoint-owned startup, host configuration, lazy start, clients, services, configuration, sync/async disposal, isolation, and existing test names, while fresh bootstraps add source-grounded behavior tests. Non-web tests stay in-process and require a resolvable Generic Host; test-only scope reports the exact production adaptation instead of silently rewriting startup or launching a process. | | [dotnet-benchmark](skills/dotnet-benchmark/SKILL.md) | Discovers, prioritizes, and authors trustworthy BenchmarkDotNet experiments for a .NET type following codebelt conventions and using the `Codebelt.Extensions.BenchmarkDotNet.Console` runner. It inspects implementation code, call sites, tests, existing benchmarks, and available profiles instead of benchmarking every public member; ranks likely high-impact operations; selects representative typical, boundary, scaling, and adverse cases; and rejects external-I/O or service-level questions that need profiling, macrobenchmarks, or load tests. It creates fair current-versus-candidate comparisons only when observable work is equivalent, uses baseline-free single-operation characterization when no honest comparator exists, prevents unrelated construction/formatting/equality/hash ratios, requires exact per-case correctness oracles plus a semantic preflight for truthful workload labels, hard-gates interpretation on a complete valid BenchmarkDotNet summary, preserves workload invariants such as selectivity and hit/miss ratios as sizes scale, distinguishes deferred pipeline creation from terminal/materialization work, and performs Release build, discovery listing, and dry execution before any explicit full run. Explicit `yolo` mode auto-accepts routine repo-derived defaults and the proposed plan, then proceeds through build/list/dry validation without confirmation churn; only a separate explicit human instruction can start a full performance run. Its runner preflight recognizes the standard Slim/runtime setup and explains when `SkipBenchmarksWithReports = true` plus a matching `reports/tuning/` artifact deliberately filters a benchmark, preventing needless class renames, disassembly, or tool thrash; after the first valid full result it stops unless deeper diagnostics could change a real engineering decision. Harness setup remains adaptive: it detects `.slnx`/`.sln`, CPM, existing `tuning/` projects, and a reusable `tooling/` runner, onboards only missing pieces, resolves package versions dynamically, and keeps the benchmark class in the SUT namespace. | | [dotnet-remote-testing](skills/dotnet-remote-testing/SKILL.md) | Run .NET tests inside a resolved remote Docker environment and return concise, structured results — Visual Studio's Remote Testing experience (choose an environment → run tests → see results) with the container plumbing hidden behind a deterministic runner (`scripts/remote-test.cs`) the skill orchestrates instead of composing ad-hoc `docker run` commands. It honors Microsoft's existing `testenvironments.json` version-1 contract (`name`, `localRoot`, `dockerImage`, `dockerFile`, with the either/or Docker-source rule), treats that file as authoritative when present, and reports WSL/SSH/unknown types as unsupported rather than converting or silently ignoring them. When no `testenvironments.json` exists it provides a zero-configuration experience built exclusively on official `mcr.microsoft.com/dotnet/sdk` images, discovering the currently supported LTS and STS channels plus the current preview from Microsoft's live `releases-index.json` using `support-phase`/`release-type` (never hardcoded version numbers or even/odd assumptions) and caching that metadata outside the repository for offline reuse. It prefers an exact `latest-sdk` image tag (stripping preview build metadata), validates the tag against Microsoft's registry, and pins each execution to the resolved immutable digest so results are reproducible across environment, image, digest, SDK, and architecture. Execution stages the source into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, mounts a persistent NuGet cache outside the repo, runs restore → build → test with structured TRX collection, classifies failures into distinct kinds (configuration, unsupported environment, Docker unavailable, image resolution, SDK incompatibility, staging, restore, compilation, test-host, test failure, result-processing, cleanup, cancellation, release-metadata) so infrastructure problems are never reported as failing unit tests, and always cleans up transient Docker resources. It never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and never silently falls back to running tests on the host. Docker is the only transport for now, designed so WSL/SSH can be added later without disturbing the deterministic Docker path, which is covered by a comprehensive built-in `--self-test` plus a PowerShell harness. | -| [dotnet-segregated-assets](skills/dotnet-segregated-assets/SKILL.md) | Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional `wwwroot` while deployed static content is served by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) — a separate asset host, not the web application. The skill orchestrates a bundled deterministic runner (`scripts/segregate-assets.cs`) that inspects the static-asset topology, distinguishes App assets (app-owned, authored in `wwwroot`) from shared CDN assets (reusable across applications, never duplicated into `wwwroot`), detects and escalates risky Blazor / Razor Class Library / generated Static Web Assets scenarios instead of blindly excluding them, and proves the publish invariant by publishing to an isolated temp directory. It adds an `http-segregated-assets` HTTP launch profile pointing App URLs at a local read-only origin (scheme-safe, never protocol-relative against an HTTP origin), a hardened local `web-cdn-origin:2.0.0` service mounting `wwwroot` into `/cdnroot` read-only (non-root, read-only root filesystem, no privileged mode, no Docker socket), and a derived `Assets.Dockerfile` production image (`FROM codebeltnet/web-cdn-origin:2.0.0` + `COPY --chown=65532:65532 ./wwwroot/ /cdnroot/`) using Docker's PascalCase `.Dockerfile` convention. App-owned `wwwroot` is excluded from web publish with targeted `` metadata rather than the `StaticWebAssetsEnabled` global kill switch, keeping Razor Class Library (`_content`) and framework (`_framework`) assets intact. The motivation is architectural — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading — not HTTP/1.x domain sharding. It reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the app's own base-URL setting otherwise, never adding a Cuemon dependency just to migrate, and reconciles idempotently on re-run. | +| [dotnet-segregated-assets](skills/dotnet-segregated-assets/SKILL.md) | Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional `wwwroot` while deployed static content is served by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) — a separate asset host, not the web application. The skill orchestrates a bundled deterministic runner (`scripts/segregate-assets.cs`) that inspects the static-asset topology, distinguishes App assets (app-owned, authored in `wwwroot`) from shared CDN assets (reusable across applications, never duplicated into `wwwroot`), detects and escalates risky Blazor / Razor Class Library / generated Static Web Assets scenarios, and proves the publish invariant by publishing to an isolated temp directory. It adds an `http-segregated-assets` HTTP launch profile pointing App URLs at a local read-only origin (scheme-safe, never protocol-relative against an HTTP origin), a hardened local `web-cdn-origin:2.0.0` service mounting `wwwroot` into `/cdnroot` read-only (non-root, read-only root filesystem, no privileged mode, no Docker socket), and a derived `Assets.Dockerfile` production image (`FROM codebeltnet/web-cdn-origin:2.0.0` + `COPY --chown=65532:65532 ./wwwroot/ /cdnroot/`) using Docker's PascalCase `.Dockerfile` convention; when a dedicated local Compose file is appropriate, it uses the canonical `compose.assets.yml` name to pair with that image. App-owned `wwwroot` is excluded from web publish with targeted `` metadata while preserving Razor Class Library (`_content`), framework (`_framework`), and generated Static Web Assets flow. The motivation is architectural — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading. It reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the app's own base-URL setting otherwise, never adding a Cuemon dependency just to migrate, and reconciles idempotently on re-run. | | [agent-smith](skills/agent-smith/SKILL.md) | Apply a rigorous, consistent, evidence-driven software-craftsmanship standard across a whole engineering task. Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | ### Copyable Install Commands @@ -702,15 +702,16 @@ Cross-platform .NET developers usually get Linux test feedback the slow way: pus **dotnet-segregated-assets** keeps that split honest. The skill is the orchestration layer — it understands intent, reads the repository's real conventions, and makes the edits — while the bundled deterministic runner (`scripts/segregate-assets.cs`) inspects the static-asset topology, classifies it, and *proves* the outcome instead of trusting a declaration that merely looks right. -- **`wwwroot` stays the authoring root** — developers keep editing where they always did; no `approot`/`cdnroot` source folder, and never the removed 1.4 `ADD approot` Dockerfile pattern +- **`wwwroot` stays the authoring root** — developers keep editing where they always did, while `/cdnroot` remains the container content root for the asset host - **App is not CDN** — app-owned assets (authored in `wwwroot`, served from a per-app asset host) are separated from shared CDN assets (reusable across applications, never duplicated into any app's `wwwroot`), and the skill always asks whether a CDN equivalent exists -- **Targeted exclusion, not a kill switch** — app-owned `wwwroot` is removed from web publish with ``, verified empirically to leave Razor Class Library (`_content`) and framework (`_framework`) assets intact — exactly what `StaticWebAssetsEnabled=false` would wrongly destroy +- **Targeted app-owned publish handling** — `` removes application-owned files while preserving Razor Class Library (`_content`), framework (`_framework`), and generated Static Web Assets - **Proven, not assumed** — `verify --run-publish` publishes to an isolated temp directory and asserts app-owned `wwwroot` files are absent from the artifact; verification output never touches the repository - **Safety guardrail over broken migrations** — Blazor, Blazor WebAssembly, Razor Class Library, scoped CSS, component JavaScript modules, and frontend-build scenarios are detected and escalated rather than blindly excluded; stopping to request an explicit generated-static-assets design is a successful outcome, not a failure - **Scheme-safe local topology** — the `http-segregated-assets` profile points App URLs at an `http://localhost:` origin, never a protocol-relative or `https://localhost` URL that an HTTPS page would break against an HTTP-only origin - **Hardened local origin** — the local `web-cdn-origin:2.0.0` service mounts `wwwroot` into `/cdnroot` read-only as a non-root user, with a read-only root filesystem, no privileged mode, no Docker socket, and only the required port exposed - **Docker naming aligned** — the derived production asset image uses the Docker-documented `.Dockerfile` form with PascalCase `Assets.Dockerfile`, selected explicitly when a non-default Dockerfile is built -- **Architectural motivation, stated honestly** — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading; never justified as HTTP/1.x domain sharding or extra browser connection parallelism +- **Compose naming aligned** — a dedicated local origin topology uses `compose.assets.yml`, paired with `Assets.Dockerfile`; existing repository orchestration is extended when present +- **Architectural motivation, stated honestly** — segregation of duties, independent deployment and scaling, explicit cache behavior, and origin/CDN offloading - **Adapts, never imposes** — it reuses Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` when already present and the application's own base-URL setting otherwise, and never adds a Cuemon dependency just to migrate - **Idempotent and deterministic** — re-running reconciles existing segregation instead of duplicating MSBuild items, launch profiles, Compose services, or Dockerfiles, and the runner ships a built-in `--self-test` plus a PowerShell harness - **Fail-closed verification and planning** — local verification requires both the matching HTTP launch profile and origin Compose service, generated-asset risks override existing-segregation detection, and a no-`wwwroot` project can still produce CDN-only work when a shared equivalent exists From 408a26202b3cae95cacad7ea5b6b7a0de325197a Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 11 Aug 2026 23:12:47 +0200 Subject: [PATCH 14/41] =?UTF-8?q?=F0=9F=93=9D=20expand=20dotnet-segregated?= =?UTF-8?q?-assets=20skill=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance skill instructions with clearer workflows, improve reference documentation with detailed examples and rationale, and add comprehensive FORMS parameter collection. These updates provide users and maintainers with complete guidance on skill usage and configuration. --- skills/dotnet-segregated-assets/FORMS.md | 33 +- skills/dotnet-segregated-assets/SKILL.md | 289 ++++++++++-------- .../references/app-vs-cdn.md | 121 +++++--- .../references/local-development.md | 186 +++++------ .../references/production-image.md | 19 +- .../references/static-web-assets-guardrail.md | 4 + 6 files changed, 364 insertions(+), 288 deletions(-) diff --git a/skills/dotnet-segregated-assets/FORMS.md b/skills/dotnet-segregated-assets/FORMS.md index 315719a..265ab02 100644 --- a/skills/dotnet-segregated-assets/FORMS.md +++ b/skills/dotnet-segregated-assets/FORMS.md @@ -1,6 +1,6 @@ # .NET Segregated Static Assets Input Form -Collect only the fields that are still unresolved after running `segregate-assets.cs inspect` and reading the repository. Most fields have a computed or recommended default — present it first and accept a blank answer as acceptance. Prefer the host's native structured input controls when they are available; otherwise use the deterministic plain-text fallback described under **Presentation rules** without changing field order, defaults, recommended choices, or the final confirmation. +Collect only the fields that are still unresolved after running `segregate-assets.cs inspect` and reading the repository. Use the runner's Cuemon and custom-abstraction evidence to resolve `asset_configuration`; do not infer an abstraction from a filename alone. Most fields have a computed or recommended default — present it first and accept a blank answer as acceptance. Prefer the host's native structured input controls when they are available; otherwise use the deterministic plain-text fallback described under **Presentation rules** without changing field order, defaults, recommended choices, or the final confirmation. The single question you must always resolve is `cdn_equivalent`. It changes whether a second local origin is provisioned and how shared assets are referenced, and it must never be assumed. @@ -33,16 +33,18 @@ The single question you must always resolve is `cdn_equivalent`. It changes whet - **required:** false - **show_when:** `cdn_equivalent` is `Yes` -### asset_configuration - -- **type:** single-choice -- **prompt:** How are App/CDN asset URLs generated in this application? -- **choices:** - - Cuemon App/Cdn tag helpers already present (Recommended when detected) - - The application's own asset base-URL option/setting - - No abstraction yet — introduce a minimal app-owned base-URL setting -- **default:** Auto-detected from inspection (Cuemon when `AppTagHelperOptions`/`CdnTagHelperOptions` are found; otherwise the app's own setting) (Recommended) -- **required:** true +### asset_configuration + +- **type:** single-choice +- **prompt:** How are App/CDN asset URLs generated in this application? +- **choices:** + - Cuemon App/CDN TagHelpers already present — reuse `AppTagHelperOptions`/`CdnTagHelperOptions` (Recommended when detected) + - The application's own suitable asset base-URL option/setting + - No suitable abstraction yet — introduce the smallest app-owned setting only if required +- **default:** Auto-detected from inspection using package/project references, namespace imports, options, `_ViewImports.cshtml`, and actual `app-*`/`cdn-*` markup (Recommended) +- **required:** true + +When Cuemon is detected, do not select the non-Cuemon or new-abstraction choice merely because an earlier `AppAssetOptions`-style abstraction exists. Treat it as a migration input, classify its consumers as App or CDN, and remove it only after proving that no consumers remain. ### app_origin_port @@ -102,12 +104,13 @@ The single question you must always resolve is `cdn_equivalent`. It changes whet ## Presentation rules -- Run `segregate-assets.cs inspect` first and infer explicit answers from its output and the repository; do not ask questions the inspection already answers. -- Ask one unresolved field at a time. Never bundle multiple questions. +- Run `segregate-assets.cs inspect` first and infer explicit answers from its output and the repository; do not ask questions the inspection already answers. +- Treat `assetAbstractions.cuemon` and `assetAbstractions.custom` as evidence. If both are present, plan a semantic migration and cleanup; do not leave two URL-generation systems behind. +- Ask one unresolved field at a time. Never bundle multiple questions. - Present the recommended/default choice first and suffix it with `(Recommended)`. - For `web_project`, offer the discovered project names as selectable choices; when exactly one web project applies, select it without asking. - For `cdn_equivalent`, always ask if it is unresolved — never assume shared assets belong in the application's wwwroot. - For `text` fields with a computed default (ports, hosts), offer the computed value as a selectable choice alongside free text, and treat a blank response as accepting the shown value. - If native structured input widgets are unavailable, follow this deterministic plain-text fallback instead of improvising your own questioning style: start immediately with `Field: `, then a one-line prompt, then numbered choices (recommended first), and accept a blank reply as the default. Do not add a conversational preamble, and do not switch interaction styles mid-collection. Consistency matters more than creativity during parameter collection. -- Respect `show_when` conditions: skip `cdn_source`, `cdn_origin_port`, and `deployed_cdn_host` entirely when `cdn_equivalent` is `No`; skip `web_project` when only one web project exists. -- After all fields are resolved, summarize the exact project, App/CDN ports, deployed hosts, asset-configuration approach, and whether a production image will be built, then ask `confirmation`. +- Respect `show_when` conditions: skip `cdn_source`, `cdn_origin_port`, and `deployed_cdn_host` entirely when `cdn_equivalent` is `No`; skip `web_project` when only one web project exists. +- After all fields are resolved, summarize the exact project, App/CDN ports, deployed hosts, asset-configuration approach, any competing abstraction cleanup, and whether a production image will be built, then ask `confirmation`. diff --git a/skills/dotnet-segregated-assets/SKILL.md b/skills/dotnet-segregated-assets/SKILL.md index ffc2df0..8c3ff39 100644 --- a/skills/dotnet-segregated-assets/SKILL.md +++ b/skills/dotnet-segregated-assets/SKILL.md @@ -1,131 +1,162 @@ ---- -name: dotnet-segregated-assets -description: > - Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional wwwroot, while deployed static content is served by Codebelt Static Content Provider (codebeltnet/web-cdn-origin:2.0.0), a separate asset host rather than the web app. Use when asked to segregate static assets, move wwwroot off the web app, serve static files from a separate asset host, or stop shipping wwwroot with the app. Distinguishes App assets (app-owned, from wwwroot) from shared CDN assets, adds an http-segregated-assets launch profile, derives a production asset image, and excludes app-owned wwwroot from publish with targeted MSBuild metadata while preserving the framework asset pipeline. Escalates risky Blazor, RCL, and generated Static Web Assets scenarios. Do NOT use to build a general-purpose CDN or migrate non-ASP.NET static sites. -compatibility: > - Requires the .NET SDK 10+ and PowerShell 7+. Docker is optional (only for the local origin). ---- - -# .NET Segregated Static Assets - -Keep the developer experience developers already know — author static files in `wwwroot` — while making the **deployed** web application stop serving and stop shipping those files. In production the static content is delivered by **Codebelt Static Content Provider** (`codebeltnet/web-cdn-origin:2.0.0`), a separately built and deployed asset host, not by the ASP.NET Core business application. - -The one invariant everything else follows from: - -> `wwwroot` remains the application's conventional static-content **authoring root**, but it is **not** part of the deployed web application's static-content serving responsibility. - -## Architecture: you orchestrate, the runner inspects and verifies - -The bundled .NET file-based program `scripts/segregate-assets.cs` is the **deterministic layer**. You are the **orchestration layer**: understand intent, resolve the repository's real conventions, make the edits, and resolve the App-vs-CDN semantic choices. Route inspection and verification through the runner instead of guessing: - -``` -dotnet run --file "/scripts/segregate-assets.cs" -- [options] -``` - -Commands: `inspect` (discover web projects, classify static-asset topology, detect risky Static Web Assets, report existing segregation), `plan` (resolve the target project, ports, and the ordered decision list without writing files), `verify` (publish to an isolated temp directory and prove app-owned `wwwroot` is absent, plus validate the local origin topology), and `--self-test`. Add `--json` to any command for machine-readable output. The runner never edits the repository — it inspects and verifies; **you** apply edits using the literal templates in `references/`, adapted to the project. - -## Critical - -- **Keep `wwwroot` as the authoring root and `/cdnroot` as the container content root.** Use the base image's established port, runtime user, and working directory. -- **Exclude app-owned `wwwroot` from publish with targeted metadata.** Use `` and preserve Razor Class Library (`_content/…`) and framework (`_framework/…`) asset flow. See `references/static-web-assets-guardrail.md`. -- **Never claim a declaration works because it looks right — prove it.** Application-owned files from source `wwwroot` must be absent from the publish artifact. Confirm with `verify --run-publish` against an isolated temp output; never write verification output into the repository. -- **App is not CDN.** App assets are app-owned and authored in `wwwroot`; CDN assets are shared across applications and must never be duplicated into an application's `wwwroot`. Always ask whether a CDN/shared-asset equivalent exists (`FORMS.md`). -- **Keep local URLs scheme-safe.** The local origin speaks HTTP on a host port. Point App asset URLs at `http://localhost:` from an HTTP application profile. Never emit a protocol-relative (`//localhost:`) or `https://localhost:` URL that an HTTPS page would turn into an HTTPS request against an HTTP-only origin. -- **Motivation is architectural.** The value is segregation of duties, independent deployment and scaling, explicit cache behavior, origin/CDN offloading, and a reduced application artifact. - -## Step 1: Inspect before changing anything - -``` -dotnet run --file "/scripts/segregate-assets.cs" -- inspect --repo-root "" [--project ] --json -``` - -The runner returns candidate web projects, the resolved target, a `classification`, risk signals, and existing-segregation flags. Act on the classification: - -| Classification | Meaning | What you do | -|---|---|---| -| `Simple` | Physical `wwwroot`, no risky generated assets | Apply App-asset segregation (Steps 3–6). | -| `RiskyGeneratedAssets` | Blazor/RCL/scoped-CSS/frontend-build/etc. detected | **Stop and escalate** (Step 2 guardrail). Preserve the generated asset pipeline. | -| `AlreadySegregated` | Publish exclusion + segregated profile present | Reconcile idempotently — do not duplicate. | -| `Ambiguous` | Multiple web projects | Ask which project; pass `--project`. | -| `NoWwwroot` | Web app without `wwwroot` | Only configure CDN consumption if a CDN equivalent exists. | -| `NotAWebApp` | No `Microsoft.NET.Sdk.Web` project | Confirm the target repository. | - -## Step 2: Collect intent and honor the guardrail - -Read `FORMS.md` and infer what you can. The one question you must always resolve is whether a **CDN/shared-asset equivalent exists** — because it changes whether you provision a second origin and how shared assets are referenced. Never assume shared assets belong in the application's `wwwroot`. - -If `inspect` reports `RiskyGeneratedAssets`, treat it as a **compatibility guardrail**. Preserve Blazor Web App, Blazor WebAssembly, `_framework`/`_content`, Razor Class Library, scoped CSS, component JS, and frontend-generated output through an explicit asset-artifact design. If you cannot establish a safe, deterministic way to materialize the required generated output while preserving correct runtime references, **stop and report that the project needs an explicit generated-static-assets segregation design.** That is a successful safety outcome, not a failure. Details: `references/static-web-assets-guardrail.md`. - -## Step 3: Segregate App assets - -For a `Simple` project, apply these idempotently (skip any the runner already reports as present). All literal templates live in `references/` — read them and adapt paths, ports, and naming to the repository's conventions rather than copying blindly. - -1. **Exclude app-owned `wwwroot` from web publish** — add the targeted `Content Update="wwwroot/**" CopyToPublishDirectory="Never"` item to the web project. (`references/production-image.md`) -2. **Add a segregated launch profile** — a new `http-segregated-assets` profile that keeps the app in Development but points App asset URLs at the local origin over HTTP. Preserve the ordinary Development profile untouched. (`references/local-development.md`) -3. **Provide a local Static Content Provider** — run `codebeltnet/web-cdn-origin:2.0.0` mounting the app's existing `wwwroot` into `/cdnroot` **read-only**, on a host port, with a hardened posture (non-root, read-only root filesystem where practical, no privileged mode, no Docker socket, no extra capabilities, only the required port). Prefer a tiny dedicated Compose file unless the repo already has an orchestration mechanism to extend. Name a dedicated file `compose.assets.yml` to pair the local topology with `Assets.Dockerfile`, and invoke it explicitly with `docker compose -f compose.assets.yml ...`. (`references/local-development.md`) - -## Step 4: Configure App URL generation - -Adapt to the application's existing URL-generation abstraction; do **not** add a Cuemon dependency just to implement this skill. - -- **If Cuemon `AppTagHelperOptions`/`CdnTagHelperOptions` are already present:** set App local `BaseUrl` to the local App origin (`localhost:`) with `Scheme = Http`; set CDN local `BaseUrl` to the local shared origin with `Scheme = Http` when a CDN equivalent exists; use `Scheme = Https` absolute URLs for deployed configuration. The default `Scheme = Relative` emits protocol-relative `//` URLs — unsafe against an HTTP-only local origin, so make the local scheme explicit. -- **If Cuemon is not used:** configure the application's own asset base-URL setting (for example a `SegregatedAssets:App:BaseUrl` / `:Scheme` option the app already reads, or its equivalent) and drive it from the launch profile's environment variables. - -See `references/app-vs-cdn.md`. - -## Step 5: CDN assets (only when an equivalent exists) - -If a shared CDN equivalent exists, determine its existing source/configuration. When its content is locally available, provision a **second** local origin on a different host port: - -``` -localhost: -> web-cdn-origin:2.0.0 -> /wwwroot -localhost: -> web-cdn-origin:2.0.0 -> -``` - -Point CDN asset URLs at that origin locally and at the shared/CDN host in deployment. Never copy CDN assets into the application's `wwwroot`. - -## Step 6: Build the production asset image and verify - -Add a derived image that ships the **actual final asset output** (run the frontend build first if the app generates its `wwwroot`): - -Name the derived Dockerfile `.Dockerfile` with a PascalCase `` prefix. For this skill, use `Assets.Dockerfile`. Select it explicitly with `--file` or the equivalent Compose `dockerfile` property. - -```dockerfile -FROM codebeltnet/web-cdn-origin:2.0.0 - -COPY --chown=65532:65532 ./wwwroot/ /cdnroot/ -``` - -Do not override the base image's `/cdnroot`, port, runtime user (`65532`), or working directory without a demonstrated requirement, and prefer `COPY` over `ADD`. Where CI/CD already builds once and promotes artifacts, emit the static assets as their own artifact and package them into the image rather than rebuilding. Document the integration point; do not redesign CI/CD. (`references/production-image.md`) - -Then prove the invariant: - -``` -dotnet run --file "/scripts/segregate-assets.cs" -- verify --repo-root "" -p "" --run-publish --check-local --json -``` - -`verify` must report the app-owned `wwwroot` files ABSENT from the publish artifact (shared `_content`/`_framework` assets are allowed to remain) and the local topology as scheme-safe and hardened. - -## Step 7: Document the two workflows - -Update the application's documentation to state that deployed static content is intentionally served by Codebelt Static Content Provider, not the ASP.NET Core business application, and that `wwwroot` remains because it is the conventional, tooling-friendly authoring location. Show Normal Development, Segregated Development, and Deployment flows (and a separate parallel flow for shared CDN assets when one exists). Template in `references/production-image.md`. - -## Idempotency - -Running the skill again on a configured app must not create duplicate MSBuild items, launch profiles, Compose services, Dockerfiles, or documentation sections, must not increment ports unnecessarily, and must not overwrite customized URLs or introduce a competing asset-configuration system. Use `inspect` to detect existing segregation and reconcile it. - +--- +name: dotnet-segregated-assets +description: > + Migrate or configure an ASP.NET Core web application so developers keep authoring static files in the conventional wwwroot while deployed static content is served by Codebelt Static Content Provider (codebeltnet/web-cdn-origin:2.0.0), a separate asset host rather than the web app. Use when asked to segregate static assets, move wwwroot off the web app, stop shipping wwwroot with the app, or reconcile Cuemon App/CDN TagHelpers with a segregated topology. Reuse existing Cuemon or project abstractions, distinguish App assets from shared CDN assets, preserve Static Web Assets, and verify publish/local invariants deterministically. Do NOT use to build a general-purpose CDN or migrate non-ASP.NET static sites. +compatibility: > + Requires the .NET SDK 10+ and PowerShell 7+. Docker is optional (only for the local origin). +--- + +# .NET Segregated Static Assets + +Keep `wwwroot` as the conventional, tooling-friendly authoring root while making the deployed web application stop serving and shipping its application-owned files. Deployed static content is delivered by Codebelt Static Content Provider (`codebeltnet/web-cdn-origin:2.0.0`) through a separate asset host, not by the ASP.NET Core business application. + +The architecture is: + +```text +Razor declares ownership: app-* or cdn-* +Configuration declares location: current application, local segregated origin, or deployed host +web-cdn-origin declares delivery: static content only +``` + +## Runner boundary + +The bundled .NET file-based program `scripts/segregate-assets.cs` is the deterministic inspection and verification layer. The agent is the orchestration and editing layer: it resolves repository conventions, makes semantic source/configuration edits, and verifies the result. The runner never edits or rewrites Razor, C#, project files, launch profiles, Dockerfiles, Compose, or documentation. + +```text +dotnet run --file "/scripts/segregate-assets.cs" -- [options] +``` + +Use `inspect` before changing anything, `plan` for a read-only ordered decision list, `verify` to publish into an isolated temporary directory and check local topology, and `--self-test` for the hermetic runner tests. Add `--json` when consuming results mechanically. + +`inspect` reports candidate projects, risk signals, existing segregation, and asset-abstraction evidence including: + +- Cuemon package references in the project or inherited build files, referenced projects, namespace imports, `AppTagHelperOptions`, `CdnTagHelperOptions`, `_ViewImports.cshtml` registration, and actual `app-*`/`cdn-*` elements; +- custom `AppAssetOptions`-style types, options registrations, Razor injections, and `GetUrl`/`GetAssetUrl`-style calls; +- coexistence of Cuemon and a competing custom abstraction; +- stale attribute-style syntax and cache-busting signals such as `asp-append-version` and `ICacheBusting`. + +Use these facts to decide what the agent should edit. Do not turn the runner into a general source-code migration engine. + +## Critical invariants + +- Keep `wwwroot` as the authoring root and `/cdnroot` as the container content root. Do not resurrect `approot`. +- Exclude application-owned `wwwroot` from the deployed web application's publish artifact with targeted metadata: ``. +- Do not use `false` as a blanket solution. Preserve Blazor, RCL, framework, generated, and frontend Static Web Assets guards; stop for an explicit generated-static-assets design when those outputs cannot be safely materialized and verified. +- Static assets are served by `codebeltnet/web-cdn-origin:2.0.0`; local `/cdnroot` mounts remain read-only and the runtime remains non-root. +- App assets and shared CDN assets are separate concepts. Always determine whether a shared/CDN equivalent exists before deciding origins or markup. Never copy shared content into an application's `wwwroot`. +- Preserve ordinary Development. Segregated Development is opt-in through `http-segregated-assets`. +- Verification is deterministic and re-running the skill is idempotent. Never claim the publish invariant from an MSBuild declaration alone; run `verify --run-publish`. +- Do not add Cuemon merely to implement this skill when the application otherwise does not use it. + +## Decision hierarchy for asset URL abstractions + +Existing framework and project abstractions are preferred over skill-invented abstractions. Resolve the following order after `inspect`: + +1. **Cuemon is already available.** Reuse `Cuemon.AspNetCore.Razor.TagHelpers`, `AppTagHelperOptions`, and `CdnTagHelperOptions`. Do not create `AppAssetOptions`, `SegregatedAssetsOptions`, another `GetAssetUrl()` abstraction, or a second configuration hierarchy. If a previous migration created a custom abstraction, migrate it away as described below. +2. **A suitable non-Cuemon abstraction exists.** Reuse it. Do not add Cuemon solely because the skill knows about it. +3. **No suitable abstraction exists.** Introduce only the smallest app-owned configuration mechanism needed by the existing application, and only after confirming that no framework/project abstraction is available. + +When Cuemon is detected through multiple signals, use the referenced package/source to confirm its exact current configuration surface. The current public model exposes `TagHelperOptions.BaseUrlMode` with `TagHelperBaseUrlMode.Configured` and `TagHelperBaseUrlMode.Automatic`, alongside `BaseUrl` and `ProtocolUriScheme`. For App assets, set `AppTagHelperOptions.BaseUrlMode = TagHelperBaseUrlMode.Automatic`: an explicit App `BaseUrl` wins, while an absent App `BaseUrl` resolves against the active application request. Keep CDN assets explicitly configured with `CdnTagHelperOptions.BaseUrlMode = TagHelperBaseUrlMode.Configured` and an explicit CDN base. Cuemon does not inspect launch-profile names. + +## App/CDN ownership and Razor migration + +Classify every static reference by ownership before changing it. + +App assets are owned by exactly one application: its CSS, JavaScript, branding, images, favicons, manifest, application fonts, and application-specific media. Shared CDN assets are reusable across applications: Bootstrap, Font Awesome, shared fonts, design-system packages, reusable JavaScript/CSS libraries, and common images. A file's current directory or URL shape does not establish ownership. + +When Cuemon is available, use its current public custom-element syntax: + +```html +