diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9049483f..b17971c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Validate i18n keys run: npm run validate:i18n + - name: Validate framework scope + run: node scripts/validate-framework-scope.mjs + - name: TypeScript type check run: npm run typecheck diff --git a/CLAUDE.md b/CLAUDE.md index 1c7455dd..223911d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant - **ClientApp** (`template/SimpleModule.Host/ClientApp/app.tsx`) — Inertia bootstrap. Resolves pages by splitting route name (e.g., `Products/Browse` → imports `/_content/Products/Products.pages.js`). - **Module pages** — Each module builds its React pages via Vite in library mode → `{ModuleName}.pages.js` in module's `wwwroot/`. Entry point: `Pages/index.ts` exporting a `pages` record mapping route names to components. -- **Type generation** — `[Dto]` types → source generator embeds TS interfaces → `tools/extract-ts-types.mjs` writes `.ts` files to `ClientApp/types/`. +- **Type generation** — `[Dto]` types → source generator embeds TS interfaces → `scripts/extract-ts-types.mjs` writes `.ts` files to `ClientApp/types/`. ### Request Flow diff --git a/Directory.Packages.props b/Directory.Packages.props index f41945d5..361fc59c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,7 +67,7 @@ - + diff --git a/Dockerfile b/Dockerfile index 9e7d92fb..8b9e21f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ COPY framework/SimpleModule.Core/*.csproj framework/SimpleModule.Core/ COPY framework/SimpleModule.Database/*.csproj framework/SimpleModule.Database/ COPY framework/SimpleModule.Generator/*.csproj framework/SimpleModule.Generator/ COPY framework/SimpleModule.Hosting/*.csproj framework/SimpleModule.Hosting/ -COPY framework/SimpleModule.DevTools/*.csproj framework/SimpleModule.DevTools/ +COPY tools/SimpleModule.DevTools/*.csproj tools/SimpleModule.DevTools/ COPY framework/SimpleModule.Storage/*.csproj framework/SimpleModule.Storage/ COPY framework/SimpleModule.Storage.Local/*.csproj framework/SimpleModule.Storage.Local/ COPY framework/SimpleModule.Storage.Azure/*.csproj framework/SimpleModule.Storage.Azure/ diff --git a/Dockerfile.worker b/Dockerfile.worker index 21dbe836..33524add 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -13,7 +13,7 @@ WORKDIR /src # Node is needed in the build stage (not at runtime) because the # ExtractDtoTypeScript / ExtractRoutes targets in SimpleModule.Hosting.targets -# shell out to `node tools/extract-*.mjs` after CoreCompile. The scripts only +# shell out to `node scripts/extract-*.mjs` after CoreCompile. The scripts only # use Node stdlib, so no `npm ci` — just the node binary. RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ @@ -32,7 +32,7 @@ COPY framework/SimpleModule.Core/*.csproj framework/SimpleModule.Core/ COPY framework/SimpleModule.Database/*.csproj framework/SimpleModule.Database/ COPY framework/SimpleModule.Generator/*.csproj framework/SimpleModule.Generator/ COPY framework/SimpleModule.Hosting/*.csproj framework/SimpleModule.Hosting/ -COPY framework/SimpleModule.DevTools/*.csproj framework/SimpleModule.DevTools/ +COPY tools/SimpleModule.DevTools/*.csproj tools/SimpleModule.DevTools/ COPY framework/SimpleModule.Storage/*.csproj framework/SimpleModule.Storage/ COPY framework/SimpleModule.Storage.Local/*.csproj framework/SimpleModule.Storage.Local/ COPY framework/SimpleModule.Storage.Azure/*.csproj framework/SimpleModule.Storage.Azure/ diff --git a/README.md b/README.md index 7eba63b6..cf3724b4 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ template/ cli/ SimpleModule.Cli # `sm` CLI tool for scaffolding and validation tests/ # Framework tests, shared test infrastructure, and e2e tests -tools/ # Build/dev orchestrators, type extraction, component scaffolding +scripts/ # Build/dev orchestrators, type extraction, component scaffolding ``` ### Request Flow diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 50cd7cce..8462e602 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -8,7 +8,6 @@ - @@ -27,6 +26,9 @@ + + + diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index 854cc7e1..5b788503 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -544,3 +544,40 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` - `AnalysisLevel=latest-all`, `AnalysisMode=All`. - Suppressed rules live in `.editorconfig`. - Tests run against both SQLite and PostgreSQL in CI. + +--- + +## 13. Framework Scope + +The `framework/` directory contains foundational plumbing: module lifecycle, source generation, DbContext infrastructure, and host bootstrap. Nothing else. + +Framework projects are explicitly allowlisted in `framework/.allowed-projects`. The target list contains exactly: `SimpleModule.Core`, `SimpleModule.Database`, `SimpleModule.Generator`, `SimpleModule.Hosting`. During the in-flight migration, the list is temporarily permissive and shrinks as projects migrate. + +### Adding a project to `framework/` + +Requires: + +1. Justification that the project is foundational — referenced by the host bootstrap or by every module, with no domain or provider semantics. +2. A PR that updates `.allowed-projects`, names the reviewer, and documents why a module or `tools/` project is insufficient. + +### `tools/` category + +The `tools/` directory holds non-module .NET utilities consumed by the host, the framework bootstrap, or other tools. Rules: + +- Flat layout: `tools/SimpleModule.{Name}/{Name}.csproj` — no `src/` subdirectory, no Contracts split. +- Tools never declare `[Module]`. +- Modules (anything under `modules/`) never reference a `tools/` project. The host and `framework/SimpleModule.Hosting` may. + +### Sub-projects + +A sub-project is an additional assembly inside a module, used when a module owns multiple optional providers (e.g., `SimpleModule.Agents.AI.Anthropic`). Rules: + +- Lives at `modules/{Name}/src/SimpleModule.{Name}.{Suffix}/`. +- Name matches `SimpleModule.{Name}.{Suffix}`. +- Does not declare `[Module]` — only the main module assembly owns lifecycle. +- May not own a `DbContext`. +- Follows the same dependency rules as its module (Section 3). + +### Enforcement + +`scripts/validate-framework-scope.mjs` runs in CI and in `npm run check`. It fails on any violation of the rules above. diff --git a/docs/site/frontend/overview.md b/docs/site/frontend/overview.md index e1a3738e..c071bfd8 100644 --- a/docs/site/frontend/overview.md +++ b/docs/site/frontend/overview.md @@ -107,7 +107,7 @@ The `@simplemodule/client` package (`packages/SimpleModule.Client/`) provides th ## Type Safety -The source generator discovers C# types marked with the `[Dto]` attribute and embeds TypeScript interface definitions. The `tools/extract-ts-types.mjs` script extracts these into `.ts` files under `ClientApp/types/`, giving React components full type safety over server-provided props: +The source generator discovers C# types marked with the `[Dto]` attribute and embeds TypeScript interface definitions. The `scripts/extract-ts-types.mjs` script extracts these into `.ts` files under `ClientApp/types/`, giving React components full type safety over server-provided props: ```tsx import type { Product } from '../types'; diff --git a/docs/site/frontend/vite.md b/docs/site/frontend/vite.md index 91316961..ea8ee22b 100644 --- a/docs/site/frontend/vite.md +++ b/docs/site/frontend/vite.md @@ -139,7 +139,7 @@ Three scripts are standard: ### `npm run dev` -The `npm run dev` command starts the complete development environment using the **dev orchestrator** (`tools/dev-orchestrator.mjs`). It launches three types of processes in parallel: +The `npm run dev` command starts the complete development environment using the **dev orchestrator** (`scripts/dev-orchestrator.mjs`). It launches three types of processes in parallel: 1. **`dotnet run`** -- The ASP.NET backend on `https://localhost:5001` 2. **Module watches** -- `vite build --watch` for every module with a `vite.config.ts` @@ -197,7 +197,7 @@ npm run build ## Build Orchestrator -The production build uses the **build orchestrator** (`tools/build-orchestrator.mjs`) which: +The production build uses the **build orchestrator** (`scripts/build-orchestrator.mjs`) which: 1. Discovers all buildable workspaces (modules + ClientApp) 2. Builds all workspaces **in parallel** for performance diff --git a/docs/superpowers/plans/2026-04-20-framework-scaffolding-and-devtools.md b/docs/superpowers/plans/2026-04-20-framework-scaffolding-and-devtools.md new file mode 100644 index 00000000..fc5214da --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-framework-scaffolding-and-devtools.md @@ -0,0 +1,774 @@ +# Framework Scope Minimization — Phase 0 (Scaffolding) + Phase 1 (DevTools) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the enforcement scaffolding (allowlist file, CI validation script, Constitution section) that prevents `framework/` from growing, and prove it works by moving `SimpleModule.DevTools` out of `framework/` into a new `tools/` category. + +**Architecture:** A plain-text allowlist file (`framework/.allowed-projects`) names every project permitted under `framework/`. A Node validation script (`scripts/validate-framework-scope.mjs`) fails CI if `framework/` contains anything else, if sub-projects under `modules/` violate the naming pattern, or if `tools/` projects misbehave. Constitution Section 13 documents the invariant. After scaffolding is green, `SimpleModule.DevTools` moves from `framework/` to a new `tools/` directory — the first project to exercise the new category. + +**Tech Stack:** Node.js (validation script), MSBuild `.slnx` / `.csproj` (solution file, project references), GitHub Actions (CI), markdown (Constitution). + +**Follow-up plans (not in this plan):** Phase 2 (Storage providers → `modules/FileStorage/`), Phase 3 (Agents providers → `modules/Agents/`), Phase 4 (Rag providers → `modules/Rag/`). Each will be its own plan once Phase 1 lands. + +**Related spec:** `docs/superpowers/specs/2026-04-20-framework-scope-minimization-design.md` + +--- + +## Task 1: Create the permissive allowlist + +The allowlist starts with every current framework project listed. This keeps CI green during migration. Entries are removed as each framework project moves out. + +**Files:** +- Create: `framework/.allowed-projects` + +- [ ] **Step 1: Create the allowlist file** + +Write the following to `framework/.allowed-projects` (one project name per line, alphabetical for diffability): + +``` +SimpleModule.Agents +SimpleModule.AI.Anthropic +SimpleModule.AI.AzureOpenAI +SimpleModule.AI.Ollama +SimpleModule.AI.OpenAI +SimpleModule.Core +SimpleModule.Database +SimpleModule.DevTools +SimpleModule.Generator +SimpleModule.Hosting +SimpleModule.Rag +SimpleModule.Rag.StructuredRag +SimpleModule.Rag.VectorStore.InMemory +SimpleModule.Rag.VectorStore.Postgres +SimpleModule.Storage +SimpleModule.Storage.Azure +SimpleModule.Storage.Local +SimpleModule.Storage.S3 +``` + +This mirrors the output of `ls framework/` today (excluding `Directory.Build.props`). + +- [ ] **Step 2: Verify the list matches reality** + +Run: `ls framework/ | grep -v Directory.Build.props | sort | diff - framework/.allowed-projects` +Expected: no output (files match exactly). + +--- + +## Task 2: Write the validation script + +A single-file Node script, no dependencies beyond Node stdlib. It performs four checks and exits 1 on any failure. The codebase pattern (see `scripts/validate-i18n.mjs`) is self-contained scripts without separate unit tests — CI exercises the script on the real repo. + +**Files:** +- Create: `scripts/validate-framework-scope.mjs` + +- [ ] **Step 1: Create the script with the shebang and framework allowlist check** + +```javascript +#!/usr/bin/env node +// Validates framework scope rules (see docs/CONSTITUTION.md Section 13). +// Usage: node scripts/validate-framework-scope.mjs +// +// Checks: +// 1. framework/ only contains projects listed in framework/.allowed-projects +// 2. Sub-projects under modules/{Name}/src/ match SimpleModule.{Name}.{Suffix} +// 3. Sub-projects do not declare [Module] +// 4. tools/ projects are flat-layout SimpleModule.{Name} and never declare [Module] +// and no module csproj references a tools/ project + +import { readdirSync, readFileSync, statSync } from 'fs'; +import { resolve, join, basename } from 'path'; + +const repoRoot = resolve(new URL('..', import.meta.url).pathname); +const errors = []; + +function readAllowlist() { + const path = join(repoRoot, 'framework', '.allowed-projects'); + return readFileSync(path, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +function checkFrameworkAllowlist() { + const allowed = new Set(readAllowlist()); + const frameworkDir = join(repoRoot, 'framework'); + const entries = readdirSync(frameworkDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!allowed.has(entry.name)) { + errors.push( + `framework/${entry.name} is not in framework/.allowed-projects. ` + + `Add it with reviewer approval, or move it out of framework/.`, + ); + } + } +} + +// Placeholder implementations — filled in by later tasks. +function checkSubProjectNaming() {} +function checkSubProjectNoModuleAttribute() {} +function checkToolsLayering() {} + +checkFrameworkAllowlist(); +checkSubProjectNaming(); +checkSubProjectNoModuleAttribute(); +checkToolsLayering(); + +if (errors.length > 0) { + console.error('Framework scope validation failed:\n'); + for (const err of errors) console.error(` ✗ ${err}`); + process.exit(1); +} + +console.log('✓ Framework scope validation passed'); +``` + +- [ ] **Step 2: Make the script executable and verify it runs** + +Run: `chmod +x scripts/validate-framework-scope.mjs && node scripts/validate-framework-scope.mjs` +Expected: `✓ Framework scope validation passed` (exit 0). The allowlist matches current framework/ state, so the check passes. + +- [ ] **Step 3: Verify the check catches violations** + +Temporarily create a rogue project directory to confirm the check fires: + +```bash +mkdir -p framework/SimpleModule.RogueProject +node scripts/validate-framework-scope.mjs +echo "Exit: $?" +rmdir framework/SimpleModule.RogueProject +``` + +Expected output (exit 1): +``` +Framework scope validation failed: + + ✗ framework/SimpleModule.RogueProject is not in framework/.allowed-projects. ... +Exit: 1 +``` + +Expected after restoration: re-running the script exits 0. + +--- + +## Task 3: Add sub-project naming check + +Sub-projects live under `modules/{ModuleName}/src/` and must be named `SimpleModule.{ModuleName}` (main), `SimpleModule.{ModuleName}.Contracts`, or `SimpleModule.{ModuleName}.{Suffix}` (sub-project). + +**Files:** +- Modify: `scripts/validate-framework-scope.mjs` + +- [ ] **Step 1: Implement `checkSubProjectNaming`** + +Replace the placeholder `function checkSubProjectNaming() {}` with: + +```javascript +function checkSubProjectNaming() { + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + for (const moduleEntry of moduleDirs) { + const moduleName = moduleEntry.name; + const srcDir = join(modulesDir, moduleName, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + const expectedPrefix = `SimpleModule.${moduleName}`; + for (const projectEntry of projectDirs) { + const name = projectEntry.name; + // Must match SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.{Suffix} + if (name !== expectedPrefix && !name.startsWith(`${expectedPrefix}.`)) { + errors.push( + `modules/${moduleName}/src/${name}/ does not match required ` + + `pattern '${expectedPrefix}[.*]'. Sub-projects must be named ` + + `'${expectedPrefix}.{Suffix}'.`, + ); + } + } + } +} + +function exists(path) { + try { statSync(path); return true; } catch { return false; } +} +``` + +- [ ] **Step 2: Verify the check passes against current repo state** + +Run: `node scripts/validate-framework-scope.mjs` +Expected: `✓ Framework scope validation passed` (exit 0). + +If this fails, it means an existing module already violates the pattern — fix the violation in a separate PR before continuing. (Unlikely, since the spec asserts the convention is already followed.) + +- [ ] **Step 3: Verify the check catches violations** + +```bash +mkdir -p modules/Products/src/SimpleModule.WrongName +node scripts/validate-framework-scope.mjs +echo "Exit: $?" +rmdir modules/Products/src/SimpleModule.WrongName +``` + +Expected (exit 1): `modules/Products/src/SimpleModule.WrongName/ does not match required pattern 'SimpleModule.Products[.*]'...` + +--- + +## Task 4: Add sub-project no-[Module] check + +Sub-projects may not declare `[Module]` — only the main module assembly owns lifecycle. A simple grep across `.cs` files is sufficient. + +**Files:** +- Modify: `scripts/validate-framework-scope.mjs` + +- [ ] **Step 1: Add a recursive `.cs` file walker helper** + +Insert this helper near the other helpers: + +```javascript +function walkCsFiles(dir) { + const results = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'bin' || entry.name === 'obj' || entry.name === 'node_modules') { + continue; + } + stack.push(full); + } else if (entry.isFile() && entry.name.endsWith('.cs')) { + results.push(full); + } + } + } + return results; +} +``` + +- [ ] **Step 2: Implement `checkSubProjectNoModuleAttribute`** + +Replace `function checkSubProjectNoModuleAttribute() {}` with: + +```javascript +function checkSubProjectNoModuleAttribute() { + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + for (const moduleEntry of moduleDirs) { + const moduleName = moduleEntry.name; + const srcDir = join(modulesDir, moduleName, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + for (const projectEntry of projectDirs) { + const name = projectEntry.name; + const isMain = name === `SimpleModule.${moduleName}`; + const isContracts = name === `SimpleModule.${moduleName}.Contracts`; + if (isMain || isContracts) continue; + // This is a sub-project. Scan its .cs files for [Module( + const files = walkCsFiles(join(srcDir, name)); + for (const file of files) { + const content = readFileSync(file, 'utf8'); + if (/\[\s*Module\s*\(/.test(content)) { + errors.push( + `Sub-project ${name} declares [Module] in ${file.substring(repoRoot.length + 1)}. ` + + `Only the main module assembly (SimpleModule.${moduleName}) may declare [Module].`, + ); + } + } + } + } +} +``` + +- [ ] **Step 3: Verify the check passes** + +Run: `node scripts/validate-framework-scope.mjs` +Expected: `✓ Framework scope validation passed` (no existing sub-projects exist yet, so this check is a no-op against current state). + +- [ ] **Step 4: Verify the check catches a violation** + +Create a temporary sub-project with a bogus `[Module]`: + +```bash +mkdir -p modules/Products/src/SimpleModule.Products.Fake +cat > modules/Products/src/SimpleModule.Products.Fake/Bad.cs <<'EOF' +using SimpleModule.Core; +[Module("Bad")] +public class BadSub {} +EOF +node scripts/validate-framework-scope.mjs +echo "Exit: $?" +rm -rf modules/Products/src/SimpleModule.Products.Fake +``` + +Expected (exit 1): `Sub-project SimpleModule.Products.Fake declares [Module] in modules/Products/src/SimpleModule.Products.Fake/Bad.cs. Only the main module assembly (SimpleModule.Products) may declare [Module].` + +Expected after cleanup: script passes again. + +--- + +## Task 5: Add tools/ layering check + +Tools live at `tools/SimpleModule.{Name}/` (flat). No `.cs` file under `tools/` declares `[Module]`. No `.csproj` under `modules/` references a `tools/` project. + +Note: `tools/` may not exist yet at this point in the plan. The check must skip silently when the directory is missing. + +**Files:** +- Modify: `scripts/validate-framework-scope.mjs` + +- [ ] **Step 1: Implement `checkToolsLayering`** + +Replace `function checkToolsLayering() {}` with: + +```javascript +function checkToolsLayering() { + const toolsDir = join(repoRoot, 'tools'); + if (exists(toolsDir)) { + const entries = readdirSync(toolsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith('SimpleModule.')) { + errors.push( + `tools/${entry.name}/ does not match required naming 'SimpleModule.{Name}'.`, + ); + continue; + } + const toolPath = join(toolsDir, entry.name); + const files = walkCsFiles(toolPath); + for (const file of files) { + const content = readFileSync(file, 'utf8'); + if (/\[\s*Module\s*\(/.test(content)) { + errors.push( + `tools/${entry.name} declares [Module] in ${file.substring(repoRoot.length + 1)}. ` + + `Tools are not modules and must not declare [Module].`, + ); + } + } + } + } + // Check no module csproj references a tools/ project. + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + for (const moduleEntry of moduleDirs) { + const srcDir = join(modulesDir, moduleEntry.name, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()); + for (const projectEntry of projectDirs) { + const csprojPath = join(srcDir, projectEntry.name, `${projectEntry.name}.csproj`); + if (!exists(csprojPath)) continue; + const content = readFileSync(csprojPath, 'utf8'); + // Match ProjectReference paths containing tools/ or tools\ + if (/ProjectReference[^>]*Include="[^"]*[\\/]tools[\\/]/.test(content)) { + errors.push( + `${csprojPath.substring(repoRoot.length + 1)} references a tools/ project. ` + + `Modules may not depend on tools/ — tools are for host/framework only.`, + ); + } + } + } +} +``` + +- [ ] **Step 2: Verify the check passes** + +Run: `node scripts/validate-framework-scope.mjs` +Expected: `✓ Framework scope validation passed` (tools/ does not exist yet). + +- [ ] **Step 3: Commit the script and allowlist** + +```bash +git add framework/.allowed-projects scripts/validate-framework-scope.mjs +git commit -m "feat: add framework scope validation script and allowlist + +Scaffolds the invariant that framework/ contains only foundational +plumbing. Script enforces four rules: +- framework/ directory allowlisted in framework/.allowed-projects +- sub-projects named SimpleModule.{Module}[.{Suffix}] +- sub-projects do not declare [Module] +- tools/ flat-layout, no [Module], not referenced from modules/ + +Allowlist starts permissive (all 19 current framework projects) and +shrinks as projects migrate to modules/ or tools/." +``` + +--- + +## Task 6: Wire validation into CI and `npm run check` + +Two places run the check: GitHub Actions (always on PRs) and the local `npm run check` (developer feedback before push). + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `package.json` + +- [ ] **Step 1: Add the step to the lint job in ci.yml** + +Read `.github/workflows/ci.yml` and find the `lint:` job. Insert a new step after `Validate i18n keys` and before `TypeScript type check`: + +```yaml + - name: Validate framework scope + run: node scripts/validate-framework-scope.mjs +``` + +The result (showing context): + +```yaml + - name: Validate i18n keys + run: npm run validate:i18n + + - name: Validate framework scope + run: node scripts/validate-framework-scope.mjs + + - name: TypeScript type check + run: npm run typecheck +``` + +- [ ] **Step 2: Append the check to `npm run check`** + +Find this line in `package.json`: + +```json +"check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run typecheck", +``` + +Replace with: + +```json +"check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run validate:framework-scope && npm run typecheck", +``` + +Add the new script entry to `package.json` scripts block (alphabetically near other `validate:*` entries): + +```json +"validate:framework-scope": "node scripts/validate-framework-scope.mjs", +``` + +- [ ] **Step 3: Verify `npm run check` works** + +Run: `npm run check` +Expected: all sub-checks pass, including `✓ Framework scope validation passed`. Exit 0. + +If biome reports formatting issues on the newly created `scripts/validate-framework-scope.mjs`, run `npm run check:fix` to fix them. + +- [ ] **Step 4: Commit CI wiring** + +```bash +git add .github/workflows/ci.yml package.json +git commit -m "ci: enforce framework scope validation in CI and npm check + +Adds validate-framework-scope to the lint job and to the local +npm run check pipeline so violations fail fast before PR review." +``` + +--- + +## Task 7: Add Constitution Section 13 + +Document the invariant in the authoritative rules file. Existing Constitution sections are numbered 1-12; this adds Section 13 at the end. + +**Files:** +- Modify: `docs/CONSTITUTION.md` + +- [ ] **Step 1: Read the end of the Constitution to confirm insertion point** + +Run: `tail -20 docs/CONSTITUTION.md` +Expected: Section 12 "Framework Contributor Guidelines" ends the file. Note the last line (should be the end of that section's content). + +- [ ] **Step 2: Append Section 13** + +Append the following to `docs/CONSTITUTION.md` (keep trailing newline): + +```markdown + +--- + +## 13. Framework Scope + +The `framework/` directory contains foundational plumbing: module lifecycle, source generation, DbContext infrastructure, and host bootstrap. Nothing else. + +Framework projects are explicitly allowlisted in `framework/.allowed-projects`. The target list contains exactly: `SimpleModule.Core`, `SimpleModule.Database`, `SimpleModule.Generator`, `SimpleModule.Hosting`. During the in-flight migration, the list is temporarily permissive and shrinks as projects migrate. + +### Adding a project to `framework/` + +Requires: + +1. Justification that the project is foundational — referenced by the host bootstrap or by every module, with no domain or provider semantics. +2. A PR that updates `.allowed-projects`, names the reviewer, and documents why a module or `tools/` project is insufficient. + +### `tools/` category + +The `tools/` directory holds non-module .NET utilities consumed by the host, the framework bootstrap, or other tools. Rules: + +- Flat layout: `tools/SimpleModule.{Name}/{Name}.csproj` — no `src/` subdirectory, no Contracts split. +- Tools never declare `[Module]`. +- Modules (anything under `modules/`) never reference a `tools/` project. The host and `framework/SimpleModule.Hosting` may. + +### Sub-projects + +A sub-project is an additional assembly inside a module, used when a module owns multiple optional providers (e.g., `SimpleModule.Agents.AI.Anthropic`). Rules: + +- Lives at `modules/{Name}/src/SimpleModule.{Name}.{Suffix}/`. +- Name matches `SimpleModule.{Name}.{Suffix}`. +- Does not declare `[Module]` — only the main module assembly owns lifecycle. +- May not own a `DbContext`. +- Follows the same dependency rules as its module (Section 3). + +### Enforcement + +`scripts/validate-framework-scope.mjs` runs in CI and in `npm run check`. It fails on any violation of the rules above. +``` + +- [ ] **Step 3: Verify the file is well-formed** + +Run: `grep -c "^## " docs/CONSTITUTION.md` +Expected: `13` (one heading per Constitution section). + +- [ ] **Step 4: Commit Constitution update** + +```bash +git add docs/CONSTITUTION.md +git commit -m "docs: add Constitution Section 13 on Framework Scope + +Documents the framework allowlist, tools/ category, sub-project +convention, and validation mechanism that enforces them." +``` + +--- + +## Task 8: Create `tools/` and move DevTools into it + +Moves `framework/SimpleModule.DevTools/` to `tools/SimpleModule.DevTools/` via `git mv` to preserve history. DevTools does not have a Contracts split, so it drops straight into the flat `tools/` layout. + +**Files:** +- Move: `framework/SimpleModule.DevTools/` → `tools/SimpleModule.DevTools/` + +- [ ] **Step 1: Verify DevTools' current state** + +Run: `ls framework/SimpleModule.DevTools/` +Expected: `.csproj`, `.cs` files, `README.md`, no Contracts subdirectory. + +- [ ] **Step 2: Move the directory with git mv** + +Run: +```bash +git mv framework/SimpleModule.DevTools tools/SimpleModule.DevTools +git status --short | head -20 +``` + +Expected output contains rename entries: +``` +R framework/SimpleModule.DevTools/DevToolsConstants.cs -> tools/SimpleModule.DevTools/DevToolsConstants.cs +... (one line per file) +``` + +--- + +## Task 9: Update ProjectReference paths + +Two `.csproj` files reference DevTools via relative path: +- `framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj` — was `..\SimpleModule.DevTools\...`, now needs `..\..\tools\SimpleModule.DevTools\...` +- `tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj` — was `..\..\framework\SimpleModule.DevTools\...`, now needs `..\..\tools\SimpleModule.DevTools\...` + +**Files:** +- Modify: `framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj` +- Modify: `tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj` + +- [ ] **Step 1: Update Hosting csproj** + +Find the existing line in `framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj`: + +```xml + +``` + +Replace with: + +```xml + +``` + +- [ ] **Step 2: Update DevTools.Tests csproj** + +Find the existing line in `tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj`: + +```xml + +``` + +Replace with: + +```xml + +``` + +--- + +## Task 10: Update the solution file + +`SimpleModule.slnx` lists every project. DevTools is currently under the `/framework/` folder. Move it to a new `/tools/` folder entry. + +**Files:** +- Modify: `SimpleModule.slnx` + +- [ ] **Step 1: Inspect the solution file structure** + +Run: `grep -n -B1 -A1 "DevTools\|` section containing `DevTools` and any existing `` or similar. + +- [ ] **Step 2: Remove the DevTools line from the framework folder** + +Find this line inside the `` block: + +```xml + +``` + +Delete it. + +- [ ] **Step 3: Add DevTools to a `/tools/` folder** + +If a `` block does not exist, add one near the `/framework/` block. Example insertion: + +```xml + + + +``` + +If a `` block already exists (unlikely, but check), add the `` line inside it. + +--- + +## Task 11: Verify the move built cleanly + +Before removing the allowlist entry and committing, confirm nothing is broken. + +- [ ] **Step 1: Build the affected projects** + +Run: `dotnet build framework/SimpleModule.Hosting tools/SimpleModule.DevTools tests/SimpleModule.DevTools.Tests 2>&1 | tail -5` + +Expected: `Build succeeded. 0 Error(s)`. + +If restore fails because of pre-existing MailKit NU1902 warnings in unrelated projects, add `-p:NoWarn=NU1902` and try again — those errors are out of scope (flagged in the spec). + +- [ ] **Step 2: Run DevTools tests** + +Run: `dotnet test tests/SimpleModule.DevTools.Tests 2>&1 | tail -8` + +Expected: all tests pass. + +- [ ] **Step 3: Run the validation script** + +Run: `node scripts/validate-framework-scope.mjs` + +Expected: **this should still pass**. DevTools is still in `.allowed-projects`, and `framework/SimpleModule.DevTools/` no longer exists so the allowlist permissiveness is fine. The `tools/` layering check sees `tools/SimpleModule.DevTools/` — flat layout, no `[Module]` — and passes. + +If the script fails with "tools/SimpleModule.DevTools declares [Module]" — that would be a bug in DevTools (it shouldn't have one). Inspect the offending file and confirm it's a false positive (e.g., a comment or string containing `[Module(`) or a genuine issue. + +--- + +## Task 12: Remove DevTools from the allowlist + +Now that DevTools has moved out of `framework/`, the allowlist must no longer include it. This is the edit that makes the script ACTIVELY enforce the new state. + +**Files:** +- Modify: `framework/.allowed-projects` + +- [ ] **Step 1: Remove the DevTools line** + +Delete the line `SimpleModule.DevTools` from `framework/.allowed-projects`. + +Verify with: + +Run: `grep -c "^SimpleModule\." framework/.allowed-projects` +Expected: `17` (was 18, now 17). + +- [ ] **Step 2: Run the validation script** + +Run: `node scripts/validate-framework-scope.mjs` +Expected: `✓ Framework scope validation passed` (exit 0). framework/ no longer contains DevTools, and the allowlist no longer lists it. Consistent. + +- [ ] **Step 3: Prove the guard now catches regression** + +Temporarily restore DevTools to framework/ to confirm the allowlist rejects it: + +```bash +mkdir -p framework/SimpleModule.DevTools +node scripts/validate-framework-scope.mjs +echo "Exit: $?" +rmdir framework/SimpleModule.DevTools +``` + +Expected (exit 1): `framework/SimpleModule.DevTools is not in framework/.allowed-projects. ...` + +Expected after cleanup: `✓ Framework scope validation passed`. + +--- + +## Task 13: Final commit for Phase 1 + +- [ ] **Step 1: Run `npm run check`** + +Run: `npm run check` +Expected: all sub-checks pass. + +- [ ] **Step 2: Commit the DevTools move** + +```bash +git add -A +git status --short +git commit -m "refactor: move DevTools from framework/ to tools/ + +First application of the tools/ category. DevTools is a dev-time +utility (Vite dev middleware, live reload, file watchers), not +foundational plumbing, so it belongs in tools/ rather than framework/. + +- git mv framework/SimpleModule.DevTools tools/SimpleModule.DevTools +- Updated ProjectReference paths in Hosting and DevTools.Tests +- Updated SimpleModule.slnx (moved under new /tools/ folder) +- Removed SimpleModule.DevTools from framework/.allowed-projects + +Framework is now down to 17 allowlisted projects; phases 2-4 will +absorb the remaining provider projects into their owning modules." +``` + +- [ ] **Step 3: Verify final state** + +Run: `git log --oneline -5` +Expected: the most recent commits are (newest first): +1. `refactor: move DevTools from framework/ to tools/` +2. `docs: add Constitution Section 13 on Framework Scope` +3. `ci: enforce framework scope validation in CI and npm check` +4. `feat: add framework scope validation script and allowlist` + +Run: `ls framework/ | sort` +Expected: 18 directory entries (was 19, DevTools removed) + `Directory.Build.props`. + +Run: `ls tools/` +Expected: `SimpleModule.DevTools` (the only entry). + +--- + +## Summary + +After this plan lands: + +- `framework/.allowed-projects` explicitly lists every permitted framework project (now 17). +- `scripts/validate-framework-scope.mjs` enforces four rules on every CI run and every local `npm run check`. +- Constitution Section 13 documents the invariant. +- DevTools has migrated from `framework/` to `tools/`, proving the `tools/` category works end to end. +- Phases 2, 3, 4 (Storage, Agents, Rag absorptions) can now proceed, each with its own plan, each shrinking the allowlist by 4-5 entries. diff --git a/docs/superpowers/specs/2026-04-20-framework-scope-minimization-design.md b/docs/superpowers/specs/2026-04-20-framework-scope-minimization-design.md new file mode 100644 index 00000000..b048d5d2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-framework-scope-minimization-design.md @@ -0,0 +1,217 @@ +# Framework Scope Minimization — Design + +## Goal + +Shrink the framework to the foundational plumbing that every module depends on. Everything domain-shaped, provider-shaped, or optional moves into a module or into a new `tools/` category. Prevent regression via an explicit allowlist enforced in CI. + +## Target state + +``` +framework/ 4 projects (Core, Database, Generator, Hosting) +tools/ non-module .NET projects (DevTools and future siblings) +modules/ existing modules + absorbed framework provider projects +packages/ unchanged (frontend npm packages) +scripts/ Node build scripts (just renamed from tools/) +``` + +The `tools/` rename from the original `tools/` directory to `scripts/` has already landed in commit-pending work. + +## What moves where + +### Framework allowlist (final state) + +Exactly these four projects remain under `framework/`: + +- `SimpleModule.Core` +- `SimpleModule.Database` +- `SimpleModule.Generator` +- `SimpleModule.Hosting` + +### Migration map + +| From | To | +|---|---| +| `framework/SimpleModule.Agents` | `modules/Agents/src/SimpleModule.Agents/` (merge into main assembly) | +| `framework/SimpleModule.AI.Anthropic` | `modules/Agents/src/SimpleModule.Agents.AI.Anthropic/` | +| `framework/SimpleModule.AI.AzureOpenAI` | `modules/Agents/src/SimpleModule.Agents.AI.AzureOpenAI/` | +| `framework/SimpleModule.AI.Ollama` | `modules/Agents/src/SimpleModule.Agents.AI.Ollama/` | +| `framework/SimpleModule.AI.OpenAI` | `modules/Agents/src/SimpleModule.Agents.AI.OpenAI/` | +| `framework/SimpleModule.Rag` | `modules/Rag/src/SimpleModule.Rag/` (merge into main assembly) | +| `framework/SimpleModule.Rag.StructuredRag` | `modules/Rag/src/SimpleModule.Rag.StructuredRag/` | +| `framework/SimpleModule.Rag.VectorStore.InMemory` | `modules/Rag/src/SimpleModule.Rag.VectorStore.InMemory/` | +| `framework/SimpleModule.Rag.VectorStore.Postgres` | `modules/Rag/src/SimpleModule.Rag.VectorStore.Postgres/` | +| `framework/SimpleModule.Storage` | `modules/FileStorage/src/SimpleModule.FileStorage.Storage/` (or merged into Contracts — audit during Phase 1) | +| `framework/SimpleModule.Storage.Azure` | `modules/FileStorage/src/SimpleModule.FileStorage.Azure/` | +| `framework/SimpleModule.Storage.Local` | `modules/FileStorage/src/SimpleModule.FileStorage.Local/` | +| `framework/SimpleModule.Storage.S3` | `modules/FileStorage/src/SimpleModule.FileStorage.S3/` | +| `framework/SimpleModule.DevTools` | `tools/SimpleModule.DevTools/` | + +The `FileStorage → Storage` module rename is deferred to a separate follow-up. During this work, the FileStorage module keeps its current name, and the absorbed sub-projects take temporary names like `SimpleModule.FileStorage.Azure`. The follow-up drops `File` everywhere. + +## Sub-project convention + +A **sub-project** is a `.csproj` under `modules/{Name}/src/` that is not the main module assembly or its Contracts. + +Rules: + +1. Name matches `SimpleModule.{ModuleName}.{Suffix}` (e.g., `SimpleModule.Agents.AI.Anthropic`). +2. Lives at `modules/{ModuleName}/src/SimpleModule.{ModuleName}.{Suffix}/`. +3. Does not declare `[Module]` — only the main assembly owns lifecycle. Sub-projects expose DI via extension methods that the main module calls from `ConfigureServices`. +4. May contain `IEndpoint` implementations, `[Dto]` types, services, value objects. The source generator picks these up through the main module's transitive reference chain — no generator changes required. +5. May not own a `DbContext`. The module owns data. +6. Dependencies: may reference own Contracts, other modules' Contracts, framework Core; may not reference another module's implementation (SM0011 already enforces this). + +The generator does not need to know about sub-projects. It discovers `[Module]` classes (sub-projects have none), `IEndpoint` implementations across referenced assemblies (already works), and `[Dto]` types across assemblies (already works). Existing diagnostics SM0052/SM0053 only fire on types annotated with `[Module]` and so do not affect sub-projects. + +## `tools/` category + +A `.csproj` under `tools/` is a development-time or host-time utility. + +Rules: + +1. Name matches `SimpleModule.{ToolName}` (e.g., `SimpleModule.DevTools`). +2. Lives at `tools/SimpleModule.{ToolName}/` — flat layout, no `src/` subdirectory, no Contracts split. +3. Does not declare `[Module]`. +4. No endpoints, no `DbContext`, no events. +5. Referenced by the host or by other tools only — modules never reference `tools/` projects. + +Tools are invisible to module discovery because they declare no `[Module]`. + +## Enforcement + +One CI script, one invariant file, one Constitution section. + +### `framework/.allowed-projects` + +Plain text, one project name per line. During migration the file starts with all current framework projects listed and shrinks as PRs land. Final content: + +``` +SimpleModule.Core +SimpleModule.Database +SimpleModule.Generator +SimpleModule.Hosting +``` + +### `scripts/validate-framework-scope.mjs` + +Runs four checks, exit 1 on any failure: + +1. **Framework allowlist.** Every directory `framework/*/` must match an entry in `.allowed-projects`. +2. **Sub-project naming.** Every `.csproj` under `modules/{ModuleName}/src/` that is not `SimpleModule.{ModuleName}` or `SimpleModule.{ModuleName}.Contracts` must match `SimpleModule.{ModuleName}.*`. +3. **Sub-project lifecycle.** No `.cs` file in a sub-project declares `[Module(`. +4. **Tools layering.** Every directory under `tools/` is `SimpleModule.{Name}/`. No `.cs` file under `tools/` declares `[Module(`. No `.csproj` under `modules/` has a `ProjectReference` to a `tools/` project. + +### CI wiring + +New step in the existing GitHub Actions workflow, after `dotnet restore`. No build required — file-scan only. Also appended to `npm run check` so local pre-push catches violations. + +### Constitution Section 13 (new) + +Text to add to `docs/CONSTITUTION.md`: + +> ## 13. Framework Scope +> +> The `framework/` directory contains foundational plumbing: module lifecycle, source generation, DbContext infrastructure, and host bootstrap. Nothing else. +> +> Framework projects are explicitly allowlisted in `framework/.allowed-projects`. This list contains exactly: `SimpleModule.Core`, `SimpleModule.Database`, `SimpleModule.Generator`, `SimpleModule.Hosting`. +> +> Adding a project to `framework/` requires: +> +> 1. Justification that the project is foundational — referenced by the host bootstrap or by every module, with no domain or provider semantics. +> 2. A PR that updates `.allowed-projects`, names the reviewer, and documents why a module or `tools/` project is insufficient. +> +> The `tools/` directory holds non-module .NET utilities consumed by the host or other tools. Tools never declare `[Module]` and are never referenced from modules. +> +> Sub-projects are additional assemblies inside a module. They live at `modules/{Name}/src/SimpleModule.{Name}.{Suffix}/`, do not declare `[Module]`, and may not own a `DbContext`. Section 2 module ownership rules apply; sub-projects inherit them. + +### Rejected alternatives + +- **MSBuild-level validation** — duplicates the CI check with more error noise during partial builds. Rejected. +- **New SM diagnostic for allowlist violation** — adds complexity to the generator, which contradicts the shrink-the-framework goal. Rejected. +- **NuGet-level enforcement** — out of scope; packaging is orthogonal. + +## Migration phases + +The allowlist starts permissive (all 19 projects listed) and shrinks as PRs land, keeping CI green throughout. + +### Phase 0: Scaffolding + +- Add `framework/.allowed-projects` listing all 19 current framework projects. +- Add `scripts/validate-framework-scope.mjs`. +- Add Constitution Section 13. +- Wire check into CI and `npm run check`. + +Risk: low. Adds files, changes nothing functional. + +### Phase 1: DevTools → `tools/` + +Warm-up phase, proves the `tools/` category works with least scope. + +- `framework/SimpleModule.DevTools/` → `tools/SimpleModule.DevTools/` +- Update host reference in `template/SimpleModule.Host/SimpleModule.Host.csproj`. +- Update `SimpleModule.slnx`. +- Remove `SimpleModule.DevTools` from `.allowed-projects`. + +Risk: low. Single project move, no absorption. + +### Phase 2: Storage providers → `modules/FileStorage/` + +Moves 4 framework projects into the existing FileStorage module. + +- `framework/SimpleModule.Storage*` → `modules/FileStorage/src/SimpleModule.FileStorage.{Storage,Azure,Local,S3}/` +- Audit whether `SimpleModule.Storage` (the abstractions) should merge into existing `SimpleModule.FileStorage.Contracts` or remain as its own sub-project. +- Resolve pre-existing SM0025 error on FileStorage contract-implementation split. +- Update `.slnx`, host `ProjectReference`s, any `.targets` paths. +- Remove 4 entries from `.allowed-projects`. + +Risk: moderate. Structural moves touch solution file and host references. + +### Phase 3: Agents providers → `modules/Agents/` + +Same pattern as Phase 2, 5 projects. + +- `framework/SimpleModule.Agents` + `AI.{Anthropic,AzureOpenAI,Ollama,OpenAI}` → `modules/Agents/src/SimpleModule.Agents[.AI.*]/` +- Resolve pre-existing SM0025 on `IAgentsContracts`. +- Remove 5 entries from `.allowed-projects`. + +Risk: moderate. Five projects, four external SDK dependencies. + +### Phase 4: Rag providers → `modules/Rag/` + +Same pattern, 4 projects. + +- `framework/SimpleModule.Rag*` → `modules/Rag/src/SimpleModule.Rag[.StructuredRag|.VectorStore.*]/` +- Resolve pre-existing SM0025 on `IRagContracts`. +- Remove 4 entries from `.allowed-projects`. + +Risk: moderate. + +### Phase 5 (implicit) + +After Phases 1-4 land, `.allowed-projects` contains exactly the four target entries. No separate work — the allowlist is the natural end state. + +## Follow-up (not in this project) + +**`FileStorage → Storage` module rename.** Separate PR, separate risk profile. + +Scope: +- Rename module directory, projects, namespaces, classes, constants. +- Change `[Module]` name, `RoutePrefix`, `ViewPrefix`. +- Update permission constants (SM0034 will enforce prefix). +- Update React page names and `Pages/index.ts` keys. +- DB migration: rename Postgres/SQL Server schema (`filestorage` → `storage`) or SQLite table prefix (`FileStorage_` → `Storage_`). +- Update any cross-module references to `SimpleModule.FileStorage.Contracts`. + +Risk: high. User-facing URL changes, permission string changes, irreversible DB migration. Ship alone so revert is clean. + +## Cross-phase note + +The current build fails on pre-existing SM0025 errors for `IAgentsContracts`, `IRagContracts`, and `IJobExecutionContext`. These are not introduced by the migration — they indicate the framework/module split is already partially broken on `main`. Phases 2-4 must resolve their respective SM0025 as part of absorption; they block per-phase verification. + +Unrelated: `MailKit 4.15.1` has a known moderate-severity vulnerability (NU1902) failing the host build under `TreatWarningsAsErrors`. This is pre-existing and outside the scope of this work — flag for a separate dependency-upgrade PR. + +## Out of scope + +- Splitting `SimpleModule.Core` into smaller framework projects (Orchard-style decomposition). Interesting but orthogonal. +- Decomposing modules into feature-level toggles (Orchard's `[Feature]` concept). Not requested; the current "one module = one feature" is working. +- Any change to the source generator's diagnostic set. The generator stays untouched. diff --git a/framework/.allowed-projects b/framework/.allowed-projects new file mode 100644 index 00000000..b91f2315 --- /dev/null +++ b/framework/.allowed-projects @@ -0,0 +1,17 @@ +SimpleModule.AI.Anthropic +SimpleModule.AI.AzureOpenAI +SimpleModule.AI.Ollama +SimpleModule.AI.OpenAI +SimpleModule.Agents +SimpleModule.Core +SimpleModule.Database +SimpleModule.Generator +SimpleModule.Hosting +SimpleModule.Rag +SimpleModule.Rag.StructuredRag +SimpleModule.Rag.VectorStore.InMemory +SimpleModule.Rag.VectorStore.Postgres +SimpleModule.Storage +SimpleModule.Storage.Azure +SimpleModule.Storage.Local +SimpleModule.Storage.S3 diff --git a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj index 04bf8ad2..b44ca090 100644 --- a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj +++ b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj @@ -11,7 +11,7 @@ - + diff --git a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets index 86bbb11b..8e0f25bc 100644 --- a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets +++ b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets @@ -90,12 +90,12 @@ @@ -104,12 +104,12 @@ diff --git a/modules/Email/src/SimpleModule.Email/Providers/SmtpEmailProvider.cs b/modules/Email/src/SimpleModule.Email/Providers/SmtpEmailProvider.cs index 8713c507..5f889681 100644 --- a/modules/Email/src/SimpleModule.Email/Providers/SmtpEmailProvider.cs +++ b/modules/Email/src/SimpleModule.Email/Providers/SmtpEmailProvider.cs @@ -23,7 +23,7 @@ public async Task SendAsync( using var client = new SmtpClient(); await client.ConnectAsync(smtp.Host, smtp.Port, smtp.UseSsl, cancellationToken); - if (!string.IsNullOrWhiteSpace(smtp.Username)) + if (!string.IsNullOrWhiteSpace(smtp.Username) && !string.IsNullOrWhiteSpace(smtp.Password)) { await client.AuthenticateAsync(smtp.Username, smtp.Password, cancellationToken); } diff --git a/package.json b/package.json index d96f446e..a64d1616 100644 --- a/package.json +++ b/package.json @@ -15,22 +15,23 @@ "website" ], "scripts": { - "dev": "node tools/dev-orchestrator.mjs", + "dev": "node scripts/dev-orchestrator.mjs", "lint": "biome lint .", "format": "biome format --write .", - "check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run typecheck", - "typecheck": "node tools/typecheck.mjs", + "check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run validate:framework-scope && npm run typecheck", + "typecheck": "node scripts/typecheck.mjs", "check:fix": "biome check --write . && npm run validate-pages", "validate-pages": "node template/SimpleModule.Host/ClientApp/validate-pages.mjs", - "generate:types": "dotnet build template/SimpleModule.Host && node tools/extract-ts-types.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator modules", - "generate:routes": "dotnet build template/SimpleModule.Host && node tools/extract-routes.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator template/SimpleModule.Host/ClientApp/routes.ts", - "generate:i18n-keys": "node tools/generate-i18n-keys.mjs modules", - "validate:i18n": "node tools/validate-i18n.mjs modules", - "ui:add": "node tools/add-component.mjs", + "generate:types": "dotnet build template/SimpleModule.Host && node scripts/extract-ts-types.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator modules", + "generate:routes": "dotnet build template/SimpleModule.Host && node scripts/extract-routes.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator template/SimpleModule.Host/ClientApp/routes.ts", + "generate:i18n-keys": "node scripts/generate-i18n-keys.mjs modules", + "validate:framework-scope": "node scripts/validate-framework-scope.mjs", + "validate:i18n": "node scripts/validate-i18n.mjs modules", + "ui:add": "node scripts/add-component.mjs", "prebuild": "npm run generate:i18n-keys", - "build": "cross-env VITE_MODE=prod node tools/build-orchestrator.mjs", + "build": "cross-env VITE_MODE=prod node scripts/build-orchestrator.mjs", "prebuild:dev": "npm run generate:i18n-keys", - "build:dev": "cross-env VITE_MODE=dev node tools/build-orchestrator.mjs", + "build:dev": "cross-env VITE_MODE=dev node scripts/build-orchestrator.mjs", "test:e2e": "npm run test -w tests/e2e", "test:e2e:ui": "npm run test:ui -w tests/e2e", "website:dev": "npm run dev -w website", diff --git a/tools/add-component.mjs b/scripts/add-component.mjs similarity index 100% rename from tools/add-component.mjs rename to scripts/add-component.mjs diff --git a/tools/build-orchestrator.mjs b/scripts/build-orchestrator.mjs similarity index 100% rename from tools/build-orchestrator.mjs rename to scripts/build-orchestrator.mjs diff --git a/tools/dev-orchestrator.mjs b/scripts/dev-orchestrator.mjs similarity index 100% rename from tools/dev-orchestrator.mjs rename to scripts/dev-orchestrator.mjs diff --git a/tools/extract-routes.mjs b/scripts/extract-routes.mjs similarity index 94% rename from tools/extract-routes.mjs rename to scripts/extract-routes.mjs index 3cc9e0a4..cea3b0c7 100644 --- a/tools/extract-routes.mjs +++ b/scripts/extract-routes.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Extracts TypeScript route definitions from TypeScriptRoutes.g.cs -// Usage: node tools/extract-routes.mjs +// Usage: node scripts/extract-routes.mjs import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { resolve, dirname, join } from 'path'; diff --git a/tools/extract-ts-types.mjs b/scripts/extract-ts-types.mjs similarity index 95% rename from tools/extract-ts-types.mjs rename to scripts/extract-ts-types.mjs index 1b761930..fd2c5184 100644 --- a/tools/extract-ts-types.mjs +++ b/scripts/extract-ts-types.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Extracts TypeScript interfaces from per-module DtoTypeScript_*.g.cs files -// Usage: node tools/extract-ts-types.mjs +// Usage: node scripts/extract-ts-types.mjs import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { resolve, join } from 'path'; diff --git a/tools/extract-view-pages.mjs b/scripts/extract-view-pages.mjs similarity index 81% rename from tools/extract-view-pages.mjs rename to scripts/extract-view-pages.mjs index 76bd57f8..8514e4cf 100644 --- a/tools/extract-view-pages.mjs +++ b/scripts/extract-view-pages.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Extracts auto-generated Pages/index.ts from ViewPages_*.g.cs files -// Usage: node tools/extract-view-pages.mjs -// Example: node tools/extract-view-pages.mjs obj/Debug/.../ViewPages_Products.g.cs src/modules/Products/src/Products/Pages +// Usage: node scripts/extract-view-pages.mjs +// Example: node scripts/extract-view-pages.mjs obj/Debug/.../ViewPages_Products.g.cs src/modules/Products/src/Products/Pages import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, resolve } from 'path'; diff --git a/tools/generate-i18n-keys.mjs b/scripts/generate-i18n-keys.mjs similarity index 98% rename from tools/generate-i18n-keys.mjs rename to scripts/generate-i18n-keys.mjs index 736d8ef2..56803501 100644 --- a/tools/generate-i18n-keys.mjs +++ b/scripts/generate-i18n-keys.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generates TypeScript key constants from i18n en.json files. -// Usage: node tools/generate-i18n-keys.mjs [modules-dir] +// Usage: node scripts/generate-i18n-keys.mjs [modules-dir] import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; diff --git a/tools/i18n-utils.mjs b/scripts/i18n-utils.mjs similarity index 100% rename from tools/i18n-utils.mjs rename to scripts/i18n-utils.mjs diff --git a/tools/orchestrator-utils.mjs b/scripts/orchestrator-utils.mjs similarity index 100% rename from tools/orchestrator-utils.mjs rename to scripts/orchestrator-utils.mjs diff --git a/tools/typecheck.mjs b/scripts/typecheck.mjs similarity index 100% rename from tools/typecheck.mjs rename to scripts/typecheck.mjs diff --git a/scripts/validate-framework-scope.mjs b/scripts/validate-framework-scope.mjs new file mode 100755 index 00000000..f11979c4 --- /dev/null +++ b/scripts/validate-framework-scope.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +// Validates framework scope rules (see docs/CONSTITUTION.md Section 13). +// Usage: node scripts/validate-framework-scope.mjs +// +// Checks: +// 1. framework/ only contains projects listed in framework/.allowed-projects +// 2. Sub-projects under modules/{Name}/src/ match SimpleModule.{Name}.{Suffix} +// 3. Sub-projects do not declare [Module] +// 4. tools/ projects are flat-layout SimpleModule.{Name} and never declare [Module] +// and no module csproj references a tools/ project + +import { readdirSync, readFileSync, statSync } from 'fs'; +import { resolve, join } from 'path'; + +const repoRoot = resolve(new URL('..', import.meta.url).pathname); + +// Legacy sub-projects that declare [Module] in violation of the rule. +// These predate the framework scope minimization work and will be resolved +// by the phase that absorbs each module: +// - SimpleModule.Agents.Module → Phase 3 (Agents absorption) +// - SimpleModule.Rag.Module → Phase 4 (Rag absorption) +// TODO: remove entries here as each phase lands. This set must be empty +// before the framework migration is declared complete. +// See: docs/superpowers/specs/2026-04-20-framework-scope-minimization-design.md +const LEGACY_MODULE_SUBPROJECTS_GRANDFATHERED = new Set([ + 'SimpleModule.Agents.Module', + 'SimpleModule.Rag.Module', +]); + +const errors = []; + +function exists(path) { + try { + statSync(path); + return true; + } catch { + return false; + } +} + +function walkCsFiles(dir) { + const results = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === 'bin' || + entry.name === 'obj' || + entry.name === 'node_modules' + ) { + continue; + } + stack.push(full); + } else if (entry.isFile() && entry.name.endsWith('.cs')) { + results.push(full); + } + } + } + return results; +} + +function readAllowlist() { + const path = join(repoRoot, 'framework', '.allowed-projects'); + if (!exists(path)) { + errors.push( + `framework/.allowed-projects is missing. Create it and list the ` + + `projects currently under framework/ (one per line).`, + ); + return []; + } + return readFileSync(path, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +function checkFrameworkAllowlist() { + const allowed = new Set(readAllowlist()); + const frameworkDir = join(repoRoot, 'framework'); + const entries = readdirSync(frameworkDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!allowed.has(entry.name)) { + errors.push( + `framework/${entry.name} is not in framework/.allowed-projects. ` + + `Add it with reviewer approval, or move it out of framework/.`, + ); + } + } +} + +function checkSubProjectNaming() { + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + for (const moduleEntry of moduleDirs) { + const moduleName = moduleEntry.name; + const srcDir = join(modulesDir, moduleName, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + const expectedPrefix = `SimpleModule.${moduleName}`; + for (const projectEntry of projectDirs) { + const name = projectEntry.name; + // Must match SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.{Suffix} + if (name !== expectedPrefix && !name.startsWith(`${expectedPrefix}.`)) { + errors.push( + `modules/${moduleName}/src/${name}/ does not match required ` + + `pattern '${expectedPrefix}[.*]'. Sub-projects must be named ` + + `'${expectedPrefix}.{Suffix}'.`, + ); + } + } + } +} + +function checkSubProjectNoModuleAttribute() { + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + for (const moduleEntry of moduleDirs) { + const moduleName = moduleEntry.name; + const srcDir = join(modulesDir, moduleName, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + for (const projectEntry of projectDirs) { + const name = projectEntry.name; + const isMain = name === `SimpleModule.${moduleName}`; + const isContracts = name === `SimpleModule.${moduleName}.Contracts`; + if (isMain || isContracts) continue; + if (LEGACY_MODULE_SUBPROJECTS_GRANDFATHERED.has(name)) continue; + // This is a sub-project. Scan its .cs files for [Module( + const files = walkCsFiles(join(srcDir, name)); + for (const file of files) { + const content = readFileSync(file, 'utf8'); + if (/\[\s*Module\s*\(/.test(content)) { + errors.push( + `Sub-project ${name} declares [Module] in ${file.substring(repoRoot.length + 1)}. ` + + `Only the main module assembly (SimpleModule.${moduleName}) may declare [Module].`, + ); + } + } + } + } +} + +function checkToolsLayering() { + const toolsDir = join(repoRoot, 'tools'); + if (exists(toolsDir)) { + const entries = readdirSync(toolsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith('SimpleModule.')) { + errors.push( + `tools/${entry.name}/ does not match required naming 'SimpleModule.{Name}'.`, + ); + continue; + } + const toolPath = join(toolsDir, entry.name); + const files = walkCsFiles(toolPath); + for (const file of files) { + const content = readFileSync(file, 'utf8'); + if (/\[\s*Module\s*\(/.test(content)) { + errors.push( + `tools/${entry.name} declares [Module] in ${file.substring(repoRoot.length + 1)}. ` + + `Tools are not modules and must not declare [Module].`, + ); + } + } + } + } + // Check no module csproj references a tools/ project. + const modulesDir = join(repoRoot, 'modules'); + if (!exists(modulesDir)) return; + const moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + for (const moduleEntry of moduleDirs) { + const srcDir = join(modulesDir, moduleEntry.name, 'src'); + if (!exists(srcDir)) continue; + const projectDirs = readdirSync(srcDir, { withFileTypes: true }).filter( + (e) => e.isDirectory(), + ); + for (const projectEntry of projectDirs) { + const csprojPath = join( + srcDir, + projectEntry.name, + `${projectEntry.name}.csproj`, + ); + if (!exists(csprojPath)) continue; + const content = readFileSync(csprojPath, 'utf8'); + // Match ProjectReference paths containing tools/ or tools\ + if (/ProjectReference[^>]*Include="[^"]*[\\/]tools[\\/]/.test(content)) { + errors.push( + `${csprojPath.substring(repoRoot.length + 1)} references a tools/ project. ` + + `Modules may not depend on tools/ — tools are for host/framework only.`, + ); + } + } + } +} + +checkFrameworkAllowlist(); +checkSubProjectNaming(); +checkSubProjectNoModuleAttribute(); +checkToolsLayering(); + +if (errors.length > 0) { + console.error('Framework scope validation failed:\n'); + for (const err of errors) console.error(` ✗ ${err}`); + process.exit(1); +} + +console.log('✓ Framework scope validation passed'); diff --git a/tools/validate-i18n.mjs b/scripts/validate-i18n.mjs similarity index 98% rename from tools/validate-i18n.mjs rename to scripts/validate-i18n.mjs index 47e1917b..54196cee 100644 --- a/tools/validate-i18n.mjs +++ b/scripts/validate-i18n.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Validates i18n locale files across modules. -// Usage: node tools/validate-i18n.mjs [modules-dir] +// Usage: node scripts/validate-i18n.mjs [modules-dir] // // Checks: // 1. Every module with Locales/ has an en.json (base locale) diff --git a/tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj b/tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj index 88436caf..911e3536 100644 --- a/tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj +++ b/tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj @@ -14,6 +14,6 @@ - + diff --git a/framework/SimpleModule.DevTools/DevToolsConstants.cs b/tools/SimpleModule.DevTools/DevToolsConstants.cs similarity index 100% rename from framework/SimpleModule.DevTools/DevToolsConstants.cs rename to tools/SimpleModule.DevTools/DevToolsConstants.cs diff --git a/framework/SimpleModule.DevTools/DevToolsExtensions.cs b/tools/SimpleModule.DevTools/DevToolsExtensions.cs similarity index 100% rename from framework/SimpleModule.DevTools/DevToolsExtensions.cs rename to tools/SimpleModule.DevTools/DevToolsExtensions.cs diff --git a/framework/SimpleModule.DevTools/LiveReloadServer.cs b/tools/SimpleModule.DevTools/LiveReloadServer.cs similarity index 100% rename from framework/SimpleModule.DevTools/LiveReloadServer.cs rename to tools/SimpleModule.DevTools/LiveReloadServer.cs diff --git a/framework/SimpleModule.DevTools/README.md b/tools/SimpleModule.DevTools/README.md similarity index 100% rename from framework/SimpleModule.DevTools/README.md rename to tools/SimpleModule.DevTools/README.md diff --git a/framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj b/tools/SimpleModule.DevTools/SimpleModule.DevTools.csproj similarity index 100% rename from framework/SimpleModule.DevTools/SimpleModule.DevTools.csproj rename to tools/SimpleModule.DevTools/SimpleModule.DevTools.csproj diff --git a/framework/SimpleModule.DevTools/ViteDevMiddleware.cs b/tools/SimpleModule.DevTools/ViteDevMiddleware.cs similarity index 100% rename from framework/SimpleModule.DevTools/ViteDevMiddleware.cs rename to tools/SimpleModule.DevTools/ViteDevMiddleware.cs diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs b/tools/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs similarity index 100% rename from framework/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs rename to tools/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Logging.cs b/tools/SimpleModule.DevTools/ViteDevWatchService.Logging.cs similarity index 100% rename from framework/SimpleModule.DevTools/ViteDevWatchService.Logging.cs rename to tools/SimpleModule.DevTools/ViteDevWatchService.Logging.cs diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Process.cs b/tools/SimpleModule.DevTools/ViteDevWatchService.Process.cs similarity index 100% rename from framework/SimpleModule.DevTools/ViteDevWatchService.Process.cs rename to tools/SimpleModule.DevTools/ViteDevWatchService.Process.cs diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.cs b/tools/SimpleModule.DevTools/ViteDevWatchService.cs similarity index 100% rename from framework/SimpleModule.DevTools/ViteDevWatchService.cs rename to tools/SimpleModule.DevTools/ViteDevWatchService.cs