From 76cf2baf9f61b9b374087ebcada4a0560554ad1a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Fri, 9 Oct 2026 22:54:37 -0500 Subject: [PATCH] chore: replace em and en dashes with ASCII throughout An em dash cannot be typed on a standard keyboard without a numeric-alt or IME sequence, so every occurrence was machine-written rather than authored. They also propagate by copy, since the preset artifacts are copied between packages. Replacement is context-aware rather than a blind substitution: | Pattern | Becomes | |---|---| | Markdown heading or bold label followed by a dash | a colon | | Anything else mid-sentence | a spaced ASCII hyphen | | Numeric ranges | a bare hyphen | Documentation and comment text, plus two console status strings that are noted separately. Signed-off-by: Joey Smith Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 +- .github/copilot-instructions.md | 18 ++-- .github/skills/speckit-analyze/SKILL.md | 4 +- .github/skills/speckit-checklist/SKILL.md | 4 +- .github/skills/speckit-clarify/SKILL.md | 12 +-- .github/skills/speckit-constitution/SKILL.md | 2 +- .github/skills/speckit-converge/SKILL.md | 18 ++-- .github/skills/speckit-implement/SKILL.md | 2 +- .github/skills/speckit-plan/SKILL.md | 2 +- .github/skills/speckit-specify/SKILL.md | 10 +- .github/skills/speckit-tasks/SKILL.md | 2 +- .github/skills/speckit-taskstoissues/SKILL.md | 2 +- Dockerfile | 2 +- compose.yml | 10 +- docs/dashboard-widget-system.md | 52 +++++------ plan/refactor-acl-decoupling-1.md | 92 +++++++++---------- src/Container/Configuration.php | 4 +- src/WidgetInterface.php | 2 +- 18 files changed, 120 insertions(+), 120 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 90bff64..94cd7e1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // Webware alignment Dev Container. // -// This is a thin wrapper around compose.yml — it reuses the `tooling` service +// This is a thin wrapper around compose.yml - it reuses the `tooling` service // rather than defining a parallel environment, so VS Code and plain // `docker compose` users share the exact same container. Non-VS Code // developers are not orphaned: they use `docker compose up -d` + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ae992ee..79276f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,12 +1,12 @@ -# Webware Admin — Copilot Agent Instructions +# Webware Admin: Copilot Agent Instructions -## Read this before acting — fleet working agreements +## Read this before acting: fleet working agreements `vendor/webware/webware-tools/agent-working-agreements.md` is authoritative for how work is done in this fleet, and it overrides anything else in this file or in any local note. Read it before acting, and again before reporting. The non-negotiables: -- Do exactly the scope asked — only the files, branches and repositories named. No adjacent edits, +- Do exactly the scope asked - only the files, branches and repositories named. No adjacent edits, no cleanup, no onboarding, no surveys. - Anything else you notice gets one line at most, with no proposed fix. - Never close a reply with a condensed restatement of it, and never present an inference as a @@ -21,22 +21,22 @@ PHPUnit 13 enforces a strict separation between mocks and stubs. Violating these - **Use `createStub()`** when the test double only needs to return values (`method()->willReturn()`, `method()->willReturnCallback()`). No expectations are configured. - **Use `createMock()`** only when the test verifies behavior with `expects()` (e.g. `expects($this->once())`, `expects($this->never())`). -- **Never** call `createMock()` without also calling `expects()` on at least one method — PHPUnit 13 will issue a notice. +- **Never** call `createMock()` without also calling `expects()` on at least one method - PHPUnit 13 will issue a notice. - **Remove** `/** @var ClassName&MockObject */` annotations and `MockObject` intersection types on variables created with `createStub()`. - **Remove** the `use PHPUnit\Framework\MockObject\MockObject;` import from any file where no `createMock()` + `expects()` usage remains. ### Examples ```php -// CORRECT — stub returns value, no expectations +// CORRECT - stub returns value, no expectations $container = $this->createStub(ContainerInterface::class); $container->method('get')->willReturnCallback(static fn(string $id): mixed => ...); -// CORRECT — mock verifies behavior with expects() +// CORRECT - mock verifies behavior with expects() $handler = $this->createMock(ErrorHandler::class); $handler->expects($this->once())->method('attachListener'); -// WRONG — createMock() with no expects() triggers PHPUnit notice +// WRONG - createMock() with no expects() triggers PHPUnit notice $logger = $this->createMock(Logger::class); $logger->method('withName')->willReturn($logger); // should be createStub() ``` @@ -45,6 +45,6 @@ $logger->method('withName')->willReturn($logger); // should be createStub() `phpunit.xml.dist` has `requireCoverageMetadata="true"`. Every test class **must** have: -1. `#[CoversClass(ClassName::class)]` — one per source class under test. -2. `#[CoversMethod(ClassName::class, 'methodName')]` — one per public/protected method exercised. +1. `#[CoversClass(ClassName::class)]` - one per source class under test. +2. `#[CoversMethod(ClassName::class, 'methodName')]` - one per public/protected method exercised. 3. `use PHPUnit\Framework\Attributes\CoversClass;` and `use PHPUnit\Framework\Attributes\CoversMethod;` imports. diff --git a/.github/skills/speckit-analyze/SKILL.md b/.github/skills/speckit-analyze/SKILL.md index 8b2016d..da43f0e 100644 --- a/.github/skills/speckit-analyze/SKILL.md +++ b/.github/skills/speckit-analyze/SKILL.md @@ -60,7 +60,7 @@ Identify inconsistencies, duplications, ambiguities, and underspecified items ac **STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). -**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`. +**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks - not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`. ## Execution Steps @@ -83,7 +83,7 @@ Load only the minimal necessary context from each artifact: - Overview/Context - Functional Requirements -- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact) +- Success Criteria (measurable outcomes - e.g., performance, security, availability, user success, business impact) - User Stories - Edge Cases (if present) diff --git a/.github/skills/speckit-checklist/SKILL.md b/.github/skills/speckit-checklist/SKILL.md index 2285e3c..752fe7a 100644 --- a/.github/skills/speckit-checklist/SKILL.md +++ b/.github/skills/speckit-checklist/SKILL.md @@ -107,11 +107,11 @@ You **MUST** consider the user input before proceeding (if not empty). - Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?") - Audience framing (e.g., "Will this be used by the author only or peers during PR review?") - Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?") - - Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?") + - Scenario class gap (e.g., "No recovery flows detected - are rollback / partial failure paths in scope?") Question formatting rules: - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters - - Limit to A–E options maximum; omit table if a free-form answer is clearer + - Limit to A - E options maximum; omit table if a free-form answer is clearer - Never ask the user to restate what they already said - Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope." diff --git a/.github/skills/speckit-clarify/SKILL.md b/.github/skills/speckit-clarify/SKILL.md index b445bda..49b3590 100644 --- a/.github/skills/speckit-clarify/SKILL.md +++ b/.github/skills/speckit-clarify/SKILL.md @@ -128,7 +128,7 @@ Execution steps: 4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: - Maximum of 5 total questions across the whole session. - Each question must be answerable with EITHER: - - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR + - A short multiple‑choice selection (2-5 distinct, mutually exclusive options), OR - A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). - Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation. - Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved. @@ -140,7 +140,7 @@ Execution steps: - Present EXACTLY ONE question at a time. - **Question writing quality (applies to every question, MC or short-answer):** - Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own. - - NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question. + - NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID - it is a label, not a question. - After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** ?` or `**Question:** ? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt. - Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options. - Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not. @@ -211,14 +211,14 @@ Execution steps: - If it does NOT exist, skip this step silently. - If it exists: 1. Read the checklist file. - 2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata). + 2. Identify all GitHub task-list checkbox lines - lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata). 3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list. 4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7). 5. For each checkbox item, update only if the checked/unchecked state actually changes: - If the item now passes and was unchecked: change `[ ]` to `[x]`. - If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`. - If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs). - 6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs. + 6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content - headings, metadata, notes, line ordering, whitespace - must remain unchanged to avoid noisy diffs. 7. Compare the before-snapshot with the current state to compute three lists for the Completion Report: - **Newly passing**: items that changed from unchecked to checked. - **Regressions**: items that changed from checked to unchecked. @@ -251,7 +251,7 @@ Check if `.specify/extensions.yml` exists in the project root. - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`. - For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: + - **Mandatory hook** (`optional: false`) - **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks @@ -278,7 +278,7 @@ Report completion (after questioning loop ends or early termination): - Number of questions asked & answered. - Path to updated spec. - Sections touched (list names). -- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention. +- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state - both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention. - Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact). - If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan. - Suggested next command. diff --git a/.github/skills/speckit-constitution/SKILL.md b/.github/skills/speckit-constitution/SKILL.md index 6a37619..04eef81 100644 --- a/.github/skills/speckit-constitution/SKILL.md +++ b/.github/skills/speckit-constitution/SKILL.md @@ -103,7 +103,7 @@ Follow this execution flow: - If version bump type ambiguous, propose reasoning before finalizing. 3. Draft the updated constitution content using the resolved template as the required structure: - - Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left). + - Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet - explicitly justify any left). - Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance. - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. - Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations. diff --git a/.github/skills/speckit-converge/SKILL.md b/.github/skills/speckit-converge/SKILL.md index b3397b5..0e3c02b 100644 --- a/.github/skills/speckit-converge/SKILL.md +++ b/.github/skills/speckit-converge/SKILL.md @@ -69,7 +69,7 @@ of remaining work as a new, traceable task** at the bottom of `tasks.md` so that `/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`. This is **not** a diff tool and does **not** track changes. It assesses the present state -of the code relative to the feature's artifacts — no git, no branch comparison, no history. +of the code relative to the feature's artifacts - no git, no branch comparison, no history. ## Operating Constraints @@ -79,7 +79,7 @@ of the code relative to the feature's artifacts — no git, no branch comparison - modify `spec.md` or `plan.md` in any way; - rewrite, renumber, reorder, or delete any existing task (including tasks from a prior Convergence phase); -- modify, create, or delete any application code — completing the appended tasks is the +- modify, create, or delete any application code - completing the appended tasks is the job of `/speckit-implement`. When the codebase already satisfies everything, the command MUST leave `tasks.md` @@ -112,7 +112,7 @@ Load only the minimal necessary context from each artifact: **From spec.md:** - Functional Requirements (FR-###) -- Success Criteria (SC-###) — include only items requiring buildable work; exclude +- Success Criteria (SC-###) - include only items requiring buildable work; exclude post-launch outcome metrics and business KPIs - User Stories and their Acceptance Scenarios - Edge Cases (if present) @@ -142,7 +142,7 @@ Create an internal model (do not echo raw artifacts): impose buildable obligations. - **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword search for the concepts each requirement describes, derive the set of source files and - components in scope for assessment. Bound the assessment to these — do **not** infer + components in scope for assessment. Bound the assessment to these - do **not** infer scope beyond what the artifacts define. ### 4. Assess the Codebase and Classify Findings @@ -156,7 +156,7 @@ For each item in the intent inventory, inspect the current code in scope and pro - **`contradicts`**: the code does something that conflicts with stated intent or a constitution MUST principle. - **`unrequested`**: the code contains work not called for by the spec, plan, or tasks - (surfaced for awareness — converge does **not** delete code, it only appends a task to + (surfaced for awareness - converge does **not** delete code, it only appends a task to review/justify or remove it). Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a @@ -192,7 +192,7 @@ Before appending anything, output a compact, severity-graded summary (no file wr - Requirements / acceptance criteria checked - Plan decisions checked -- Constitution principles checked (or "skipped — template") +- Constitution principles checked (or "skipped - template") - Findings by gap type (missing / partial / contradicts / unrequested) - Findings by severity @@ -220,12 +220,12 @@ Append to the **end** of `tasks.md`, per the append contract: Constitution-violation tasks MUST be emitted first and described as `CRITICAL`. 4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new, - separately-numbered one below it — do not touch the old one. + separately-numbered one below it - do not touch the old one. **If there are no actionable findings** (`converged` outcome): -- Do **not** modify `tasks.md` at all — no empty phase header. -- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."** +- Do **not** modify `tasks.md` at all - no empty phase header. +- Report: **"✅ Converged - the implementation satisfies the spec, plan, and tasks."** - Include the summary counts of what was checked. ### 8. Provide Next Actions (Handoff) diff --git a/.github/skills/speckit-implement/SKILL.md b/.github/skills/speckit-implement/SKILL.md index 0d6a7a7..8f5724d 100644 --- a/.github/skills/speckit-implement/SKILL.md +++ b/.github/skills/speckit-implement/SKILL.md @@ -193,7 +193,7 @@ Check if `.specify/extensions.yml` exists in the project root. - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`. - For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: + - **Mandatory hook** (`optional: false`) - **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks diff --git a/.github/skills/speckit-plan/SKILL.md b/.github/skills/speckit-plan/SKILL.md index 972fded..c525cfc 100644 --- a/.github/skills/speckit-plan/SKILL.md +++ b/.github/skills/speckit-plan/SKILL.md @@ -80,7 +80,7 @@ Check if `.specify/extensions.yml` exists in the project root. - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`. - For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: + - **Mandatory hook** (`optional: false`) - **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks diff --git a/.github/skills/speckit-specify/SKILL.md b/.github/skills/speckit-specify/SKILL.md index 6f6dd9d..e330b69 100644 --- a/.github/skills/speckit-specify/SKILL.md +++ b/.github/skills/speckit-specify/SKILL.md @@ -83,7 +83,7 @@ Given that feature description, do this: **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is 2. Otherwise, auto-generate it under `specs/`: - - Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release) + - Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only - will be removed in a future release) - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) @@ -106,7 +106,7 @@ Given that feature description, do this: **IMPORTANT**: - You must only create one feature per `/speckit-specify` invocation - - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice + - The spec directory name and the git branch name are independent - they may be the same but that is the user's choice - The spec directory and file are always created by this command, never by the hook 4. Load the resolved active `spec-template` file to understand required sections. @@ -246,7 +246,7 @@ Check if `.specify/extensions.yml` exists in the project root. - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`. - For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: + - **Mandatory hook** (`optional: false`) - **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks @@ -270,8 +270,8 @@ Check if `.specify/extensions.yml` exists in the project root. ## Completion Report Report completion to the user with: -- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path -- `SPEC_FILE` — the spec file path +- `SPECIFY_FEATURE_DIRECTORY` - the feature directory path +- `SPEC_FILE` - the spec file path - Checklist results summary - Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`) diff --git a/.github/skills/speckit-tasks/SKILL.md b/.github/skills/speckit-tasks/SKILL.md index d6888df..2be6dc2 100644 --- a/.github/skills/speckit-tasks/SKILL.md +++ b/.github/skills/speckit-tasks/SKILL.md @@ -100,7 +100,7 @@ Check if `.specify/extensions.yml` exists in the project root. - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`. - For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: + - **Mandatory hook** (`optional: false`) - **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks diff --git a/.github/skills/speckit-taskstoissues/SKILL.md b/.github/skills/speckit-taskstoissues/SKILL.md index 0612de3..ca6fdca 100644 --- a/.github/skills/speckit-taskstoissues/SKILL.md +++ b/.github/skills/speckit-taskstoissues/SKILL.md @@ -66,7 +66,7 @@ git config --get remote.origin.url > [!CAUTION] > ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL -1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. +1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` - `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs - with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. 1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: `, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`). - **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`). - Only create issues for tasks that do not yet have a matching issue. diff --git a/Dockerfile b/Dockerfile index 7e9cea2..4ff0c47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # # Builds a self-contained, interactive PHP development environment carrying # the Webware toolchain: PHP CLI, Composer, Mago, and Xdebug. Developers work -# inside this container — either through the VS Code Dev Container (backed by +# inside this container: either through the VS Code Dev Container (backed by # compose.yml) or by `docker compose up -d` + `docker compose exec tooling`. # The host machine (Windows, WSL, Linux, or macOS) never needs a native PHP # toolchain. diff --git a/compose.yml b/compose.yml index 1158529..1ef419b 100644 --- a/compose.yml +++ b/compose.yml @@ -9,7 +9,7 @@ # This compose file is the single source of truth for the environment. Two # ways to use it: # -# 1. VS Code Dev Container — "Reopen in Container" uses +# 1. VS Code Dev Container: "Reopen in Container" uses # .devcontainer/devcontainer.json, which is *backed by* this file (it # references the `tooling` service rather than defining its own). # @@ -24,7 +24,7 @@ # Packages whose tests need MySQL uncomment the `mysql` service below (and the # `phpmyadmin` web UI if wanted), plus the `depends_on` block on `tooling`. The # `mysql` service mirrors the `db-image` package parameter used by CI, and is -# reachable from `tooling` as host `mysql` on port 3306 — matching the PhpDb +# reachable from `tooling` as host `mysql` on port 3306: matching the PhpDb # `mysql.local.php` connection convention. Packages that need a different # engine add an equivalent service here (postgres, mariadb, ...) mirroring # their own `db-image` parameter. @@ -59,7 +59,7 @@ services: # container. vendor/ is part of that bind mount: the container is only # a test runner, so the developer's host copy of vendor/ (populated by # `composer install` run either on the host or in the container) is the - # single source of truth. Never override vendor/ with a named volume — + # single source of truth. Never override vendor/ with a named volume - # a named volume hides it from the host and forces Composer I/O through # the Docker Desktop VM layer. - .:/app @@ -74,7 +74,7 @@ services: working_dir: /app # ------------------------------------------------------------------------- - # MySQL (optional) — uncomment this whole block for packages whose tests + # MySQL (optional) - uncomment this whole block for packages whose tests # need a MySQL database (e.g. webware-acl via php-db/phpdb-mysql). # # Mirror the spec's `db-image` / `db-env-json` / `db-port` Package Parameters @@ -100,7 +100,7 @@ services: # # survives `docker compose down` (use `down -v` to wipe it). # - mysql-data:/var/lib/mysql # - # # phpMyAdmin — optional web UI for the `mysql` service above. Reachable at + # # phpMyAdmin - optional web UI for the `mysql` service above. Reachable at # # http://localhost:8080 (log in with the MYSQL_* credentials). Uncomment # # alongside `mysql`. # phpmyadmin: diff --git a/docs/dashboard-widget-system.md b/docs/dashboard-widget-system.md index e156982..18bb7c0 100644 --- a/docs/dashboard-widget-system.md +++ b/docs/dashboard-widget-system.md @@ -10,7 +10,7 @@ tags: [admin, dashboard, widget, psr-14, acl, mezzio] # Admin Dashboard Widget System -A PSR-14 event-driven system that allows any module in the application to contribute widgets to the admin dashboard. Access to each widget is controlled by the ACL. Modules remain fully decoupled — they register a PSR-14 listener; the admin module has no knowledge of them. +A PSR-14 event-driven system that allows any module in the application to contribute widgets to the admin dashboard. Access to each widget is controlled by the ACL. Modules remain fully decoupled - they register a PSR-14 listener; the admin module has no knowledge of them. ## 1. Component Overview @@ -18,18 +18,18 @@ A PSR-14 event-driven system that allows any module in the application to contri - Provide a standard contract (`WidgetInterface`) that any module can implement to expose an admin dashboard widget. - Dispatch a mutable `RegisterWidgetEvent` from request middleware so that registered listeners can contribute widgets. -- Filter the collected widgets through the ACL before the dashboard handler ever sees them — widgets the current user cannot access are invisible. +- Filter the collected widgets through the ACL before the dashboard handler ever sees them - widgets the current user cannot access are invisible. - Allow each module to own its widget's visual rendering by providing a namespaced template path. ### Scope **Included:** - `WidgetInterface` contract (PHP 8.4 get-hooked properties) -- `WidgetContainer` — typed collection enforcing that only `WidgetInterface` instances can be filtered -- `RegisterWidgetEvent` — mutable PSR-14 event; listeners call `registerWidget()` on it -- `DashboardMiddleware` — dispatches the event, filters, sets request attribute -- `AclWidgetFilterIterator` — PHP `FilterIterator` wrapping the ACL check -- `DashboardHandler` — reads the filtered iterator and renders the dashboard template +- `WidgetContainer` - typed collection enforcing that only `WidgetInterface` instances can be filtered +- `RegisterWidgetEvent` - mutable PSR-14 event; listeners call `registerWidget()` on it +- `DashboardMiddleware` - dispatches the event, filters, sets request attribute +- `AclWidgetFilterIterator` - PHP `FilterIterator` wrapping the ACL check +- `DashboardHandler` - reads the filtered iterator and renders the dashboard template **Excluded:** - Concrete widget implementations (each module provides its own) @@ -44,10 +44,10 @@ A PSR-14 event-driven system that allows any module in the application to contri | Pattern | Where used | |---|---| -| **Collect Event** (mutable PSR-14 event) | `RegisterWidgetEvent` — listeners push data in | -| **Chain of Responsibility** | Middleware pipeline — `DashboardMiddleware` → `DashboardHandler` | +| **Collect Event** (mutable PSR-14 event) | `RegisterWidgetEvent` - listeners push data in | +| **Chain of Responsibility** | Middleware pipeline - `DashboardMiddleware` → `DashboardHandler` | | **IteratorAggregate / FilterIterator** | `WidgetContainer` yields a fresh `ArrayIterator`; `AclWidgetFilterIterator` filters it | -| **Type Safety by Construction** | `WidgetContainer::addWidget(WidgetInterface)` is the only mutation path — no `instanceof` guards needed downstream | +| **Type Safety by Construction** | `WidgetContainer::addWidget(WidgetInterface)` is the only mutation path - no `instanceof` guards needed downstream | | **Interface Segregation** | `WidgetInterface` extends `Laminas\Permissions\Acl\Resource\ResourceInterface` | | **Factory** | Every service has a corresponding `*Factory` in `Container/` | @@ -147,30 +147,30 @@ Extends `Laminas\Permissions\Acl\Resource\ResourceInterface` so widgets can be p | Property / Method | Type | Description | |---|---|---| | `$title` | `string` | Display title shown in the widget header | -| `$resourceId` | `string` | ACL resource identifier — must match an ACL resource registered by the module | -| `$privilege` | `string` | ACL privilege required — e.g. `'view'`, `'list'` | +| `$resourceId` | `string` | ACL resource identifier - must match an ACL resource registered by the module | +| `$privilege` | `string` | ACL privilege required - e.g. `'view'`, `'list'` | | `$template` | `string` | Namespaced template pair, e.g. `'product::admin-widget'` | -| `$order` | `int` | Sort position — lower values appear first | +| `$order` | `int` | Sort position - lower values appear first | | `getResourceId()` | `string` | Required by `ResourceInterface`; return `$this->resourceId` | -All properties must be declared with a `get` hook — PHP 8.4 asymmetric visibility. +All properties must be declared with a `get` hook - PHP 8.4 asymmetric visibility. ### `WidgetContainer` -A typed collection that can only ever hold `WidgetInterface` instances. Its constructor (optionally accepting one widget) and `addWidget()` are the only mutation paths, both typed — so downstream code never needs an `instanceof` guard. +A typed collection that can only ever hold `WidgetInterface` instances. Its constructor (optionally accepting one widget) and `addWidget()` are the only mutation paths, both typed - so downstream code never needs an `instanceof` guard. | Method | Parameters | Returns | Description | |---|---|---|---| -| `__construct()` | `?WidgetInterface $widget` | — | Seeds the container with one widget, if given | +| `__construct()` | `?WidgetInterface $widget` | - | Seeds the container with one widget, if given | | `addWidget()` | `WidgetInterface $widget` | `void` | Adds a widget, keeping the container sorted ascending by `$order` | -| `getIterator()` | — | `Iterator` | Returns a fresh `ArrayIterator` over the sorted widgets | +| `getIterator()` | - | `Iterator` | Returns a fresh `ArrayIterator` over the sorted widgets | ### `RegisterWidgetEvent` | Method | Parameters | Returns | Description | |---|---|---|---| | `registerWidget()` | `WidgetInterface $widget` | `void` | Delegates to the container's `addWidget()`; called by module listeners | -| `getWidgetContainer()` | — | `WidgetContainer` | Returns the event's widget container | +| `getWidgetContainer()` | - | `WidgetContainer` | Returns the event's widget container | ### `DashboardMiddleware` @@ -284,22 +284,22 @@ $app->get('/admin', [ ## 5. Quality Attributes ### Security -- Widget visibility is enforced by the ACL before the handler renders — a module widget is never sent to the template if the current user lacks the required resource/privilege. Filtering fails closed: without an authenticated, role-aware user, no widgets are shown. +- Widget visibility is enforced by the ACL before the handler renders - a module widget is never sent to the template if the current user lacks the required resource/privilege. Filtering fails closed: without an authenticated, role-aware user, no widgets are shown. - `WidgetInterface` extends `ResourceInterface`, and the iterator passes each widget's `resourceId`/`privilege` directly to `isAllowed()` without string coercion. -- Type safety is structural: `WidgetContainer` accepts only `WidgetInterface` instances via its constructor and `addWidget()`, and there is no other way to mutate its state — the filter cannot receive a non-widget item. +- Type safety is structural: `WidgetContainer` accepts only `WidgetInterface` instances via its constructor and `addWidget()`, and there is no other way to mutate its state - the filter cannot receive a non-widget item. ### Performance -- The iterator is lazy — `FilterIterator::accept()` is called only when the template iterates; no up-front array construction of filtered results. +- The iterator is lazy - `FilterIterator::accept()` is called only when the template iterates; no up-front array construction of filtered results. - `WidgetContainer::addWidget()` performs one `usort` per insertion (widget counts are small), so `getIterator()` is a plain, cheap `ArrayIterator` copy. ### Extensibility - Any module can contribute widgets by registering a single PSR-14 listener. No modification to `webware-admin` is required. -- Widget ordering is controlled by each widget's `$order` value — no central registration or priority numbering. +- Widget ordering is controlled by each widget's `$order` value - no central registration or priority numbering. - Each widget fully owns its template; the dashboard template is layout-only. ### Maintainability -- `DashboardMiddleware` has two injected dependencies (`EventDispatcherInterface`, `AclInterface`) — both are interfaces, fully testable with mocks. -- `AclWidgetFilterIterator` delegates all ACL logic to `AclInterface::isAllowed()` — no ACL implementation leaks into the widget layer. +- `DashboardMiddleware` has two injected dependencies (`EventDispatcherInterface`, `AclInterface`) - both are interfaces, fully testable with mocks. +- `AclWidgetFilterIterator` delegates all ACL logic to `AclInterface::isAllowed()` - no ACL implementation leaks into the widget layer. --- @@ -352,5 +352,5 @@ The consuming application MUST provide a `Laminas\Permissions\Acl\AclInterface` ### Related Documentation -- `webware-acl` package documentation — ACL resource and privilege registration -- `docs/planning/` — implementation plan phases +- `webware-acl` package documentation - ACL resource and privilege registration +- `docs/planning/` - implementation plan phases diff --git a/plan/refactor-acl-decoupling-1.md b/plan/refactor-acl-decoupling-1.md index 0c1c819..831edef 100644 --- a/plan/refactor-acl-decoupling-1.md +++ b/plan/refactor-acl-decoupling-1.md @@ -26,11 +26,11 @@ Full design context: - **REQ-001**: No `Webware\Acl\*` reference remains anywhere in webware-admin (src, test, composer metadata). - **REQ-002**: `webware/webware-acl` moves from `require` to `suggest` in admin's composer.json; its VCS repository entry is removed. -- **REQ-003**: Dashboard widget filtering MUST stay in admin. The dashboard can never render unfiltered — the pipeline fails closed: no authenticated role-aware user ⇒ zero widgets; no `AclInterface` service in the container ⇒ container resolution fails ⇒ dashboard errors. +- **REQ-003**: Dashboard widget filtering MUST stay in admin. The dashboard can never render unfiltered - the pipeline fails closed: no authenticated role-aware user ⇒ zero widgets; no `AclInterface` service in the container ⇒ container resolution fails ⇒ dashboard errors. - **REQ-004**: Widgets MUST be filtered in every environment, including tests. Integration tests exercise the real filter path using a real `Laminas\Permissions\Acl\Acl` instance, with no database requirement. -- **REQ-005**: Only `WidgetInterface` instances may reach `AclWidgetFilterIterator` — enforced structurally: `WidgetContainer` accepts widgets solely through its typed constructor and `addWidget()`, and there is no other mutation path. -- **REQ-006**: Widget system classes live in the component root namespace (`Webware\Admin\WidgetInterface`, `Webware\Admin\WidgetContainer`, `Webware\Admin\AclWidgetFilterIterator`) — no `Widget\` sub-namespace duplicating the component name. -- **CON-001**: `laminas/laminas-permissions-acl: ^2.16` is already a direct admin dependency — the runtime decoupling seam adds no new packages. +- **REQ-005**: Only `WidgetInterface` instances may reach `AclWidgetFilterIterator` - enforced structurally: `WidgetContainer` accepts widgets solely through its typed constructor and `addWidget()`, and there is no other mutation path. +- **REQ-006**: Widget system classes live in the component root namespace (`Webware\Admin\WidgetInterface`, `Webware\Admin\WidgetContainer`, `Webware\Admin\AclWidgetFilterIterator`) - no `Widget\` sub-namespace duplicating the component name. +- **CON-001**: `laminas/laminas-permissions-acl: ^2.16` is already a direct admin dependency - the runtime decoupling seam adds no new packages. - **CON-002**: `Webware\UserManager\UserInterface` is dropped from admin; the user is read from the standard `Mezzio\Authentication\UserInterface::class` request attribute (`mezzio/mezzio-authentication: ^1.13` is already a direct dependency). For PHPUnit testing without a database, `mezzio/mezzio-authentication-session` is added as a `require-dev` package (session-backed authentication adapter). - **CON-003**: PHP `~8.4.1 || ~8.5.0`; PHPUnit 13 strict mode (coverage metadata, mock/stub separation) per the webware constitution. - **CON-004**: acl→admin is already event-decoupled (`RegisterWidgetEvent` + listener); this refactor only removes the admin→acl hard references. @@ -38,29 +38,29 @@ Full design context: ## 2. Implementation Steps -### Implementation Phase 1 — Admin composer metadata +### Implementation Phase 1: Admin composer metadata - GOAL-001: Remove the hard composer dependency so admin resolves without acl. | Task | Description | Completed | Date | |------|-------------|-----------|------| | TASK-001 | `composer.json`: move `"webware/webware-acl": "0.1.x-dev"` from `require` (line 34) to `suggest` with the description `"Provides the ACL implementation and ACL configuration consumed by the dashboard widget filtering, plus the ACL management UI."`. | ✅ | 2026-08-23 | -| TASK-002 | `composer.json`: remove the `webware/webware-acl` VCS entry from `repositories` (lines 62–65). | ✅ | 2026-08-23 | +| TASK-002 | `composer.json`: remove the `webware/webware-acl` VCS entry from `repositories` (lines 62-65). | ✅ | 2026-08-23 | | TASK-003 | `composer.json`: add `mezzio/mezzio-authentication-session: ^1.11` to `require-dev` (CON-002). Regenerate `composer.lock` (`composer update`) so the lockfile no longer resolves acl and gains the session adapter. | | | -### Implementation Phase 2 — Admin source decoupling +### Implementation Phase 2: Admin source decoupling - GOAL-002: Re-type the ACL seam to laminas while keeping widget filtering in admin (REQ-003). | Task | Description | Completed | Date | |------|-------------|-----------|------| | TASK-004 | `src/Container/DashboardMiddlewareFactory.php`: replace `use Webware\Acl\AclInterface;` with `use Laminas\Permissions\Acl\AclInterface;`. Container lookup stays `$container->get(AclInterface::class)` (host app aliases it to its ACL implementation, in practice `Webware\Acl\Acl`). | ✅ | 2026-08-23 | -| TASK-005 | `src/Middleware/DashboardMiddleware.php`: (a) type-hint the constructor `acl` argument as `Laminas\Permissions\Acl\AclInterface`; (b) read the user from `Mezzio\Authentication\UserInterface::class` instead of `Webware\UserManager\UserInterface`; (c) keep filtering in admin — pass `$user instanceof RoleInterface ? $user : null` to the iterator so non-role-aware users fail closed. | ✅ | 2026-08-23 | -| TASK-006 | `src/ConfigProvider.php`: keep `getAclConfig()` but re-shape it for hosts that use laminas-permissions-acl — emit the default ACL rules under the `mezzio-authorization-acl` config key, matching the structure required by mezzio's integration package (`roles`: `User` + `Administrator => ['User']`; `resources`: the admin dashboard route name `webware.admin.dashboard.read`; `allow`: `Administrator`). Remove only the `Webware\Acl\AclInterface` import and the old `AclInterface::class` config key. webware-acl never consumes this config (it is DB-driven). | ✅ | 2026-08-23 | +| TASK-005 | `src/Middleware/DashboardMiddleware.php`: (a) type-hint the constructor `acl` argument as `Laminas\Permissions\Acl\AclInterface`; (b) read the user from `Mezzio\Authentication\UserInterface::class` instead of `Webware\UserManager\UserInterface`; (c) keep filtering in admin - pass `$user instanceof RoleInterface ? $user : null` to the iterator so non-role-aware users fail closed. | ✅ | 2026-08-23 | +| TASK-006 | `src/ConfigProvider.php`: keep `getAclConfig()` but re-shape it for hosts that use laminas-permissions-acl - emit the default ACL rules under the `mezzio-authorization-acl` config key, matching the structure required by mezzio's integration package (`roles`: `User` + `Administrator => ['User']`; `resources`: the admin dashboard route name `webware.admin.dashboard.read`; `allow`: `Administrator`). Remove only the `Webware\Acl\AclInterface` import and the old `AclInterface::class` config key. webware-acl never consumes this config (it is DB-driven). | ✅ | 2026-08-23 | | TASK-007 | `src/Widget/AclWidgetFilterIterator.php`: re-type to `Laminas\Permissions\Acl\AclInterface` and `?Laminas\Permissions\Acl\Role\RoleInterface`; null user denies every widget. The iterator STAYS in admin (REQ-003). | ✅ | 2026-08-23 | | TASK-008 | `docs/dashboard-widget-system.md`: update the dependencies table (laminas-permissions-acl is the required `AclInterface`, webware-acl is the suggested implementation), the Configuration section (host app MUST provide an `AclInterface` service), and the security note (filtering fails closed). | ✅ | 2026-08-23 | -### Implementation Phase 3 — WidgetContainer type enforcement +### Implementation Phase 3: WidgetContainer type enforcement - GOAL-003: Enforce by construction that only `WidgetInterface` instances can reach `AclWidgetFilterIterator`, and flatten the `Widget\*` classes into the package root. @@ -72,66 +72,66 @@ Full design context: | TASK-018 | `src/AclWidgetFilterIterator.php`: constructor takes `WidgetContainer` directly and passes `$widgets->getIterator()` to `parent::__construct()`; drop the `instanceof WidgetInterface` guard (type safety is structural now); keep null-user fail closed. `DashboardMiddleware` passes `$event->getWidgetContainer()`. | ✅ | 2026-08-23 | | TASK-019 | Docs + plan updated for the new API; `analysis-baseline.toml` regenerated (the `IteratorIterator` false positive should disappear). | ✅ | 2026-08-23 | -### Implementation Phase 4 — Webware-acl companion (separate repo, separate PR) +### Implementation Phase 4: Webware-acl companion (separate repo, separate PR) - GOAL-004: acl drops its bogus suggest entry and formally requires admin; admin keeps ownership of the laminas-acl default config (acl is DB-driven and never consumes it). | Task | Description | Completed | Date | |------|-------------|-----------|------| -| TASK-009 | acl `ConfigProvider`: NO config changes — acl's rules live in its database and are not derived from admin's `mezzio-authorization-acl` defaults. Admin keeps `getAclConfig()` specifically for hosts that use laminas-permissions-acl. | | | -| TASK-010 | acl `composer.json`: replace the bogus `suggest` entry (lines 41–43, referencing nonexistent `RegisterAclWidgetListener`) with `require: "webware/webware-admin": "0.1.x-dev"` and add the admin VCS repository entry. | | | -| TASK-011 | acl: ensure its `Acl` implementation satisfies `Laminas\Permissions\Acl\AclInterface` so host apps can alias `Laminas\Permissions\Acl\AclInterface::class => Webware\Acl\Acl::class`. Existing `RegisterWidgetListener` already bridges the event seam — no delegator or extra middleware needed. | | | +| TASK-009 | acl `ConfigProvider`: NO config changes - acl's rules live in its database and are not derived from admin's `mezzio-authorization-acl` defaults. Admin keeps `getAclConfig()` specifically for hosts that use laminas-permissions-acl. | | | +| TASK-010 | acl `composer.json`: replace the bogus `suggest` entry (lines 41-43, referencing nonexistent `RegisterAclWidgetListener`) with `require: "webware/webware-admin": "0.1.x-dev"` and add the admin VCS repository entry. | | | +| TASK-011 | acl: ensure its `Acl` implementation satisfies `Laminas\Permissions\Acl\AclInterface` so host apps can alias `Laminas\Permissions\Acl\AclInterface::class => Webware\Acl\Acl::class`. Existing `RegisterWidgetListener` already bridges the event seam - no delegator or extra middleware needed. | | | -### Implementation Phase 5 — Validation +### Implementation Phase 5: Validation - GOAL-005: Prove the refactor green and the decoupling real. | Task | Description | Completed | Date | |------|-------------|-----------|------| -| TASK-012 | Admin: `composer test`, `composer test-coverage`, Mago format/lint/analyze/guard, infection — all green with acl absent from admin's dependency tree. | | | +| TASK-012 | Admin: `composer test`, `composer test-coverage`, Mago format/lint/analyze/guard, infection - all green with acl absent from admin's dependency tree. | | | | TASK-013 | Integration check (acl repo): with admin installed as acl's dependency, dispatch the dashboard route; the ACL management widget appears and `AclWidgetFilterIterator` filters widgets per the settled ACL config. | | | | TASK-014 | Fail-closed smoke check: admin without an `AclInterface` service cannot resolve `DashboardMiddleware` (container error); admin with an unauthenticated request renders zero widgets. | | | ## 3. Alternatives -- **ALT-001**: `mezzio-authorization` (mezzio's laminas-acl integration) as the filtering seam. Rejected — its string-based `isGranted()` fights webware-acl's object-centric `UserInterface`/assertion design (research in issue #12). Its **config structure** IS adopted for admin's shipped defaults (`mezzio-authorization-acl` key: roles/resources/allow). -- **ALT-002**: Move `AclWidgetFilterIterator` to webware-acl and have acl re-filter via a delegator or extra middleware. Rejected — filtering must stay in admin so the dashboard fails closed by construction (REQ-003); a delegator also silently breaks admin's fail-closed guarantee if acl is absent. -- **ALT-003**: Admin-defined `WidgetFilterInterface` service seam with acl providing the implementation. Rejected as over-engineering — the user's directive is a plain laminas type hint. -- **ALT-004**: WidgetManager extending `AbstractSingleInstancePluginManager` (laminas-servicemanager) with `$instanceOf = WidgetInterface::class` guarding validation. Rejected — the initial IMS rebuild around `/vendor` would absorb a huge refactor surface for marginal gain; plain typed mutation paths achieve the same guarantee. -- **ALT-005**: Hand-rolled `Iterator` (vs `IteratorAggregate`) on `WidgetContainer`. Rejected — a hand-rolled iterator shares one cursor across nested iterations; `IteratorAggregate` returning a fresh `ArrayIterator` per call avoids the aliasing trap. -- **ALT-006**: Keep the `instanceof WidgetInterface` guard inside `AclWidgetFilterIterator::accept()`. Rejected — with the constructor taking `WidgetContainer`, type safety is structural and the runtime check is dead weight. +- **ALT-001**: `mezzio-authorization` (mezzio's laminas-acl integration) as the filtering seam. Rejected - its string-based `isGranted()` fights webware-acl's object-centric `UserInterface`/assertion design (research in issue #12). Its **config structure** IS adopted for admin's shipped defaults (`mezzio-authorization-acl` key: roles/resources/allow). +- **ALT-002**: Move `AclWidgetFilterIterator` to webware-acl and have acl re-filter via a delegator or extra middleware. Rejected - filtering must stay in admin so the dashboard fails closed by construction (REQ-003); a delegator also silently breaks admin's fail-closed guarantee if acl is absent. +- **ALT-003**: Admin-defined `WidgetFilterInterface` service seam with acl providing the implementation. Rejected as over-engineering - the user's directive is a plain laminas type hint. +- **ALT-004**: WidgetManager extending `AbstractSingleInstancePluginManager` (laminas-servicemanager) with `$instanceOf = WidgetInterface::class` guarding validation. Rejected - the initial IMS rebuild around `/vendor` would absorb a huge refactor surface for marginal gain; plain typed mutation paths achieve the same guarantee. +- **ALT-005**: Hand-rolled `Iterator` (vs `IteratorAggregate`) on `WidgetContainer`. Rejected - a hand-rolled iterator shares one cursor across nested iterations; `IteratorAggregate` returning a fresh `ArrayIterator` per call avoids the aliasing trap. +- **ALT-006**: Keep the `instanceof WidgetInterface` guard inside `AclWidgetFilterIterator::accept()`. Rejected - with the constructor taking `WidgetContainer`, type safety is structural and the runtime check is dead weight. ## 4. Dependencies -- **DEP-001**: `laminas/laminas-permissions-acl: ^2.16` — present in admin (runtime seam). -- **DEP-002**: `mezzio/mezzio-authentication: ^1.13` — present in admin (user attribute contract). -- **DEP-003**: `mezzio/mezzio-authentication-session: ^1.11` — NEW require-dev (DB-free session authentication for tests, CON-002); brings `mezzio/mezzio-session` transitively. -- **DEP-004**: Companion PR in webware-acl (Phase 3) — merge order: acl PR first or coordinated, since admin drops the dependency acl's UI requires. +- **DEP-001**: `laminas/laminas-permissions-acl: ^2.16` - present in admin (runtime seam). +- **DEP-002**: `mezzio/mezzio-authentication: ^1.13` - present in admin (user attribute contract). +- **DEP-003**: `mezzio/mezzio-authentication-session: ^1.11` - NEW require-dev (DB-free session authentication for tests, CON-002); brings `mezzio/mezzio-session` transitively. +- **DEP-004**: Companion PR in webware-acl (Phase 3) - merge order: acl PR first or coordinated, since admin drops the dependency acl's UI requires. - **DEP-005**: The user under the `Mezzio\Authentication\UserInterface::class` attribute must implement laminas `RoleInterface` for object-role `isAllowed()` calls; admin fails closed otherwise (no runtime dependency on webware-usermanager). ## 5. Files -- **FILE-001**: `composer.json` (+ `composer.lock`) — require→suggest, VCS repo removal, mezzio-authentication-session require-dev. -- **FILE-002**: `src/ConfigProvider.php` — re-shaped `getAclConfig()` (mezzio-authorization-acl structure, roles `User`/`Administrator`, resource = admin dashboard route name); `AclInterface::class` key and `Webware\Acl` import removed. -- **FILE-003**: `src/Middleware/DashboardMiddleware.php` — laminas type hint, mezzio user attribute, fail-closed RoleInterface bridge, keeps filtering. -- **FILE-004**: `src/Container/DashboardMiddlewareFactory.php` — laminas import. -- **FILE-005**: `src/Widget/AclWidgetFilterIterator.php` — moved to `src/AclWidgetFilterIterator.php` (root namespace) and re-typed to laminas `AclInterface` + `?RoleInterface`; constructor now takes `WidgetContainer`; stays in admin. -- **FILE-005a**: `src/Widget/WidgetInterface.php` — moved to `src/WidgetInterface.php` (root namespace). -- **FILE-005b**: `src/WidgetContainer.php` — NEW: typed widget collection (TASK-016). -- **FILE-005c**: `src/Event/RegisterWidgetEvent.php` — holds `WidgetContainer`; `registerWidget()`/`getWidgetContainer()` API. -- **FILE-006**: `test/unit/AclWidgetFilterIteratorTest.php` — re-enabled with laminas stubs + `GenericRole` user. -- **FILE-007**: `test/unit/DashboardMiddlewareTest.php` — NEW: allowed/denied/missing-user/non-role-aware-user cases. -- **FILE-008**: `test/integration/DashboardMiddlewareIntegrationTest.php` — NEW: real PhpSession + real laminas Acl, no DB (CON-002). -- **FILE-009**: `test/integration/ConfigProviderIntegrationTest.php` — assert the `mezzio-authorization-acl` defaults (roles/resources/allow for the dashboard route) and no laminas `AclInterface` service definition. -- **FILE-010**: `docs/dashboard-widget-system.md` — dependency table, configuration note, fail-closed security note. -- **FILE-011** (acl): no config changes — DB-driven rules only. -- **FILE-012** (acl): `composer.json` — require admin, VCS repo. +- **FILE-001**: `composer.json` (+ `composer.lock`) - require→suggest, VCS repo removal, mezzio-authentication-session require-dev. +- **FILE-002**: `src/ConfigProvider.php` - re-shaped `getAclConfig()` (mezzio-authorization-acl structure, roles `User`/`Administrator`, resource = admin dashboard route name); `AclInterface::class` key and `Webware\Acl` import removed. +- **FILE-003**: `src/Middleware/DashboardMiddleware.php` - laminas type hint, mezzio user attribute, fail-closed RoleInterface bridge, keeps filtering. +- **FILE-004**: `src/Container/DashboardMiddlewareFactory.php` - laminas import. +- **FILE-005**: `src/Widget/AclWidgetFilterIterator.php` - moved to `src/AclWidgetFilterIterator.php` (root namespace) and re-typed to laminas `AclInterface` + `?RoleInterface`; constructor now takes `WidgetContainer`; stays in admin. +- **FILE-005a**: `src/Widget/WidgetInterface.php` - moved to `src/WidgetInterface.php` (root namespace). +- **FILE-005b**: `src/WidgetContainer.php` - NEW: typed widget collection (TASK-016). +- **FILE-005c**: `src/Event/RegisterWidgetEvent.php` - holds `WidgetContainer`; `registerWidget()`/`getWidgetContainer()` API. +- **FILE-006**: `test/unit/AclWidgetFilterIteratorTest.php` - re-enabled with laminas stubs + `GenericRole` user. +- **FILE-007**: `test/unit/DashboardMiddlewareTest.php` - NEW: allowed/denied/missing-user/non-role-aware-user cases. +- **FILE-008**: `test/integration/DashboardMiddlewareIntegrationTest.php` - NEW: real PhpSession + real laminas Acl, no DB (CON-002). +- **FILE-009**: `test/integration/ConfigProviderIntegrationTest.php` - assert the `mezzio-authorization-acl` defaults (roles/resources/allow for the dashboard route) and no laminas `AclInterface` service definition. +- **FILE-010**: `docs/dashboard-widget-system.md` - dependency table, configuration note, fail-closed security note. +- **FILE-011** (acl): no config changes - DB-driven rules only. +- **FILE-012** (acl): `composer.json` - require admin, VCS repo. ## 6. Testing -- **TEST-001**: `AclWidgetFilterIteratorTest` (re-enabled, no skips): laminas `AclInterface` stubs + `Laminas\Permissions\Acl\Role\GenericRole` user — accepts when allowed, filters partially allowed, rejects when denied, denies all with a null user (fail closed). Non-widget rejection is now enforced by the `WidgetContainer` constructor instead of an `instanceof` guard. +- **TEST-001**: `AclWidgetFilterIteratorTest` (re-enabled, no skips): laminas `AclInterface` stubs + `Laminas\Permissions\Acl\Role\GenericRole` user - accepts when allowed, filters partially allowed, rejects when denied, denies all with a null user (fail closed). Non-widget rejection is now enforced by the `WidgetContainer` constructor instead of an `instanceof` guard. - **TEST-002**: `DashboardMiddlewareTest` (new): allowed widgets attached to the `RegisterWidgetEvent::class` attribute; denied widgets filtered; missing user ⇒ empty iterator; non-`RoleInterface` user (`Mezzio\Authentication\DefaultUser`) ⇒ empty iterator (fail closed). -- **TEST-003**: `DashboardMiddlewareIntegrationTest` (new, DB-free): real `PhpSession` adapter authenticates a session-bound dual-interface user (Mezzio `UserInterface` + laminas `RoleInterface`), then a real `Laminas\Permissions\Acl\Acl` drives `DashboardMiddleware` — Administrator sees the widget, Member sees none. +- **TEST-003**: `DashboardMiddlewareIntegrationTest` (new, DB-free): real `PhpSession` adapter authenticates a session-bound dual-interface user (Mezzio `UserInterface` + laminas `RoleInterface`), then a real `Laminas\Permissions\Acl\Acl` drives `DashboardMiddleware` - Administrator sees the widget, Member sees none. - **TEST-004**: `ConfigProviderIntegrationTest`: assert the `mezzio-authorization-acl` defaults (roles `User`/`Administrator`, resource/allow = `webware.admin.dashboard.read`), assert every getter and `__invoke()` return their exact expected shape via `TestAssets\ExpectedConfig` (kills `ArrayItemRemoval` mutants), and assert that no `Laminas\Permissions\Acl\AclInterface::class` service definition is registered. - **TEST-005**: Full CI matrix (mago, unit lowest/locked/latest, coverage, infection) green in both repos. - **TEST-006**: `WidgetContainerTest` (new): empty container, constructor seeds one widget, `addWidget()` maintains ascending `$order`, `getIterator()` returns a fresh iterator per call. `RegisterWidgetEventTest` updated for the container API (delegation + stable container instance). @@ -139,13 +139,13 @@ Full design context: ## 7. Risks & Assumptions - **RISK-001**: The fail-closed design requires every host app to provide a laminas `AclInterface` service; apps that forget get a container-resolution error rather than a dashboard. Accepted: REQ-003 forbids unfiltered access. -- **RISK-002**: `mezzio-authentication-session`'s `PhpSession` expects the mezzio-session `SessionInterface` request attribute; the integration test supplies an in-memory session double — behavior on real `LazySession` persistence is covered by mezzio's own suite. -- **RISK-003**: Merge-order window where acl requires an admin version that no longer requires acl — coordinate PRs or land Phase 3 first. +- **RISK-002**: `mezzio-authentication-session`'s `PhpSession` expects the mezzio-session `SessionInterface` request attribute; the integration test supplies an in-memory session double - behavior on real `LazySession` persistence is covered by mezzio's own suite. +- **RISK-003**: Merge-order window where acl requires an admin version that no longer requires acl - coordinate PRs or land Phase 3 first. - **ASSUMPTION-001**: The object under the mezzio authentication attribute implements laminas `RoleInterface` in practice (webware-usermanager/webware-acl users do); any other user type fails closed by design. - **ASSUMPTION-002**: Admin's default ACL config targets laminas-permissions-acl hosts via the mezzio-authorization-acl config structure; webware-acl (DB-driven) ignores it. The route name is the resource: `webware.admin.dashboard.read` (prefix constant + `dashboard.read`, as registered by admin's `RouteProvider`). ## 8. Related Specifications / Further Reading -- [webware-admin issue #12 — Decouple admin from webware-acl](https://github.com/webinertia/webware-admin/issues/12) +- [webware-admin issue #12 - Decouple admin from webware-acl](https://github.com/webinertia/webware-admin/issues/12) - [webware-acl integration guide](https://github.com/webinertia/webware-acl/blob/0.1.x/docs/integration-guide.md) - [webware-admin dashboard widget system docs](docs/dashboard-widget-system.md) diff --git a/src/Container/Configuration.php b/src/Container/Configuration.php index f0596dd..5063bb4 100644 --- a/src/Container/Configuration.php +++ b/src/Container/Configuration.php @@ -15,7 +15,7 @@ * Route naming for the admin namespace. * * This component owns the admin base every other module nests under. It is resolved here - * once — ADMIN_NAME by default, overridable through ADMIN_NAME_KEY — and consulted by any + * once - ADMIN_NAME by default, overridable through ADMIN_NAME_KEY - and consulted by any * component that publishes admin routes, so the fleet cannot disagree about the base. * * This component's own routes are that namespace itself: segment `admin`, name prefix @@ -35,7 +35,7 @@ public const string ADMIN_NAME_KEY = 'admin_name'; /** - * The admin namespace base, e.g. `admin`. Consult this — never a local constant — + * The admin namespace base, e.g. `admin`. Consult this - never a local constant - * when composing an admin route name or segment, so an application that relocates * the namespace relocates every module's admin routes with it. * diff --git a/src/WidgetInterface.php b/src/WidgetInterface.php index 7d0c416..f85aba6 100644 --- a/src/WidgetInterface.php +++ b/src/WidgetInterface.php @@ -32,6 +32,6 @@ interface WidgetInterface extends ResourceInterface */ public string $template { get; } - /** Render order — lower values appear first. */ + /** Render order - lower values appear first. */ public int $order { get; } }