Compare commits
2 Commits
dev
...
230-findin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1058cab1a8 | ||
|
|
68b5d62381 |
@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-commit
|
|
||||||
description: Auto-commit changes after a Spec Kit command completes
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.commit.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-feature
|
|
||||||
description: Create a feature branch with sequential or timestamp numbering
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.feature.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-initialize
|
|
||||||
description: Initialize a Git repository with an initial commit
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.initialize.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-remote
|
|
||||||
description: Detect Git remote URL for GitHub integration
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.remote.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-validate
|
|
||||||
description: Validate current branch follows feature branch naming conventions
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.validate.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
description = "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
"""
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
description = "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{args}}
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
"""
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
description = "Initialize a Git repository with an initial commit"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
"""
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
description = "Detect Git remote URL for GitHub integration"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
"""
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
description = "Validate current branch follows feature branch naming conventions"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
"""
|
|
||||||
17
.github/agents/copilot-instructions.md
vendored
17
.github/agents/copilot-instructions.md
vendored
@ -238,16 +238,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
|
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
|
||||||
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
|
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
|
||||||
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
|
|
||||||
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
|
|
||||||
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
|
|
||||||
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
|
|
||||||
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
|
|
||||||
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -282,9 +272,10 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
|
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
|
||||||
- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
|
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
|
||||||
- 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
|
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
||||||
|
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
51
.github/agents/speckit.git.commit.agent.md
vendored
51
.github/agents/speckit.git.commit.agent.md
vendored
@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
description: Auto-commit changes after a Spec Kit command completes
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
70
.github/agents/speckit.git.feature.agent.md
vendored
70
.github/agents/speckit.git.feature.agent.md
vendored
@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a feature branch with sequential or timestamp numbering
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
52
.github/agents/speckit.git.initialize.agent.md
vendored
52
.github/agents/speckit.git.initialize.agent.md
vendored
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description: Initialize a Git repository with an initial commit
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
48
.github/agents/speckit.git.remote.agent.md
vendored
48
.github/agents/speckit.git.remote.agent.md
vendored
@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
description: Detect Git remote URL for GitHub integration
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
52
.github/agents/speckit.git.validate.agent.md
vendored
52
.github/agents/speckit.git.validate.agent.md
vendored
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description: Validate current branch follows feature branch naming conventions
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@ -673,8 +673,3 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
<!-- SPECKIT START -->
|
|
||||||
For additional context about technologies to be used, project structure,
|
|
||||||
shell commands, and other important information, read the current plan
|
|
||||||
<!-- SPECKIT END -->
|
|
||||||
|
|||||||
3
.github/prompts/speckit.git.commit.prompt.md
vendored
3
.github/prompts/speckit.git.commit.prompt.md
vendored
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.commit
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.feature
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.initialize
|
|
||||||
---
|
|
||||||
3
.github/prompts/speckit.git.remote.prompt.md
vendored
3
.github/prompts/speckit.git.remote.prompt.md
vendored
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.remote
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.validate
|
|
||||||
---
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
installed: []
|
|
||||||
settings:
|
|
||||||
auto_execute_hooks: true
|
|
||||||
hooks:
|
|
||||||
before_constitution:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.initialize
|
|
||||||
enabled: true
|
|
||||||
optional: false
|
|
||||||
prompt: Execute speckit.git.initialize?
|
|
||||||
description: Initialize Git repository before constitution setup
|
|
||||||
condition: null
|
|
||||||
before_specify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.feature
|
|
||||||
enabled: true
|
|
||||||
optional: false
|
|
||||||
prompt: Execute speckit.git.feature?
|
|
||||||
description: Create feature branch before specification
|
|
||||||
condition: null
|
|
||||||
before_clarify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before clarification?
|
|
||||||
description: Auto-commit before spec clarification
|
|
||||||
condition: null
|
|
||||||
before_plan:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before planning?
|
|
||||||
description: Auto-commit before implementation planning
|
|
||||||
condition: null
|
|
||||||
before_tasks:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before task generation?
|
|
||||||
description: Auto-commit before task generation
|
|
||||||
condition: null
|
|
||||||
before_implement:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before implementation?
|
|
||||||
description: Auto-commit before implementation
|
|
||||||
condition: null
|
|
||||||
before_checklist:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before checklist?
|
|
||||||
description: Auto-commit before checklist generation
|
|
||||||
condition: null
|
|
||||||
before_analyze:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before analysis?
|
|
||||||
description: Auto-commit before analysis
|
|
||||||
condition: null
|
|
||||||
before_taskstoissues:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before issue sync?
|
|
||||||
description: Auto-commit before tasks-to-issues conversion
|
|
||||||
condition: null
|
|
||||||
after_constitution:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit constitution changes?
|
|
||||||
description: Auto-commit after constitution update
|
|
||||||
condition: null
|
|
||||||
after_specify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit specification changes?
|
|
||||||
description: Auto-commit after specification
|
|
||||||
condition: null
|
|
||||||
after_clarify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit clarification changes?
|
|
||||||
description: Auto-commit after spec clarification
|
|
||||||
condition: null
|
|
||||||
after_plan:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit plan changes?
|
|
||||||
description: Auto-commit after implementation planning
|
|
||||||
condition: null
|
|
||||||
after_tasks:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit task changes?
|
|
||||||
description: Auto-commit after task generation
|
|
||||||
condition: null
|
|
||||||
after_implement:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit implementation changes?
|
|
||||||
description: Auto-commit after implementation
|
|
||||||
condition: null
|
|
||||||
after_checklist:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit checklist changes?
|
|
||||||
description: Auto-commit after checklist generation
|
|
||||||
condition: null
|
|
||||||
after_analyze:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit analysis results?
|
|
||||||
description: Auto-commit after analysis
|
|
||||||
condition: null
|
|
||||||
after_taskstoissues:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit after syncing issues?
|
|
||||||
description: Auto-commit after tasks-to-issues conversion
|
|
||||||
condition: null
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"extensions": {
|
|
||||||
"git": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "local",
|
|
||||||
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 10,
|
|
||||||
"registered_commands": {
|
|
||||||
"agy": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"codex": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"copilot": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"gemini": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"registered_skills": [],
|
|
||||||
"installed_at": "2026-04-22T21:58:03.029565+00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# Git Branching Workflow Extension
|
|
||||||
|
|
||||||
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
|
||||||
|
|
||||||
- **Repository initialization** with configurable commit messages
|
|
||||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
|
||||||
- **Branch validation** to ensure branches follow naming conventions
|
|
||||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
|
||||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
|
|
||||||
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
|
||||||
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
|
||||||
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
|
||||||
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
|
|
||||||
|
|
||||||
## Hooks
|
|
||||||
|
|
||||||
| Event | Command | Optional | Description |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
|
|
||||||
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
|
||||||
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
|
|
||||||
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
|
|
||||||
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
|
|
||||||
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
|
|
||||||
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
|
|
||||||
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
|
|
||||||
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
|
|
||||||
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
|
|
||||||
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
|
|
||||||
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
|
|
||||||
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
|
|
||||||
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
|
|
||||||
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
|
|
||||||
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
|
|
||||||
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
|
|
||||||
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Branch numbering strategy: "sequential" or "timestamp"
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Custom commit message for git init
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit per command (all disabled by default)
|
|
||||||
# Example: enable auto-commit after specify
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
after_specify:
|
|
||||||
enabled: true
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install the bundled git extension (no network required)
|
|
||||||
specify extension add git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Disabling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Disable the git extension (spec creation continues without branching)
|
|
||||||
specify extension disable git
|
|
||||||
|
|
||||||
# Re-enable it
|
|
||||||
specify extension enable git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
When Git is not installed or the directory is not a Git repository:
|
|
||||||
- Spec directories are still created under `specs/`
|
|
||||||
- Branch creation is skipped with a warning
|
|
||||||
- Branch validation is skipped with a warning
|
|
||||||
- Remote detection returns empty results
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
The extension bundles cross-platform scripts:
|
|
||||||
|
|
||||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
|
||||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
|
||||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
|
||||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Initialize a Git repository with an initial commit"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Git Branching Workflow Extension Configuration
|
|
||||||
# Copied to .specify/extensions/git/git-config.yml on install
|
|
||||||
|
|
||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Commit message used by `git commit` during repository initialization
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit before/after core commands.
|
|
||||||
# Set "default" to enable for all commands, then override per-command.
|
|
||||||
# Each key can be true/false. Message is customizable per-command.
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
before_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before clarification"
|
|
||||||
before_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before planning"
|
|
||||||
before_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before task generation"
|
|
||||||
before_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before implementation"
|
|
||||||
before_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before checklist"
|
|
||||||
before_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before issue sync"
|
|
||||||
after_constitution:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add project constitution"
|
|
||||||
after_specify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Clarify specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
after_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add tasks"
|
|
||||||
after_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Implementation progress"
|
|
||||||
after_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add checklist"
|
|
||||||
after_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add analysis report"
|
|
||||||
after_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Sync tasks to issues"
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
schema_version: "1.0"
|
|
||||||
|
|
||||||
extension:
|
|
||||||
id: git
|
|
||||||
name: "Git Branching Workflow"
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
|
||||||
author: spec-kit-core
|
|
||||||
repository: https://github.com/github/spec-kit
|
|
||||||
license: MIT
|
|
||||||
|
|
||||||
requires:
|
|
||||||
speckit_version: ">=0.2.0"
|
|
||||||
tools:
|
|
||||||
- name: git
|
|
||||||
required: false
|
|
||||||
|
|
||||||
provides:
|
|
||||||
commands:
|
|
||||||
- name: speckit.git.feature
|
|
||||||
file: commands/speckit.git.feature.md
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
- name: speckit.git.validate
|
|
||||||
file: commands/speckit.git.validate.md
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
- name: speckit.git.remote
|
|
||||||
file: commands/speckit.git.remote.md
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
- name: speckit.git.initialize
|
|
||||||
file: commands/speckit.git.initialize.md
|
|
||||||
description: "Initialize a Git repository with an initial commit"
|
|
||||||
- name: speckit.git.commit
|
|
||||||
file: commands/speckit.git.commit.md
|
|
||||||
description: "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
|
|
||||||
config:
|
|
||||||
- name: "git-config.yml"
|
|
||||||
template: "config-template.yml"
|
|
||||||
description: "Git branching configuration"
|
|
||||||
required: false
|
|
||||||
|
|
||||||
hooks:
|
|
||||||
before_constitution:
|
|
||||||
command: speckit.git.initialize
|
|
||||||
optional: false
|
|
||||||
description: "Initialize Git repository before constitution setup"
|
|
||||||
before_specify:
|
|
||||||
command: speckit.git.feature
|
|
||||||
optional: false
|
|
||||||
description: "Create feature branch before specification"
|
|
||||||
before_clarify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before clarification?"
|
|
||||||
description: "Auto-commit before spec clarification"
|
|
||||||
before_plan:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before planning?"
|
|
||||||
description: "Auto-commit before implementation planning"
|
|
||||||
before_tasks:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before task generation?"
|
|
||||||
description: "Auto-commit before task generation"
|
|
||||||
before_implement:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before implementation?"
|
|
||||||
description: "Auto-commit before implementation"
|
|
||||||
before_checklist:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before checklist?"
|
|
||||||
description: "Auto-commit before checklist generation"
|
|
||||||
before_analyze:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before analysis?"
|
|
||||||
description: "Auto-commit before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before issue sync?"
|
|
||||||
description: "Auto-commit before tasks-to-issues conversion"
|
|
||||||
after_constitution:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit constitution changes?"
|
|
||||||
description: "Auto-commit after constitution update"
|
|
||||||
after_specify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit specification changes?"
|
|
||||||
description: "Auto-commit after specification"
|
|
||||||
after_clarify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit clarification changes?"
|
|
||||||
description: "Auto-commit after spec clarification"
|
|
||||||
after_plan:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit plan changes?"
|
|
||||||
description: "Auto-commit after implementation planning"
|
|
||||||
after_tasks:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit task changes?"
|
|
||||||
description: "Auto-commit after task generation"
|
|
||||||
after_implement:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit implementation changes?"
|
|
||||||
description: "Auto-commit after implementation"
|
|
||||||
after_checklist:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit checklist changes?"
|
|
||||||
description: "Auto-commit after checklist generation"
|
|
||||||
after_analyze:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit analysis results?"
|
|
||||||
description: "Auto-commit after analysis"
|
|
||||||
after_taskstoissues:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit after syncing issues?"
|
|
||||||
description: "Auto-commit after tasks-to-issues conversion"
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- "git"
|
|
||||||
- "branching"
|
|
||||||
- "workflow"
|
|
||||||
|
|
||||||
config:
|
|
||||||
defaults:
|
|
||||||
branch_numbering: sequential
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Git Branching Workflow Extension Configuration
|
|
||||||
# Copied to .specify/extensions/git/git-config.yml on install
|
|
||||||
|
|
||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Commit message used by `git commit` during repository initialization
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit before/after core commands.
|
|
||||||
# Set "default" to enable for all commands, then override per-command.
|
|
||||||
# Each key can be true/false. Message is customizable per-command.
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
before_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before clarification"
|
|
||||||
before_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before planning"
|
|
||||||
before_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before task generation"
|
|
||||||
before_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before implementation"
|
|
||||||
before_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before checklist"
|
|
||||||
before_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before issue sync"
|
|
||||||
after_constitution:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add project constitution"
|
|
||||||
after_specify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Clarify specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
after_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add tasks"
|
|
||||||
after_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Implementation progress"
|
|
||||||
after_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add checklist"
|
|
||||||
after_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add analysis report"
|
|
||||||
after_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Sync tasks to issues"
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: auto-commit.sh
|
|
||||||
# Automatically commit changes after a Spec Kit command completes.
|
|
||||||
# Checks per-command config keys in git-config.yml before committing.
|
|
||||||
#
|
|
||||||
# Usage: auto-commit.sh <event_name>
|
|
||||||
# e.g.: auto-commit.sh after_specify
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
EVENT_NAME="${1:-}"
|
|
||||||
if [ -z "$EVENT_NAME" ]; then
|
|
||||||
echo "Usage: $0 <event_name>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read per-command config from git-config.yml
|
|
||||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
|
||||||
_enabled=false
|
|
||||||
_commit_msg=""
|
|
||||||
|
|
||||||
if [ -f "$_config_file" ]; then
|
|
||||||
# Parse the auto_commit section for this event.
|
|
||||||
# Look for auto_commit.<event_name>.enabled and .message
|
|
||||||
# Also check auto_commit.default as fallback.
|
|
||||||
_in_auto_commit=false
|
|
||||||
_in_event=false
|
|
||||||
_default_enabled=false
|
|
||||||
|
|
||||||
while IFS= read -r _line; do
|
|
||||||
# Detect auto_commit: section
|
|
||||||
if echo "$_line" | grep -q '^auto_commit:'; then
|
|
||||||
_in_auto_commit=true
|
|
||||||
_in_event=false
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Exit auto_commit section on next top-level key
|
|
||||||
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $_in_auto_commit; then
|
|
||||||
# Check default key
|
|
||||||
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
|
||||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
||||||
[ "$_val" = "true" ] && _default_enabled=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect our event subsection
|
|
||||||
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
|
||||||
_in_event=true
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Inside our event subsection
|
|
||||||
if $_in_event; then
|
|
||||||
# Exit on next sibling key (same indent level as event name)
|
|
||||||
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
|
||||||
_in_event=false
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
|
||||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
||||||
[ "$_val" = "true" ] && _enabled=true
|
|
||||||
[ "$_val" = "false" ] && _enabled=false
|
|
||||||
fi
|
|
||||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
|
||||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done < "$_config_file"
|
|
||||||
|
|
||||||
# If event-specific key not found, use default
|
|
||||||
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
|
||||||
# Only use default if the event wasn't explicitly set to false
|
|
||||||
# Check if event section existed at all
|
|
||||||
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
|
||||||
_enabled=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# No config file — auto-commit disabled by default
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_enabled" != "true" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if there are changes to commit
|
|
||||||
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
|
||||||
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Derive a human-readable command name from the event
|
|
||||||
# e.g., after_specify -> specify, before_plan -> plan
|
|
||||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
|
||||||
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
|
||||||
|
|
||||||
# Use custom message if configured, otherwise default
|
|
||||||
if [ -z "$_commit_msg" ]; then
|
|
||||||
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stage and commit
|
|
||||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
|
||||||
@ -1,453 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: create-new-feature.sh
|
|
||||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
|
||||||
# Sources common.sh from the project's installed scripts, falling back to
|
|
||||||
# git-common.sh for minimal git helpers.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
JSON_MODE=false
|
|
||||||
DRY_RUN=false
|
|
||||||
ALLOW_EXISTING=false
|
|
||||||
SHORT_NAME=""
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
USE_TIMESTAMP=false
|
|
||||||
ARGS=()
|
|
||||||
i=1
|
|
||||||
while [ $i -le $# ]; do
|
|
||||||
arg="${!i}"
|
|
||||||
case "$arg" in
|
|
||||||
--json)
|
|
||||||
JSON_MODE=true
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
DRY_RUN=true
|
|
||||||
;;
|
|
||||||
--allow-existing-branch)
|
|
||||||
ALLOW_EXISTING=true
|
|
||||||
;;
|
|
||||||
--short-name)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SHORT_NAME="$next_arg"
|
|
||||||
;;
|
|
||||||
--number)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BRANCH_NUMBER="$next_arg"
|
|
||||||
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo 'Error: --number must be a non-negative integer' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
--timestamp)
|
|
||||||
USE_TIMESTAMP=true
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --json Output in JSON format"
|
|
||||||
echo " --dry-run Compute branch name without creating the branch"
|
|
||||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
echo " --help, -h Show this help message"
|
|
||||||
echo ""
|
|
||||||
echo "Environment variables:"
|
|
||||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
|
||||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
|
||||||
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
ARGS+=("$arg")
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Trim whitespace and validate description is not empty
|
|
||||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Function to get highest number from specs directory
|
|
||||||
get_highest_from_specs() {
|
|
||||||
local specs_dir="$1"
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
if [ -d "$specs_dir" ]; then
|
|
||||||
for dir in "$specs_dir"/*; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
dirname=$(basename "$dir")
|
|
||||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
|
||||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
|
||||||
number=$((10#$number))
|
|
||||||
if [ "$number" -gt "$highest" ]; then
|
|
||||||
highest=$number
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to get highest number from git branches
|
|
||||||
get_highest_from_branches() {
|
|
||||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
|
||||||
_extract_highest_number() {
|
|
||||||
local highest=0
|
|
||||||
while IFS= read -r name; do
|
|
||||||
[ -z "$name" ] && continue
|
|
||||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
|
||||||
number=$((10#$number))
|
|
||||||
if [ "$number" -gt "$highest" ]; then
|
|
||||||
highest=$number
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
|
||||||
get_highest_from_remote_refs() {
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
for remote in $(git remote 2>/dev/null); do
|
|
||||||
local remote_highest
|
|
||||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
|
||||||
if [ "$remote_highest" -gt "$highest" ]; then
|
|
||||||
highest=$remote_highest
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to check existing branches and return next available number.
|
|
||||||
check_existing_branches() {
|
|
||||||
local specs_dir="$1"
|
|
||||||
local skip_fetch="${2:-false}"
|
|
||||||
|
|
||||||
if [ "$skip_fetch" = true ]; then
|
|
||||||
local highest_remote=$(get_highest_from_remote_refs)
|
|
||||||
local highest_branch=$(get_highest_from_branches)
|
|
||||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
|
||||||
highest_branch=$highest_remote
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
git fetch --all --prune >/dev/null 2>&1 || true
|
|
||||||
local highest_branch=$(get_highest_from_branches)
|
|
||||||
fi
|
|
||||||
|
|
||||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
|
||||||
|
|
||||||
local max_num=$highest_branch
|
|
||||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
|
||||||
max_num=$highest_spec
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo $((max_num + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to clean and format a branch name
|
|
||||||
clean_branch_name() {
|
|
||||||
local name="$1"
|
|
||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
|
|
||||||
#
|
|
||||||
# Search locations in priority order:
|
|
||||||
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
|
|
||||||
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
|
|
||||||
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Find project root by walking up from the script location
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_common_loaded=false
|
|
||||||
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
|
|
||||||
|
|
||||||
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_PROJECT_ROOT/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
|
||||||
source "$SCRIPT_DIR/git-common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_common_loaded" != "true" ]; then
|
|
||||||
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve repository root
|
|
||||||
if type get_repo_root >/dev/null 2>&1; then
|
|
||||||
REPO_ROOT=$(get_repo_root)
|
|
||||||
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
||||||
elif [ -n "$_PROJECT_ROOT" ]; then
|
|
||||||
REPO_ROOT="$_PROJECT_ROOT"
|
|
||||||
else
|
|
||||||
echo "Error: Could not determine repository root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if git is available at this repo root
|
|
||||||
if type has_git >/dev/null 2>&1; then
|
|
||||||
if has_git "$REPO_ROOT"; then
|
|
||||||
HAS_GIT=true
|
|
||||||
else
|
|
||||||
HAS_GIT=false
|
|
||||||
fi
|
|
||||||
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
HAS_GIT=true
|
|
||||||
else
|
|
||||||
HAS_GIT=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
SPECS_DIR="$REPO_ROOT/specs"
|
|
||||||
|
|
||||||
# Function to generate branch name with stop word filtering
|
|
||||||
generate_branch_name() {
|
|
||||||
local description="$1"
|
|
||||||
|
|
||||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
|
||||||
|
|
||||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
|
||||||
|
|
||||||
local meaningful_words=()
|
|
||||||
for word in $clean_name; do
|
|
||||||
[ -z "$word" ] && continue
|
|
||||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
|
||||||
if [ ${#word} -ge 3 ]; then
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
elif echo "$description" | grep -qw -- "${word^^}"; then
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
|
||||||
local max_words=3
|
|
||||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
|
||||||
|
|
||||||
local result=""
|
|
||||||
local count=0
|
|
||||||
for word in "${meaningful_words[@]}"; do
|
|
||||||
if [ $count -ge $max_words ]; then break; fi
|
|
||||||
if [ -n "$result" ]; then result="$result-"; fi
|
|
||||||
result="$result$word"
|
|
||||||
count=$((count + 1))
|
|
||||||
done
|
|
||||||
echo "$result"
|
|
||||||
else
|
|
||||||
local cleaned=$(clean_branch_name "$description")
|
|
||||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
|
||||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
|
||||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
|
||||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
|
||||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
|
||||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
|
||||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
|
||||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
|
||||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
|
||||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
|
||||||
else
|
|
||||||
FEATURE_NUM="$BRANCH_NAME"
|
|
||||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Generate branch name
|
|
||||||
if [ -n "$SHORT_NAME" ]; then
|
|
||||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
|
||||||
else
|
|
||||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Warn if --number and --timestamp are both specified
|
|
||||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
|
||||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine branch prefix
|
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
else
|
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
|
||||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
|
||||||
elif [ "$DRY_RUN" = true ]; then
|
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
||||||
elif [ "$HAS_GIT" = true ]; then
|
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
|
||||||
else
|
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
|
||||||
MAX_BRANCH_LENGTH=244
|
|
||||||
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
|
||||||
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
|
||||||
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
|
||||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
|
||||||
exit 1
|
|
||||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
|
||||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
|
||||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
|
||||||
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
|
||||||
|
|
||||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
|
||||||
|
|
||||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
|
||||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
|
||||||
if [ "$HAS_GIT" = true ]; then
|
|
||||||
branch_create_error=""
|
|
||||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
|
||||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
|
||||||
if [ "$ALLOW_EXISTING" = true ]; then
|
|
||||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
|
||||||
:
|
|
||||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
|
||||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
|
||||||
if [ -n "$switch_branch_error" ]; then
|
|
||||||
>&2 printf '%s\n' "$switch_branch_error"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
|
||||||
if [ -n "$branch_create_error" ]; then
|
|
||||||
>&2 printf '%s\n' "$branch_create_error"
|
|
||||||
else
|
|
||||||
>&2 echo "Please check your git configuration and try again."
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $JSON_MODE; then
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
|
||||||
jq -cn \
|
|
||||||
--arg branch_name "$BRANCH_NAME" \
|
|
||||||
--arg feature_num "$FEATURE_NUM" \
|
|
||||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
|
||||||
else
|
|
||||||
jq -cn \
|
|
||||||
--arg branch_name "$BRANCH_NAME" \
|
|
||||||
--arg feature_num "$FEATURE_NUM" \
|
|
||||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if type json_escape >/dev/null 2>&1; then
|
|
||||||
_je_branch=$(json_escape "$BRANCH_NAME")
|
|
||||||
_je_num=$(json_escape "$FEATURE_NUM")
|
|
||||||
else
|
|
||||||
_je_branch="$BRANCH_NAME"
|
|
||||||
_je_num="$FEATURE_NUM"
|
|
||||||
fi
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
|
||||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
|
||||||
else
|
|
||||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
|
||||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
|
||||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/bash/common.sh — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
# Check if we have git available at the repo root
|
|
||||||
has_git() {
|
|
||||||
local repo_root="${1:-$(pwd)}"
|
|
||||||
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
|
|
||||||
command -v git >/dev/null 2>&1 && \
|
|
||||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
|
||||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
|
||||||
spec_kit_effective_branch_name() {
|
|
||||||
local raw="$1"
|
|
||||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
|
||||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$raw"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate that a branch name matches the expected feature branch pattern.
|
|
||||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
|
||||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
|
||||||
check_feature_branch() {
|
|
||||||
local raw="$1"
|
|
||||||
local has_git_repo="$2"
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if [[ "$has_git_repo" != "true" ]]; then
|
|
||||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local branch
|
|
||||||
branch=$(spec_kit_effective_branch_name "$raw")
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
|
||||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
|
||||||
local is_sequential=false
|
|
||||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
|
||||||
is_sequential=true
|
|
||||||
fi
|
|
||||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
|
||||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
|
||||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: initialize-repo.sh
|
|
||||||
# Initialize a Git repository with an initial commit.
|
|
||||||
# Customizable — replace this script to add .gitignore templates,
|
|
||||||
# default branch config, git-flow, LFS, signing, etc.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Find project root
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
# Read commit message from extension config, fall back to default
|
|
||||||
COMMIT_MSG="[Spec Kit] Initial commit"
|
|
||||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
|
||||||
if [ -f "$_config_file" ]; then
|
|
||||||
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
|
||||||
if [ -n "$_msg" ]; then
|
|
||||||
COMMIT_MSG="$_msg"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if already a git repo
|
|
||||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Git repository already initialized; skipping" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "✓ Git repository initialized" >&2
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: auto-commit.ps1
|
|
||||||
# Automatically commit changes after a Spec Kit command completes.
|
|
||||||
# Checks per-command config keys in git-config.yml before committing.
|
|
||||||
#
|
|
||||||
# Usage: auto-commit.ps1 <event_name>
|
|
||||||
# e.g.: auto-commit.ps1 after_specify
|
|
||||||
param(
|
|
||||||
[Parameter(Position = 0, Mandatory = $true)]
|
|
||||||
[string]$EventName
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Temporarily relax ErrorActionPreference so git stderr warnings
|
|
||||||
# (e.g. CRLF notices on Windows) do not become terminating errors.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
$isRepo = $LASTEXITCODE -eq 0
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
if (-not $isRepo) {
|
|
||||||
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read per-command config from git-config.yml
|
|
||||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
|
||||||
$enabled = $false
|
|
||||||
$commitMsg = ""
|
|
||||||
|
|
||||||
if (Test-Path $configFile) {
|
|
||||||
# Parse YAML to find auto_commit section
|
|
||||||
$inAutoCommit = $false
|
|
||||||
$inEvent = $false
|
|
||||||
$defaultEnabled = $false
|
|
||||||
|
|
||||||
foreach ($line in Get-Content $configFile) {
|
|
||||||
# Detect auto_commit: section
|
|
||||||
if ($line -match '^auto_commit:') {
|
|
||||||
$inAutoCommit = $true
|
|
||||||
$inEvent = $false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exit auto_commit section on next top-level key
|
|
||||||
if ($inAutoCommit -and $line -match '^[a-z]') {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($inAutoCommit) {
|
|
||||||
# Check default key
|
|
||||||
if ($line -match '^\s+default:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim().ToLower()
|
|
||||||
if ($val -eq 'true') { $defaultEnabled = $true }
|
|
||||||
}
|
|
||||||
|
|
||||||
# Detect our event subsection
|
|
||||||
if ($line -match "^\s+${EventName}:") {
|
|
||||||
$inEvent = $true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Inside our event subsection
|
|
||||||
if ($inEvent) {
|
|
||||||
# Exit on next sibling key (2-space indent, not 4+)
|
|
||||||
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
|
|
||||||
$inEvent = $false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ($line -match '\s+enabled:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim().ToLower()
|
|
||||||
if ($val -eq 'true') { $enabled = $true }
|
|
||||||
if ($val -eq 'false') { $enabled = $false }
|
|
||||||
}
|
|
||||||
if ($line -match '\s+message:\s*(.+)$') {
|
|
||||||
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# If event-specific key not found, use default
|
|
||||||
if (-not $enabled -and $defaultEnabled) {
|
|
||||||
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
|
|
||||||
if (-not $hasEventKey) {
|
|
||||||
$enabled = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
# No config file — auto-commit disabled by default
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $enabled) {
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if there are changes to commit
|
|
||||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
|
||||||
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
|
||||||
$untracked = git ls-files --others --exclude-standard 2>$null
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
|
||||||
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Derive a human-readable command name from the event
|
|
||||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
|
||||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
|
||||||
|
|
||||||
# Use custom message if configured, otherwise default
|
|
||||||
if (-not $commitMsg) {
|
|
||||||
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Stage and commit
|
|
||||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
|
|
||||||
# while still allowing redirected error output to be captured for diagnostics.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
$out = git add . 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
|
||||||
$out = git commit -q -m $commitMsg 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[specify] Error: $_"
|
|
||||||
exit 1
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[OK] Changes committed $phase $commandName"
|
|
||||||
@ -1,403 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: create-new-feature.ps1
|
|
||||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
|
||||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
|
||||||
# git-common.ps1 for minimal git helpers.
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[switch]$Json,
|
|
||||||
[switch]$AllowExistingBranch,
|
|
||||||
[switch]$DryRun,
|
|
||||||
[string]$ShortName,
|
|
||||||
[Parameter()]
|
|
||||||
[long]$Number = 0,
|
|
||||||
[switch]$Timestamp,
|
|
||||||
[switch]$Help,
|
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
|
||||||
[string[]]$FeatureDescription
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
if ($Help) {
|
|
||||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Options:"
|
|
||||||
Write-Host " -Json Output in JSON format"
|
|
||||||
Write-Host " -DryRun Compute branch name without creating the branch"
|
|
||||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
Write-Host " -Help Show this help message"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Environment variables:"
|
|
||||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
|
||||||
Write-Host ""
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
||||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
||||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromSpecs {
|
|
||||||
param([string]$SpecsDir)
|
|
||||||
|
|
||||||
[long]$highest = 0
|
|
||||||
if (Test-Path $SpecsDir) {
|
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
||||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[long]$num = 0
|
|
||||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
||||||
$highest = $num
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromNames {
|
|
||||||
param([string[]]$Names)
|
|
||||||
|
|
||||||
[long]$highest = 0
|
|
||||||
foreach ($name in $Names) {
|
|
||||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[long]$num = 0
|
|
||||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
||||||
$highest = $num
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromBranches {
|
|
||||||
param()
|
|
||||||
|
|
||||||
try {
|
|
||||||
$branches = git branch -a 2>$null
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
|
||||||
$cleanNames = $branches | ForEach-Object {
|
|
||||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
|
||||||
}
|
|
||||||
return Get-HighestNumberFromNames -Names $cleanNames
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Verbose "Could not check Git branches: $_"
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromRemoteRefs {
|
|
||||||
[long]$highest = 0
|
|
||||||
try {
|
|
||||||
$remotes = git remote 2>$null
|
|
||||||
if ($remotes) {
|
|
||||||
foreach ($remote in $remotes) {
|
|
||||||
$env:GIT_TERMINAL_PROMPT = '0'
|
|
||||||
$refs = git ls-remote --heads $remote 2>$null
|
|
||||||
$env:GIT_TERMINAL_PROMPT = $null
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
|
||||||
$refNames = $refs | ForEach-Object {
|
|
||||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
|
||||||
} | Where-Object { $_ }
|
|
||||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
|
||||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Verbose "Could not query remote refs: $_"
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-NextBranchNumber {
|
|
||||||
param(
|
|
||||||
[string]$SpecsDir,
|
|
||||||
[switch]$SkipFetch
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($SkipFetch) {
|
|
||||||
$highestBranch = Get-HighestNumberFromBranches
|
|
||||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
|
||||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
git fetch --all --prune 2>$null | Out-Null
|
|
||||||
} catch { }
|
|
||||||
$highestBranch = Get-HighestNumberFromBranches
|
|
||||||
}
|
|
||||||
|
|
||||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
|
||||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
|
||||||
return $maxNum + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConvertTo-CleanBranchName {
|
|
||||||
param([string]$Name)
|
|
||||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Source common.ps1 from the project's installed scripts.
|
|
||||||
# Search locations in priority order:
|
|
||||||
# 1. .specify/scripts/powershell/common.ps1 under the project root
|
|
||||||
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
|
||||||
# 3. git-common.ps1 next to this script (minimal fallback)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
$commonLoaded = $false
|
|
||||||
|
|
||||||
if ($projectRoot) {
|
|
||||||
$candidates = @(
|
|
||||||
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
|
|
||||||
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
|
||||||
)
|
|
||||||
foreach ($candidate in $candidates) {
|
|
||||||
if (Test-Path $candidate) {
|
|
||||||
. $candidate
|
|
||||||
$commonLoaded = $true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
|
||||||
. "$PSScriptRoot/git-common.ps1"
|
|
||||||
$commonLoaded = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $commonLoaded) {
|
|
||||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve repository root
|
|
||||||
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
|
||||||
$repoRoot = Get-RepoRoot
|
|
||||||
} elseif ($projectRoot) {
|
|
||||||
$repoRoot = $projectRoot
|
|
||||||
} else {
|
|
||||||
throw "Could not determine repository root."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
|
||||||
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
|
||||||
# and git-common.ps1 (has -RepoRoot param with default).
|
|
||||||
$hasGit = Test-HasGit
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
$hasGit = ($LASTEXITCODE -eq 0)
|
|
||||||
} catch {
|
|
||||||
$hasGit = $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
$specsDir = Join-Path $repoRoot 'specs'
|
|
||||||
|
|
||||||
function Get-BranchName {
|
|
||||||
param([string]$Description)
|
|
||||||
|
|
||||||
$stopWords = @(
|
|
||||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
||||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
||||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
||||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
||||||
'want', 'need', 'add', 'get', 'set'
|
|
||||||
)
|
|
||||||
|
|
||||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
||||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
||||||
|
|
||||||
$meaningfulWords = @()
|
|
||||||
foreach ($word in $words) {
|
|
||||||
if ($stopWords -contains $word) { continue }
|
|
||||||
if ($word.Length -ge 3) {
|
|
||||||
$meaningfulWords += $word
|
|
||||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
|
||||||
$meaningfulWords += $word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($meaningfulWords.Count -gt 0) {
|
|
||||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
||||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
||||||
return $result
|
|
||||||
} else {
|
|
||||||
$result = ConvertTo-CleanBranchName -Name $Description
|
|
||||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
||||||
return [string]::Join('-', $fallbackWords)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
|
||||||
if ($env:GIT_BRANCH_NAME) {
|
|
||||||
$branchName = $env:GIT_BRANCH_NAME
|
|
||||||
# Check 244-byte limit (UTF-8) for override names
|
|
||||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
|
||||||
if ($branchNameUtf8ByteCount -gt 244) {
|
|
||||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
|
||||||
}
|
|
||||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
|
||||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
|
||||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
|
||||||
$featureNum = $matches[1]
|
|
||||||
} elseif ($branchName -match '^(\d+)-') {
|
|
||||||
$featureNum = $matches[1]
|
|
||||||
} else {
|
|
||||||
$featureNum = $branchName
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($ShortName) {
|
|
||||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
||||||
} else {
|
|
||||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Timestamp -and $Number -ne 0) {
|
|
||||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
|
||||||
$Number = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Timestamp) {
|
|
||||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
} else {
|
|
||||||
if ($Number -eq 0) {
|
|
||||||
if ($DryRun -and $hasGit) {
|
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
|
||||||
} elseif ($DryRun) {
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
} elseif ($hasGit) {
|
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
|
||||||
} else {
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxBranchLength = 244
|
|
||||||
if ($branchName.Length -gt $maxBranchLength) {
|
|
||||||
$prefixLength = $featureNum.Length + 1
|
|
||||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
|
||||||
|
|
||||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
|
||||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
|
||||||
|
|
||||||
$originalBranchName = $branchName
|
|
||||||
$branchName = "$featureNum-$truncatedSuffix"
|
|
||||||
|
|
||||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
|
||||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $DryRun) {
|
|
||||||
if ($hasGit) {
|
|
||||||
$branchCreated = $false
|
|
||||||
$branchCreateError = ''
|
|
||||||
try {
|
|
||||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
$branchCreated = $true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$branchCreateError = $_.Exception.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $branchCreated) {
|
|
||||||
$currentBranch = ''
|
|
||||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
|
||||||
if ($existingBranch) {
|
|
||||||
if ($AllowExistingBranch) {
|
|
||||||
if ($currentBranch -eq $branchName) {
|
|
||||||
# Already on the target branch
|
|
||||||
} else {
|
|
||||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
if ($switchBranchError) {
|
|
||||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} elseif ($Timestamp) {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
|
||||||
exit 1
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($branchCreateError) {
|
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($Json) {
|
|
||||||
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
|
||||||
} else {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$env:SPECIFY_FEATURE = $branchName
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Json) {
|
|
||||||
$obj = [PSCustomObject]@{
|
|
||||||
BRANCH_NAME = $branchName
|
|
||||||
FEATURE_NUM = $featureNum
|
|
||||||
HAS_GIT = $hasGit
|
|
||||||
}
|
|
||||||
if ($DryRun) {
|
|
||||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
|
||||||
}
|
|
||||||
$obj | ConvertTo-Json -Compress
|
|
||||||
} else {
|
|
||||||
Write-Output "BRANCH_NAME: $branchName"
|
|
||||||
Write-Output "FEATURE_NUM: $featureNum"
|
|
||||||
Write-Output "HAS_GIT: $hasGit"
|
|
||||||
if (-not $DryRun) {
|
|
||||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
function Test-HasGit {
|
|
||||||
param([string]$RepoRoot = (Get-Location))
|
|
||||||
try {
|
|
||||||
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
|
||||||
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
return ($LASTEXITCODE -eq 0)
|
|
||||||
} catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-SpecKitEffectiveBranchName {
|
|
||||||
param([string]$Branch)
|
|
||||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
|
||||||
return $Matches[2]
|
|
||||||
}
|
|
||||||
return $Branch
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-FeatureBranch {
|
|
||||||
param(
|
|
||||||
[string]$Branch,
|
|
||||||
[bool]$HasGit = $true
|
|
||||||
)
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if (-not $HasGit) {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $Branch
|
|
||||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
|
||||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
|
||||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
|
||||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
|
||||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
|
||||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: initialize-repo.ps1
|
|
||||||
# Initialize a Git repository with an initial commit.
|
|
||||||
# Customizable — replace this script to add .gitignore templates,
|
|
||||||
# default branch config, git-flow, LFS, signing, etc.
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Find project root
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
# Read commit message from extension config, fall back to default
|
|
||||||
$commitMsg = "[Spec Kit] Initial commit"
|
|
||||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
|
||||||
if (Test-Path $configFile) {
|
|
||||||
foreach ($line in Get-Content $configFile) {
|
|
||||||
if ($line -match '^init_commit_message:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
|
||||||
if ($val) { $commitMsg = $val }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if already a git repo
|
|
||||||
try {
|
|
||||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Warning "[specify] Git repository already initialized; skipping"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
try {
|
|
||||||
$out = git init -q 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
|
|
||||||
$out = git add . 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
|
||||||
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[specify] Error: $_"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "✓ Git repository initialized"
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"ai": "copilot",
|
|
||||||
"branch_numbering": "sequential",
|
|
||||||
"context_file": ".github/copilot-instructions.md",
|
|
||||||
"here": true,
|
|
||||||
"integration": "copilot",
|
|
||||||
"preset": null,
|
|
||||||
"script": "sh",
|
|
||||||
"speckit_version": "0.7.4"
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "copilot",
|
|
||||||
"version": "0.7.4"
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "copilot",
|
|
||||||
"version": "0.7.4",
|
|
||||||
"installed_at": "2026-04-22T21:58:02.962169+00:00",
|
|
||||||
"files": {
|
|
||||||
".github/agents/speckit.analyze.agent.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda",
|
|
||||||
".github/agents/speckit.checklist.agent.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde",
|
|
||||||
".github/agents/speckit.clarify.agent.md": "0cc766dcc5cab233ccdf3bc4cfb5759a6d7d1e13e29f611083046f818f5812bb",
|
|
||||||
".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7",
|
|
||||||
".github/agents/speckit.implement.agent.md": "83628415c86ba487b3a083c7a2c0f016c9073abd02c1c7f4a30cff949b6602c0",
|
|
||||||
".github/agents/speckit.plan.agent.md": "2ad128b81ccd8f5bfa78b3b43101f377dfddd8f800fa0856f85bf53b1489b783",
|
|
||||||
".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b",
|
|
||||||
".github/agents/speckit.tasks.agent.md": "a58886f29f75e1a14840007772ddd954742aafb3e03d9d1231bee033e6c1626b",
|
|
||||||
".github/agents/speckit.taskstoissues.agent.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee",
|
|
||||||
".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77",
|
|
||||||
".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c",
|
|
||||||
".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4",
|
|
||||||
".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d",
|
|
||||||
".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80",
|
|
||||||
".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e",
|
|
||||||
".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98",
|
|
||||||
".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f",
|
|
||||||
".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "speckit",
|
|
||||||
"version": "0.7.4",
|
|
||||||
"installed_at": "2026-04-22T21:58:02.965809+00:00",
|
|
||||||
"files": {
|
|
||||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +1,32 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.8.0 -> 2.9.0
|
- Version change: 2.7.0 -> 2.8.0
|
||||||
- Modified principles:
|
- Modified principles: None
|
||||||
- Added provider-boundary guardrail set under First Provider Is Not
|
|
||||||
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
|
|
||||||
- Expanded Governance review expectations for provider-owned vs
|
|
||||||
platform-core boundaries
|
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
|
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
||||||
the current first provider without allowing provider-specific
|
migration shims, dual-write logic, and compatibility fixtures in a
|
||||||
semantics to silently become platform-core truth; requires explicit
|
pre-production codebase; includes AI-agent verification checklist,
|
||||||
review of provider-owned vs platform-core seams and prefers bounded
|
review rule, and explicit exit condition at first production deploy
|
||||||
extraction over speculative multi-provider frameworks
|
- Shared Pattern First For Cross-Cutting Interaction Classes
|
||||||
|
(XCUT-001): requires shared contracts/presenters/builders for
|
||||||
|
notifications, status messaging, action links, dashboard signals,
|
||||||
|
navigation, and similar interaction classes before any local
|
||||||
|
domain-specific variant is allowed
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add provider-boundary platform
|
- .specify/templates/spec-template.md: added "Compatibility posture"
|
||||||
core check ✅
|
default block ✅
|
||||||
- .specify/templates/plan-template.md: add provider-boundary planning
|
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
|
||||||
fields + constitution check ✅
|
reuse block ✅
|
||||||
- .specify/templates/tasks-template.md: add provider-boundary task
|
- .specify/templates/plan-template.md: add shared pattern and system
|
||||||
|
fit section ✅
|
||||||
|
- .specify/templates/tasks-template.md: add cross-cutting reuse task
|
||||||
requirements ✅
|
requirements ✅
|
||||||
- .specify/templates/checklist-template.md: add provider-boundary
|
- .specify/templates/checklist-template.md: add shared-pattern reuse
|
||||||
review checks ✅
|
review checks ✅
|
||||||
|
- .github/agents/copilot-instructions.md: added "Pre-production
|
||||||
|
compatibility check" agent checklist ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -62,15 +66,6 @@ ### No Premature Abstraction (ABSTR-001)
|
|||||||
- Test convenience alone is not sufficient justification for a new abstraction.
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
### First Provider Is Not Platform Core (PROV-001)
|
|
||||||
- Microsoft is the current first provider, not the platform core.
|
|
||||||
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
|
|
||||||
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
|
|
||||||
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
|
|
||||||
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
|
|
||||||
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
|
|
||||||
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
|
|
||||||
|
|
||||||
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
@ -1613,7 +1608,6 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
|
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
|
||||||
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
||||||
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
|
||||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
affected surface under DECIDE-001 and justify any new Primary
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
Decision Surface or workflow-first navigation change.
|
Decision Surface or workflow-first navigation change.
|
||||||
@ -1631,4 +1625,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23
|
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19
|
||||||
|
|||||||
@ -32,23 +32,18 @@ ## Shared Pattern Reuse
|
|||||||
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
||||||
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
||||||
|
|
||||||
## Provider Boundary And Vocabulary
|
|
||||||
|
|
||||||
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
|
|
||||||
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
|
|
||||||
|
|
||||||
## Signals, Exceptions, And Test Depth
|
## Signals, Exceptions, And Test Depth
|
||||||
|
|
||||||
- [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||||
- [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
- [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||||
- [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
# [PROJECT_NAME] Constitution
|
|
||||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
### [PRINCIPLE_1_NAME]
|
|
||||||
<!-- Example: I. Library-First -->
|
|
||||||
[PRINCIPLE_1_DESCRIPTION]
|
|
||||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_2_NAME]
|
|
||||||
<!-- Example: II. CLI Interface -->
|
|
||||||
[PRINCIPLE_2_DESCRIPTION]
|
|
||||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_3_NAME]
|
|
||||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
|
||||||
[PRINCIPLE_3_DESCRIPTION]
|
|
||||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_4_NAME]
|
|
||||||
<!-- Example: IV. Integration Testing -->
|
|
||||||
[PRINCIPLE_4_DESCRIPTION]
|
|
||||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_5_NAME]
|
|
||||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
|
||||||
[PRINCIPLE_5_DESCRIPTION]
|
|
||||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
|
||||||
|
|
||||||
## [SECTION_2_NAME]
|
|
||||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
|
||||||
|
|
||||||
[SECTION_2_CONTENT]
|
|
||||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
|
||||||
|
|
||||||
## [SECTION_3_NAME]
|
|
||||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
|
||||||
|
|
||||||
[SECTION_3_CONTENT]
|
|
||||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
|
||||||
|
|
||||||
## Governance
|
|
||||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
|
||||||
|
|
||||||
[GOVERNANCE_RULES]
|
|
||||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
|
||||||
|
|
||||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
|
||||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
|
||||||
@ -54,17 +54,6 @@ ## Shared Pattern & System Fit
|
|||||||
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
||||||
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
|
|
||||||
- **Provider-owned seams**: [List or `N/A`]
|
|
||||||
- **Platform-core seams**: [List or `N/A`]
|
|
||||||
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
|
|
||||||
- **Retained provider-specific semantics and why**: [none / short explanation]
|
|
||||||
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
|
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
@ -93,7 +82,6 @@ ## Constitution Check
|
|||||||
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
||||||
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
|
|
||||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
|||||||
@ -47,16 +47,6 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
|
|||||||
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
||||||
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: [yes/no]
|
|
||||||
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
|
|
||||||
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
|
|
||||||
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
|
|
||||||
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
|
|
||||||
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
Use this section to classify UI and surface risk once. If the feature does
|
Use this section to classify UI and surface risk once. If the feature does
|
||||||
@ -244,13 +234,6 @@ ## Requirements *(mandatory)*
|
|||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
|
||||||
- classify each touched seam as provider-owned or platform-core,
|
|
||||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
|
||||||
- name the neutral platform terms or shared contracts being preserved,
|
|
||||||
- explain why any retained provider-specific semantics are the narrowest current-release truth,
|
|
||||||
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
||||||
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
||||||
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
||||||
|
|||||||
@ -51,11 +51,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- extending the shared path when it is sufficient for current-release truth,
|
- extending the shared path when it is sufficient for current-release truth,
|
||||||
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
||||||
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
||||||
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
|
|
||||||
- classifying each touched seam as provider-owned or platform-core,
|
|
||||||
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
|
|
||||||
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
|
|
||||||
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
|
|
||||||
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
||||||
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
||||||
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
schema_version: "1.0"
|
|
||||||
workflow:
|
|
||||||
id: "speckit"
|
|
||||||
name: "Full SDD Cycle"
|
|
||||||
version: "1.0.0"
|
|
||||||
author: "GitHub"
|
|
||||||
description: "Runs specify → plan → tasks → implement with review gates"
|
|
||||||
|
|
||||||
requires:
|
|
||||||
speckit_version: ">=0.7.2"
|
|
||||||
integrations:
|
|
||||||
any: ["copilot", "claude", "gemini"]
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
spec:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
prompt: "Describe what you want to build"
|
|
||||||
integration:
|
|
||||||
type: string
|
|
||||||
default: "copilot"
|
|
||||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
default: "full"
|
|
||||||
enum: ["full", "backend-only", "frontend-only"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- id: specify
|
|
||||||
command: speckit.specify
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: review-spec
|
|
||||||
type: gate
|
|
||||||
message: "Review the generated spec before planning."
|
|
||||||
options: [approve, reject]
|
|
||||||
on_reject: abort
|
|
||||||
|
|
||||||
- id: plan
|
|
||||||
command: speckit.plan
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: review-plan
|
|
||||||
type: gate
|
|
||||||
message: "Review the plan before generating tasks."
|
|
||||||
options: [approve, reject]
|
|
||||||
on_reject: abort
|
|
||||||
|
|
||||||
- id: tasks
|
|
||||||
command: speckit.tasks
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: implement
|
|
||||||
command: speckit.implement
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"workflows": {
|
|
||||||
"speckit": {
|
|
||||||
"name": "Full SDD Cycle",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
|
||||||
"source": "bundled",
|
|
||||||
"installed_at": "2026-04-22T21:58:03.039039+00:00",
|
|
||||||
"updated_at": "2026-04-22T21:58:03.039046+00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
||||||
@ -1 +1 @@
|
|||||||
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
||||||
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
||||||
File diff suppressed because one or more lines are too long
@ -67,6 +67,7 @@ public function handle(): int
|
|||||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||||
'tenant_id' => $tenantRouteKey,
|
'tenant_id' => $tenantRouteKey,
|
||||||
'app_certificate_thumbprint' => null,
|
'app_certificate_thumbprint' => null,
|
||||||
|
'app_status' => 'ok',
|
||||||
'app_notes' => null,
|
'app_notes' => null,
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
'environment' => 'dev',
|
'environment' => 'dev',
|
||||||
|
|||||||
@ -20,7 +20,6 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Inventory\TenantCoverageTruth;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -536,7 +535,7 @@ public function basisRunSummary(): array
|
|||||||
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
||||||
'badgeLabel' => $badge->label,
|
'badgeLabel' => $badge->label,
|
||||||
'badgeColor' => $badge->color,
|
'badgeColor' => $badge->color,
|
||||||
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
];
|
];
|
||||||
@ -561,6 +560,13 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
|||||||
|
|
||||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
return route('admin.operations.index', [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'tableFilters' => [
|
||||||
|
'type' => [
|
||||||
|
'value' => 'inventory_sync',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,14 +110,14 @@ protected function getHeaderActions(): array
|
|||||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||||
->label('Back to Operations')
|
->label('Back to Operations')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => OperationRunLinks::index());
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
if ($activeTenant instanceof Tenant) {
|
||||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||||
->label('Show all operations')
|
->label('Show all operations')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => OperationRunLinks::index());
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('refresh')
|
$actions[] = Action::make('refresh')
|
||||||
@ -126,7 +126,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (): string => isset($this->run)
|
->url(fn (): string => isset($this->run)
|
||||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||||
: OperationRunLinks::index());
|
: route('admin.operations.index'));
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
@ -353,14 +352,7 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
|||||||
|
|
||||||
private function reviewOutcomeDescription(TenantReview $record): ?string
|
private function reviewOutcomeDescription(TenantReview $record): ?string
|
||||||
{
|
{
|
||||||
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
return $this->reviewOutcome($record)->primaryReason;
|
||||||
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
|
||||||
|
|
||||||
if ($findingOutcomeSummary === null) {
|
|
||||||
return $primaryReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewOutcomeNextStep(TenantReview $record): string
|
private function reviewOutcomeNextStep(TenantReview $record): string
|
||||||
@ -381,16 +373,4 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
|
|||||||
SurfaceCompressionContext::reviewRegister(),
|
SurfaceCompressionContext::reviewRegister(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findingOutcomeSummary(TenantReview $record): ?string
|
|
||||||
{
|
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
|
||||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
|
||||||
|
|
||||||
if (! is_array($outcomeCounts)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -598,9 +598,7 @@ public function content(Schema $schema): Schema
|
|||||||
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
||||||
? null
|
? null
|
||||||
: 'You do not have permission to start bootstrap actions.')
|
: 'You do not have permission to start bootstrap actions.')
|
||||||
->action(fn (Get $get) => $this->startBootstrap(
|
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
|
||||||
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
|
|
||||||
)),
|
|
||||||
]),
|
]),
|
||||||
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
||||||
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
||||||
@ -608,11 +606,9 @@ public function content(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->afterValidation(function (): void {
|
->afterValidation(function (): void {
|
||||||
$types = $this->data['bootstrap_operation_types'] ?? [];
|
$types = $this->data['bootstrap_operation_types'] ?? [];
|
||||||
$this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
|
$this->selectedBootstrapOperationTypes = is_array($types)
|
||||||
is_array($types) ? $types : [],
|
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
|
||||||
);
|
: [];
|
||||||
|
|
||||||
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
|
|
||||||
|
|
||||||
$this->touchOnboardingSessionStep('bootstrap');
|
$this->touchOnboardingSessionStep('bootstrap');
|
||||||
}),
|
}),
|
||||||
@ -646,10 +642,6 @@ public function content(Schema $schema): Schema
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||||
]),
|
]),
|
||||||
Callout::make('Bootstrap needs attention')
|
|
||||||
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
|
||||||
->warning()
|
|
||||||
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
|
|
||||||
Callout::make('After completion')
|
Callout::make('After completion')
|
||||||
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
||||||
->info()
|
->info()
|
||||||
@ -741,111 +733,10 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
|||||||
|
|
||||||
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
||||||
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
||||||
? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
|
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int|string, mixed> $operationTypes
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
|
||||||
{
|
|
||||||
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
|
||||||
$normalized = [];
|
|
||||||
|
|
||||||
foreach ($operationTypes as $key => $value) {
|
|
||||||
if (is_string($value)) {
|
|
||||||
$normalizedValue = trim($value);
|
|
||||||
|
|
||||||
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
|
||||||
$normalized[] = $normalizedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($key) || trim($key) === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isSelected = match (true) {
|
|
||||||
is_bool($value) => $value,
|
|
||||||
is_int($value) => $value === 1,
|
|
||||||
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
$normalizedKey = trim($key);
|
|
||||||
|
|
||||||
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
|
||||||
$normalized[] = $normalizedKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($normalized));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function supportedBootstrapCapabilities(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
|
||||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $operationTypes
|
|
||||||
*/
|
|
||||||
private function persistBootstrapSelection(array $operationTypes): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
|
|
||||||
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
||||||
$existing = is_array($existing)
|
|
||||||
? $this->normalizeBootstrapOperationTypes($existing)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if ($normalized === $existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->setOnboardingSession($this->mutationService()->mutate(
|
|
||||||
draft: $this->onboardingSession,
|
|
||||||
actor: $user,
|
|
||||||
expectedVersion: $this->expectedDraftVersion(),
|
|
||||||
incrementVersion: false,
|
|
||||||
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
|
|
||||||
$state = is_array($draft->state) ? $draft->state : [];
|
|
||||||
$state['bootstrap_operation_types'] = $normalized;
|
|
||||||
|
|
||||||
$draft->state = $state;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
} catch (OnboardingDraftConflictException) {
|
|
||||||
$this->handleDraftConflict();
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (OnboardingDraftImmutableException) {
|
|
||||||
$this->handleImmutableDraft();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, TenantOnboardingSession>
|
* @return Collection<int, TenantOnboardingSession>
|
||||||
*/
|
*/
|
||||||
@ -1573,7 +1464,6 @@ private function initializeWizardData(): void
|
|||||||
// Ensure all entangled schema state paths exist at render time.
|
// Ensure all entangled schema state paths exist at render time.
|
||||||
// Livewire v4 can throw when entangling to missing nested array keys.
|
// Livewire v4 can throw when entangling to missing nested array keys.
|
||||||
$this->data['notes'] ??= '';
|
$this->data['notes'] ??= '';
|
||||||
$this->data['bootstrap_operation_types'] ??= [];
|
|
||||||
$this->data['override_blocked'] ??= false;
|
$this->data['override_blocked'] ??= false;
|
||||||
$this->data['override_reason'] ??= '';
|
$this->data['override_reason'] ??= '';
|
||||||
$this->data['new_connection'] ??= [];
|
$this->data['new_connection'] ??= [];
|
||||||
@ -1644,7 +1534,7 @@ private function initializeWizardData(): void
|
|||||||
|
|
||||||
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
||||||
if (is_array($types)) {
|
if (is_array($types)) {
|
||||||
$this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
|
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3076,7 +2966,7 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$registry = app(ProviderOperationRegistry::class);
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
$types = $this->normalizeBootstrapOperationTypes($operationTypes);
|
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
|
||||||
|
|
||||||
$types = array_values(array_filter(
|
$types = array_values(array_filter(
|
||||||
$types,
|
$types,
|
||||||
@ -3346,18 +3236,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
|
|||||||
|
|
||||||
private function resolveBootstrapCapability(string $operationType): ?string
|
private function resolveBootstrapCapability(string $operationType): ?string
|
||||||
{
|
{
|
||||||
return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
|
return match ($operationType) {
|
||||||
|
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canStartAnyBootstrap(): bool
|
private function canStartAnyBootstrap(): bool
|
||||||
{
|
{
|
||||||
foreach ($this->supportedBootstrapCapabilities() as $capability) {
|
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC)
|
||||||
if ($this->currentUserCan($capability)) {
|
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC)
|
||||||
return true;
|
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentUserCan(string $capability): bool
|
private function currentUserCan(string $capability): bool
|
||||||
@ -3608,59 +3498,33 @@ private function completionSummaryVerificationDetail(): string
|
|||||||
private function completionSummaryBootstrapLabel(): string
|
private function completionSummaryBootstrapLabel(): string
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
return $this->completionSummarySelectedBootstrapTypes() === []
|
return 'Skipped';
|
||||||
? 'Skipped'
|
|
||||||
: 'Selected';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
|
|
||||||
return 'Action required';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||||
$runs = is_array($runs) ? $runs : [];
|
$runs = is_array($runs) ? $runs : [];
|
||||||
|
|
||||||
if ($runs !== []) {
|
if ($runs === []) {
|
||||||
return 'Started';
|
return 'Skipped';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completionSummarySelectedBootstrapTypes() === []
|
return 'Started';
|
||||||
? 'Skipped'
|
|
||||||
: 'Selected';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function completionSummaryBootstrapDetail(): string
|
private function completionSummaryBootstrapDetail(): string
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
return 'No bootstrap actions selected';
|
||||||
|
|
||||||
return $selectedTypes === []
|
|
||||||
? 'No bootstrap actions selected'
|
|
||||||
: sprintf('%d action(s) selected', count($selectedTypes));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||||
$runs = is_array($runs) ? $runs : [];
|
$runs = is_array($runs) ? $runs : [];
|
||||||
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
|
||||||
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
|
|
||||||
|
|
||||||
if ($selectedTypes === []) {
|
if ($runs === []) {
|
||||||
return 'No bootstrap actions selected';
|
return 'No bootstrap actions selected';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($actionRequiredDetail !== null) {
|
return sprintf('%d operation(s) started', count($runs));
|
||||||
return $actionRequiredDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($runs === []) {
|
|
||||||
return sprintf('%d action(s) selected', count($selectedTypes));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($runs) < count($selectedTypes)) {
|
|
||||||
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf('%d action(s) started', count($runs));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function completionSummaryBootstrapSummary(): string
|
private function completionSummaryBootstrapSummary(): string
|
||||||
@ -3672,130 +3536,11 @@ private function completionSummaryBootstrapSummary(): string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function showCompletionSummaryBootstrapRecovery(): bool
|
|
||||||
{
|
|
||||||
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryBootstrapRecoveryMessage(): string
|
|
||||||
{
|
|
||||||
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryBootstrapColor(): string
|
private function completionSummaryBootstrapColor(): string
|
||||||
{
|
{
|
||||||
return match ($this->completionSummaryBootstrapLabel()) {
|
return $this->completionSummaryBootstrapLabel() === 'Started'
|
||||||
'Action required' => 'warning',
|
? 'info'
|
||||||
'Started' => 'info',
|
: 'gray';
|
||||||
'Selected' => 'warning',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryBootstrapActionRequiredDetail(): ?string
|
|
||||||
{
|
|
||||||
$reasonCode = $this->completionSummaryBootstrapReasonCode();
|
|
||||||
|
|
||||||
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $this->completionSummaryBootstrapFailedRun();
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return $reasonCode === 'bootstrap_partial_failure'
|
|
||||||
? 'A bootstrap action needs attention'
|
|
||||||
: 'A bootstrap action failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
||||||
$operatorLabel = data_get($context, 'reason_translation.operator_label');
|
|
||||||
|
|
||||||
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
|
|
||||||
return trim($operatorLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($run->outcome) {
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
|
|
||||||
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
|
|
||||||
default => 'A bootstrap action failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryBootstrapReasonCode(): ?string
|
|
||||||
{
|
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reasonCode) ? $reasonCode : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryBootstrapFailedRun(): ?OperationRun
|
|
||||||
{
|
|
||||||
return once(function (): ?OperationRun {
|
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($runMap)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runIds = array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
|
|
||||||
$runMap,
|
|
||||||
)));
|
|
||||||
|
|
||||||
if ($runIds === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->whereIn('id', $runIds)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereIn('outcome', [
|
|
||||||
OperationRunOutcome::Blocked->value,
|
|
||||||
OperationRunOutcome::Failed->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
])
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function completionSummarySelectedBootstrapTypes(): array
|
|
||||||
{
|
|
||||||
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
|
|
||||||
|
|
||||||
if (is_array($selectedTypes)) {
|
|
||||||
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
|
|
||||||
|
|
||||||
if ($normalized !== []) {
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->selectedBootstrapOperationTypes !== []) {
|
|
||||||
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
||||||
|
|
||||||
return is_array($persistedTypes)
|
|
||||||
? $this->normalizeBootstrapOperationTypes($persistedTypes)
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeOnboarding(): void
|
public function completeOnboarding(): void
|
||||||
@ -4394,10 +4139,9 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
private function bootstrapOperationOptions(): array
|
private function bootstrapOperationOptions(): array
|
||||||
{
|
{
|
||||||
$registry = app(ProviderOperationRegistry::class);
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
|
||||||
|
|
||||||
return collect($registry->all())
|
return collect($registry->all())
|
||||||
->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
|
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check')
|
||||||
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -841,17 +840,7 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|||||||
|
|
||||||
private static function profileNextStep(BaselineProfile $profile): string
|
private static function profileNextStep(BaselineProfile $profile): string
|
||||||
{
|
{
|
||||||
$compareAvailabilityReason = self::compareAvailabilityReason($profile);
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
|
|
||||||
if ($compareAvailabilityReason === null) {
|
|
||||||
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
|
|
||||||
|
|
||||||
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
|
||||||
return $latestCaptureEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($compareAvailabilityReason) {
|
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||||
@ -869,30 +858,6 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
|
|||||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$run = OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('type', 'baseline_capture')
|
|
||||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->where('status', 'completed')
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = data_get($run->context, 'reason_code');
|
|
||||||
|
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
$status = $profile->status instanceof BaselineProfileStatus
|
$status = $profile->status instanceof BaselineProfileStatus
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -106,10 +105,15 @@ private function captureAction(): Action
|
|||||||
|
|
||||||
if (! $result['ok']) {
|
if (! $result['ok']) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
$message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
|
$message = match ($reasonCode) {
|
||||||
? trim($translation->shortExplanation)
|
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
||||||
: 'Reason: '.str_replace('.', ' ', $reasonCode);
|
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
|
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
||||||
|
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
|
||||||
|
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
|
||||||
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start capture')
|
->title('Cannot start capture')
|
||||||
|
|||||||
@ -21,7 +21,6 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
@ -157,14 +156,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||||
TextEntry::make('finding_terminal_outcome')
|
|
||||||
->label('Terminal outcome')
|
|
||||||
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
|
|
||||||
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
|
|
||||||
TextEntry::make('finding_verification_state')
|
|
||||||
->label('Verification')
|
|
||||||
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
|
|
||||||
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
|
|
||||||
TextEntry::make('severity')
|
TextEntry::make('severity')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
@ -301,15 +292,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
|
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('resolved_reason')
|
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
|
||||||
->label('Resolved reason')
|
|
||||||
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('closed_reason')
|
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
|
||||||
->label('Closed/risk reason')
|
|
||||||
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('closed_by_user_id')
|
TextEntry::make('closed_by_user_id')
|
||||||
->label('Closed by')
|
->label('Closed by')
|
||||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||||
@ -741,7 +726,7 @@ public static function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||||
->description(fn (Finding $record): string => static::statusDescription($record)),
|
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||||
Tables\Columns\TextColumn::make('governance_validity')
|
Tables\Columns\TextColumn::make('governance_validity')
|
||||||
->label('Governance')
|
->label('Governance')
|
||||||
->badge()
|
->badge()
|
||||||
@ -835,14 +820,6 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(FilterOptionCatalog::findingStatuses())
|
->options(FilterOptionCatalog::findingStatuses())
|
||||||
->label('Status'),
|
->label('Status'),
|
||||||
Tables\Filters\SelectFilter::make('terminal_outcome')
|
|
||||||
->label('Terminal outcome')
|
|
||||||
->options(FilterOptionCatalog::findingTerminalOutcomes())
|
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
|
|
||||||
Tables\Filters\SelectFilter::make('verification_state')
|
|
||||||
->label('Verification')
|
|
||||||
->options(FilterOptionCatalog::findingVerificationStates())
|
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
|
|
||||||
Tables\Filters\SelectFilter::make('workflow_family')
|
Tables\Filters\SelectFilter::make('workflow_family')
|
||||||
->label('Workflow family')
|
->label('Workflow family')
|
||||||
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||||
@ -1115,20 +1092,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('resolve_selected')
|
BulkAction::make('resolve_selected')
|
||||||
->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
|
->label('Resolve selected')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
|
|
||||||
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
|
|
||||||
->form([
|
->form([
|
||||||
Select::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
->label('Resolution outcome')
|
->label('Resolution reason')
|
||||||
->options(static::resolveReasonOptions())
|
->rows(3)
|
||||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
|
||||||
->native(false)
|
|
||||||
->required()
|
->required()
|
||||||
->selectablePlaceholder(false),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -1172,7 +1145,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
|
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
|
||||||
if ($skippedCount > 0) {
|
if ($skippedCount > 0) {
|
||||||
$body .= " Skipped {$skippedCount}.";
|
$body .= " Skipped {$skippedCount}.";
|
||||||
}
|
}
|
||||||
@ -1194,20 +1167,18 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('close_selected')
|
BulkAction::make('close_selected')
|
||||||
->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
|
->label('Close selected')
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
||||||
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
||||||
->form([
|
->form([
|
||||||
Select::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
->label('Close reason')
|
->label('Close reason')
|
||||||
->options(static::closeReasonOptions())
|
->rows(3)
|
||||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
|
||||||
->native(false)
|
|
||||||
->required()
|
->required()
|
||||||
->selectablePlaceholder(false),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -1477,30 +1448,24 @@ public static function assignAction(): Actions\Action
|
|||||||
|
|
||||||
public static function resolveAction(): Actions\Action
|
public static function resolveAction(): Actions\Action
|
||||||
{
|
{
|
||||||
$rule = GovernanceActionCatalog::rule('resolve_finding');
|
|
||||||
|
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('resolve')
|
Actions\Action::make('resolve')
|
||||||
->label($rule->canonicalLabel)
|
->label('Resolve')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading($rule->modalHeading)
|
|
||||||
->modalDescription($rule->modalDescription)
|
|
||||||
->form([
|
->form([
|
||||||
Select::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
->label('Resolution outcome')
|
->label('Resolution reason')
|
||||||
->options(static::resolveReasonOptions())
|
->rows(3)
|
||||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
|
||||||
->native(false)
|
|
||||||
->required()
|
->required()
|
||||||
->selectablePlaceholder(false),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
successTitle: $rule->successTitle,
|
successTitle: 'Finding resolved',
|
||||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||||
$finding,
|
$finding,
|
||||||
$tenant,
|
$tenant,
|
||||||
@ -1530,13 +1495,11 @@ public static function closeAction(): Actions\Action
|
|||||||
->modalHeading($rule->modalHeading)
|
->modalHeading($rule->modalHeading)
|
||||||
->modalDescription($rule->modalDescription)
|
->modalDescription($rule->modalDescription)
|
||||||
->form([
|
->form([
|
||||||
Select::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
->label('Close reason')
|
->label('Close reason')
|
||||||
->options(static::closeReasonOptions())
|
->rows(3)
|
||||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
|
||||||
->native(false)
|
|
||||||
->required()
|
->required()
|
||||||
->selectablePlaceholder(false),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
@ -1731,17 +1694,12 @@ public static function reopenAction(): Actions\Action
|
|||||||
->modalHeading($rule->modalHeading)
|
->modalHeading($rule->modalHeading)
|
||||||
->modalDescription($rule->modalDescription)
|
->modalDescription($rule->modalDescription)
|
||||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||||
->fillForm([
|
|
||||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
|
||||||
])
|
|
||||||
->form([
|
->form([
|
||||||
Select::make('reopen_reason')
|
Textarea::make('reopen_reason')
|
||||||
->label('Reopen reason')
|
->label('Reopen reason')
|
||||||
->options(static::reopenReasonOptions())
|
->rows(3)
|
||||||
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
|
|
||||||
->native(false)
|
|
||||||
->required()
|
->required()
|
||||||
->selectablePlaceholder(false),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
@ -2180,150 +2138,6 @@ private static function governanceValidityState(Finding $finding): ?string
|
|||||||
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
|
|
||||||
{
|
|
||||||
return app(FindingOutcomeSemantics::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* terminal_outcome_key: ?string,
|
|
||||||
* label: ?string,
|
|
||||||
* verification_state: string,
|
|
||||||
* verification_label: ?string,
|
|
||||||
* report_bucket: ?string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private static function findingOutcome(Finding $finding): array
|
|
||||||
{
|
|
||||||
return static::findingOutcomeSemantics()->describe($finding);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function resolveReasonOptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function closeReasonOptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
|
|
||||||
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
|
|
||||||
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function reopenReasonOptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
|
|
||||||
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
|
|
||||||
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveReasonLabel(?string $reason): ?string
|
|
||||||
{
|
|
||||||
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
|
|
||||||
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
|
|
||||||
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
|
|
||||||
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
|
|
||||||
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
|
|
||||||
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function closeReasonLabel(?string $reason): ?string
|
|
||||||
{
|
|
||||||
return static::closeReasonOptions()[$reason] ?? match ($reason) {
|
|
||||||
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reopenReasonLabel(?string $reason): ?string
|
|
||||||
{
|
|
||||||
return static::reopenReasonOptions()[$reason] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function terminalOutcomeLabel(Finding $finding): ?string
|
|
||||||
{
|
|
||||||
return static::findingOutcome($finding)['label'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function verificationStateLabel(Finding $finding): ?string
|
|
||||||
{
|
|
||||||
return static::findingOutcome($finding)['verification_label'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function statusDescription(Finding $finding): string
|
|
||||||
{
|
|
||||||
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
|
|
||||||
{
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
|
|
||||||
->where('status', Finding::STATUS_RESOLVED)
|
|
||||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
|
||||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
|
|
||||||
->where('status', Finding::STATUS_RESOLVED)
|
|
||||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
|
|
||||||
->where('status', Finding::STATUS_CLOSED)
|
|
||||||
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
|
|
||||||
->where('status', Finding::STATUS_CLOSED)
|
|
||||||
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
|
|
||||||
->where('status', Finding::STATUS_CLOSED)
|
|
||||||
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
|
|
||||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
|
|
||||||
->where('status', Finding::STATUS_RISK_ACCEPTED),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
|
|
||||||
{
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
|
|
||||||
->where('status', Finding::STATUS_RESOLVED)
|
|
||||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
|
||||||
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
|
|
||||||
->where('status', Finding::STATUS_RESOLVED)
|
|
||||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
|
||||||
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
|
|
||||||
$verificationQuery
|
|
||||||
->where('status', '!=', Finding::STATUS_RESOLVED)
|
|
||||||
->orWhereNull('resolved_reason')
|
|
||||||
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
|
|
||||||
}),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function primaryNarrative(Finding $finding): string
|
private static function primaryNarrative(Finding $finding): string
|
||||||
{
|
{
|
||||||
return app(FindingRiskGovernanceResolver::class)
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -149,13 +148,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id);
|
|
||||||
})
|
})
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
TextEntry::make('support_restore')
|
TextEntry::make('support_restore')
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
@ -200,19 +199,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('operationRun.id')
|
TextEntry::make('operationRun.id')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->url(function (ReviewPack $record): ?string {
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||||
if (! $record->operation_run_id) {
|
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||||
return null;
|
: null)
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
|
||||||
})
|
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -541,19 +540,12 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
$truthEnvelope = static::truthEnvelope($record);
|
$truthEnvelope = static::truthEnvelope($record);
|
||||||
$reasonPresenter = app(ReasonPresenter::class);
|
$reasonPresenter = app(ReasonPresenter::class);
|
||||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
|
||||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
|
||||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
|
||||||
|
|
||||||
if ($findingOutcomeSummary !== null) {
|
|
||||||
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||||
'highlights' => $highlights,
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
'context_links' => static::summaryContextLinks($record),
|
'context_links' => static::summaryContextLinks($record),
|
||||||
@ -562,8 +554,6 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||||
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
|
||||||
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -665,18 +655,4 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
|
|||||||
SurfaceCompressionContext::tenantReview(),
|
SurfaceCompressionContext::tenantReview(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $summary
|
|
||||||
*/
|
|
||||||
private static function findingOutcomeSummary(array $summary): ?string
|
|
||||||
{
|
|
||||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
|
||||||
|
|
||||||
if (! is_array($outcomeCounts)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ protected function getViewData(): array
|
|||||||
return [
|
return [
|
||||||
'tenant' => null,
|
'tenant' => null,
|
||||||
'runs' => collect(),
|
'runs' => collect(),
|
||||||
'operationsIndexUrl' => OperationRunLinks::index(),
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||||
];
|
];
|
||||||
@ -68,7 +68,7 @@ protected function getViewData(): array
|
|||||||
return [
|
return [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'runs' => $runs,
|
'runs' => $runs,
|
||||||
'operationsIndexUrl' => OperationRunLinks::index($tenant),
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
@ -55,8 +54,6 @@ public function __invoke(
|
|||||||
error: $error,
|
error: $error,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
|
|
||||||
|
|
||||||
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
||||||
$auditMetadata = [
|
$auditMetadata = [
|
||||||
'source' => 'admin.consent.callback',
|
'source' => 'admin.consent.callback',
|
||||||
@ -101,7 +98,6 @@ public function __invoke(
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'consentGranted' => $consentGranted,
|
'consentGranted' => $consentGranted,
|
||||||
'verificationStateLabel' => $this->verificationStateLabel($connection),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,48 +197,4 @@ private function parseState(?string $state): ?string
|
|||||||
|
|
||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function verificationStateLabel(ProviderConnection $connection): string
|
|
||||||
{
|
|
||||||
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
|
|
||||||
? $connection->verification_status
|
|
||||||
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
|
|
||||||
|
|
||||||
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
|
|
||||||
return $connection->consent_status === ProviderConsentStatus::Granted
|
|
||||||
? 'Needs verification'
|
|
||||||
: 'Not verified';
|
|
||||||
}
|
|
||||||
|
|
||||||
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
|
|
||||||
{
|
|
||||||
TenantOnboardingSession::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->resumable()
|
|
||||||
->each(function (TenantOnboardingSession $draft) use ($connection): void {
|
|
||||||
$state = is_array($draft->state) ? $draft->state : [];
|
|
||||||
$providerConnectionId = $state['provider_connection_id'] ?? null;
|
|
||||||
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
|
||||||
|
|
||||||
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unset(
|
|
||||||
$state['verification_operation_run_id'],
|
|
||||||
$state['verification_run_id'],
|
|
||||||
$state['bootstrap_operation_runs'],
|
|
||||||
$state['bootstrap_operation_types'],
|
|
||||||
$state['bootstrap_run_ids'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$state['connection_recently_updated'] = true;
|
|
||||||
|
|
||||||
$draft->state = $state;
|
|
||||||
$draft->save();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -345,12 +345,9 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
|||||||
}
|
}
|
||||||
|
|
||||||
$finding->forceFill([
|
$finding->forceFill([
|
||||||
'status' => Finding::STATUS_CLOSED,
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
'resolved_at' => null,
|
'resolved_at' => $backfillStartedAt,
|
||||||
'resolved_reason' => null,
|
'resolved_reason' => 'consolidated_duplicate',
|
||||||
'closed_at' => $backfillStartedAt,
|
|
||||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
|
||||||
'closed_by_user_id' => null,
|
|
||||||
'recurrence_key' => null,
|
'recurrence_key' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
|||||||
@ -325,12 +325,9 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
|||||||
}
|
}
|
||||||
|
|
||||||
$finding->forceFill([
|
$finding->forceFill([
|
||||||
'status' => Finding::STATUS_CLOSED,
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
'resolved_at' => null,
|
'resolved_at' => $backfillStartedAt,
|
||||||
'resolved_reason' => null,
|
'resolved_reason' => 'consolidated_duplicate',
|
||||||
'closed_at' => $backfillStartedAt,
|
|
||||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
|
||||||
'closed_by_user_id' => null,
|
|
||||||
'recurrence_key' => null,
|
'recurrence_key' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Baselines\BaselineCaptureService;
|
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||||
@ -30,6 +29,7 @@
|
|||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -71,24 +71,13 @@ public function handle(
|
|||||||
InventoryMetaContract $metaContract,
|
InventoryMetaContract $metaContract,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
mixed $arg5 = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
mixed $arg6 = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$captureService = $arg5 instanceof BaselineCaptureService
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
? $arg5
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
: app(BaselineCaptureService::class);
|
|
||||||
$hashResolver = $arg5 instanceof CurrentStateHashResolver
|
|
||||||
? $arg5
|
|
||||||
: ($arg6 instanceof CurrentStateHashResolver
|
|
||||||
? $arg6
|
|
||||||
: app(CurrentStateHashResolver::class));
|
|
||||||
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
|
|
||||||
? $arg5
|
|
||||||
: ($arg6 instanceof BaselineContentCapturePhase
|
|
||||||
? $arg6
|
|
||||||
: app(BaselineContentCapturePhase::class));
|
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
@ -129,124 +118,10 @@ public function handle(
|
|||||||
$rolloutGate->assertEnabled();
|
$rolloutGate->assertEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
$previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant);
|
||||||
$previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
|
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun
|
||||||
? (int) $previousCurrentSnapshot->getKey()
|
? (int) $latestInventorySyncRun->getKey()
|
||||||
: null;
|
: null;
|
||||||
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
|
|
||||||
|
|
||||||
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
|
|
||||||
? data_get($context, 'baseline_capture.eligibility')
|
|
||||||
: [];
|
|
||||||
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
|
|
||||||
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
|
|
||||||
? (int) $inventoryEligibility['inventory_sync_run_id']
|
|
||||||
: null;
|
|
||||||
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
|
|
||||||
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
|
|
||||||
&& ! ($inventoryEligibility['ok'] ?? false);
|
|
||||||
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
|
|
||||||
? (int) $preflightEligibility['inventory_sync_run_id']
|
|
||||||
: null;
|
|
||||||
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
|
|
||||||
? (string) $preflightEligibility['reason_code']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$context['baseline_capture'] = array_merge(
|
|
||||||
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
|
|
||||||
[
|
|
||||||
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
|
||||||
'eligibility' => $eligibilityContext,
|
|
||||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
|
||||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$this->operationRun->update(['context' => $context]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
|
|
||||||
if (! ($inventoryEligibility['ok'] ?? false)) {
|
|
||||||
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
|
|
||||||
? (string) $inventoryEligibility['reason_code']
|
|
||||||
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
|
|
||||||
$summaryCounts = [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
'succeeded' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
];
|
|
||||||
$blockedContext = $context;
|
|
||||||
$blockedContext['reason_code'] = $reasonCode;
|
|
||||||
$blockedContext['baseline_capture'] = array_merge(
|
|
||||||
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
|
|
||||||
[
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'subjects_total' => 0,
|
|
||||||
'current_baseline_changed' => false,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$blockedContext['result'] = array_merge(
|
|
||||||
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
|
|
||||||
[
|
|
||||||
'current_baseline_changed' => false,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRun->update([
|
|
||||||
'context' => $blockedContext,
|
|
||||||
'summary_counts' => $summaryCounts,
|
|
||||||
]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
$this->auditStarted(
|
|
||||||
auditLogger: $auditLogger,
|
|
||||||
tenant: $sourceTenant,
|
|
||||||
profile: $profile,
|
|
||||||
initiator: $initiator,
|
|
||||||
captureMode: $captureMode,
|
|
||||||
subjectsTotal: 0,
|
|
||||||
effectiveScope: $effectiveScope,
|
|
||||||
inventorySyncRunId: $latestInventorySyncRunId,
|
|
||||||
);
|
|
||||||
|
|
||||||
$operationRunService->finalizeBlockedRun(
|
|
||||||
run: $this->operationRun,
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
message: $this->blockedInventoryMessage(
|
|
||||||
$reasonCode,
|
|
||||||
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
$this->auditCompleted(
|
|
||||||
auditLogger: $auditLogger,
|
|
||||||
tenant: $sourceTenant,
|
|
||||||
profile: $profile,
|
|
||||||
snapshot: null,
|
|
||||||
initiator: $initiator,
|
|
||||||
captureMode: $captureMode,
|
|
||||||
subjectsTotal: 0,
|
|
||||||
inventorySyncRunId: $latestInventorySyncRunId,
|
|
||||||
wasNewSnapshot: false,
|
|
||||||
evidenceCaptureStats: [
|
|
||||||
'requested' => 0,
|
|
||||||
'succeeded' => 0,
|
|
||||||
'skipped' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'throttled' => 0,
|
|
||||||
],
|
|
||||||
gaps: [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
],
|
|
||||||
currentBaselineChanged: false,
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventoryResult = $this->collectInventorySubjects(
|
$inventoryResult = $this->collectInventorySubjects(
|
||||||
sourceTenant: $sourceTenant,
|
sourceTenant: $sourceTenant,
|
||||||
@ -279,7 +154,6 @@ public function handle(
|
|||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'throttled' => 0,
|
'throttled' => 0,
|
||||||
];
|
];
|
||||||
$phaseResult = [];
|
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
@ -348,91 +222,6 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($subjectsTotal === 0) {
|
|
||||||
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
|
||||||
$profile,
|
|
||||||
$identityHash,
|
|
||||||
$snapshotSummary,
|
|
||||||
);
|
|
||||||
$snapshot = $snapshotResult['snapshot'];
|
|
||||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
|
||||||
$summaryCounts = [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
'succeeded' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
];
|
|
||||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
|
|
||||||
$updatedContext['baseline_capture'] = array_merge(
|
|
||||||
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
|
||||||
[
|
|
||||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
'subjects_total' => 0,
|
|
||||||
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
|
||||||
'evidence_capture' => $phaseStats,
|
|
||||||
'gaps' => [
|
|
||||||
'count' => $gapsCount,
|
|
||||||
'by_reason' => $gapsByReason,
|
|
||||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
|
||||||
? array_values($phaseResult['gap_subjects'])
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
'resume_token' => $resumeToken,
|
|
||||||
'current_baseline_changed' => false,
|
|
||||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
|
||||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$updatedContext['result'] = array_merge(
|
|
||||||
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
|
|
||||||
[
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_identity_hash' => $identityHash,
|
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
|
||||||
'items_captured' => 0,
|
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
'current_baseline_changed' => false,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRun->update([
|
|
||||||
'context' => $updatedContext,
|
|
||||||
'summary_counts' => $summaryCounts,
|
|
||||||
]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
$this->auditCompleted(
|
|
||||||
auditLogger: $auditLogger,
|
|
||||||
tenant: $sourceTenant,
|
|
||||||
profile: $profile,
|
|
||||||
snapshot: $snapshot,
|
|
||||||
initiator: $initiator,
|
|
||||||
captureMode: $captureMode,
|
|
||||||
subjectsTotal: 0,
|
|
||||||
inventorySyncRunId: $latestInventorySyncRunId,
|
|
||||||
wasNewSnapshot: $wasNewSnapshot,
|
|
||||||
evidenceCaptureStats: $phaseStats,
|
|
||||||
gaps: [
|
|
||||||
'count' => $gapsCount,
|
|
||||||
'by_reason' => $gapsByReason,
|
|
||||||
],
|
|
||||||
currentBaselineChanged: false,
|
|
||||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshotResult = $this->captureSnapshotArtifact(
|
$snapshotResult = $this->captureSnapshotArtifact(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
@ -447,9 +236,6 @@ public function handle(
|
|||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile->refresh();
|
|
||||||
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
|
|
||||||
|
|
||||||
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
||||||
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
||||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||||
@ -483,9 +269,6 @@ public function handle(
|
|||||||
: null,
|
: null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'current_baseline_changed' => $currentBaselineChanged,
|
|
||||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
|
||||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
$updatedContext['result'] = [
|
$updatedContext['result'] = [
|
||||||
@ -494,7 +277,6 @@ public function handle(
|
|||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
'current_baseline_changed' => $currentBaselineChanged,
|
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -513,8 +295,6 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
],
|
],
|
||||||
currentBaselineChanged: $currentBaselineChanged,
|
|
||||||
reasonCode: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -871,51 +651,6 @@ private function captureSnapshotArtifact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $summaryJsonb
|
|
||||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
|
||||||
*/
|
|
||||||
private function captureNoDataSnapshotArtifact(
|
|
||||||
BaselineProfile $profile,
|
|
||||||
string $identityHash,
|
|
||||||
array $summaryJsonb,
|
|
||||||
): array {
|
|
||||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: 0,
|
|
||||||
persistedItems: 0,
|
|
||||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
);
|
|
||||||
|
|
||||||
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => 0,
|
|
||||||
'persisted_items' => 0,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot->refresh();
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: 0,
|
|
||||||
persistedItems: 0,
|
|
||||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'snapshot' => $snapshot,
|
|
||||||
'was_new_snapshot' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||||
{
|
{
|
||||||
$existing = BaselineSnapshot::query()
|
$existing = BaselineSnapshot::query()
|
||||||
@ -1048,32 +783,6 @@ private function countByPolicyType(array $items): array
|
|||||||
return $counts;
|
return $counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
|
|
||||||
{
|
|
||||||
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
|
||||||
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
|
|
||||||
? (int) $currentSnapshot->getKey()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
|
|
||||||
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
|
|
||||||
: 'Capture blocked because the latest inventory sync was blocked.',
|
|
||||||
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
|
|
||||||
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
|
|
||||||
: 'Capture blocked because the latest inventory sync failed.',
|
|
||||||
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
|
|
||||||
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
|
|
||||||
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
|
|
||||||
default => 'Capture blocked because no credible inventory basis was available.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function auditStarted(
|
private function auditStarted(
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -1111,7 +820,7 @@ private function auditCompleted(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
?BaselineSnapshot $snapshot,
|
BaselineSnapshot $snapshot,
|
||||||
?User $initiator,
|
?User $initiator,
|
||||||
BaselineCaptureMode $captureMode,
|
BaselineCaptureMode $captureMode,
|
||||||
int $subjectsTotal,
|
int $subjectsTotal,
|
||||||
@ -1119,8 +828,6 @@ private function auditCompleted(
|
|||||||
bool $wasNewSnapshot,
|
bool $wasNewSnapshot,
|
||||||
array $evidenceCaptureStats,
|
array $evidenceCaptureStats,
|
||||||
array $gaps,
|
array $gaps,
|
||||||
bool $currentBaselineChanged,
|
|
||||||
?string $reasonCode,
|
|
||||||
): void {
|
): void {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -1134,10 +841,8 @@ private function auditCompleted(
|
|||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||||
'subjects_total' => $subjectsTotal,
|
'subjects_total' => $subjectsTotal,
|
||||||
'snapshot_id' => $snapshot?->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
|
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'current_baseline_changed' => $currentBaselineChanged,
|
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'evidence_capture' => $evidenceCaptureStats,
|
'evidence_capture' => $evidenceCaptureStats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
@ -1173,4 +878,17 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
|
|
||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||||
|
{
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::InventorySync->value)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $run instanceof OperationRun ? $run : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -85,13 +86,13 @@ public function refreshRuns(): void
|
|||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->active()
|
->healthyActive()
|
||||||
->orderByDesc('created_at');
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
$activeCount = (clone $query)->count();
|
$activeCount = (clone $query)->count();
|
||||||
$this->runs = (clone $query)->limit(6)->get();
|
$this->runs = (clone $query)->limit(6)->get();
|
||||||
$this->overflowCount = max(0, $activeCount - 5);
|
$this->overflowCount = max(0, $activeCount - 5);
|
||||||
$this->hasActiveRuns = $activeCount > 0;
|
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\Contracts\View\View
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
|||||||
@ -20,6 +20,21 @@ class BaselineProfile extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use BaselineProfileStatus::Draft instead.
|
||||||
|
*/
|
||||||
|
public const string STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use BaselineProfileStatus::Active instead.
|
||||||
|
*/
|
||||||
|
public const string STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use BaselineProfileStatus::Archived instead.
|
||||||
|
*/
|
||||||
|
public const string STATUS_ARCHIVED = 'archived';
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'workspace_id',
|
'workspace_id',
|
||||||
|
|||||||
@ -47,32 +47,6 @@ class Finding extends Model
|
|||||||
|
|
||||||
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
||||||
|
|
||||||
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
|
|
||||||
|
|
||||||
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
|
|
||||||
|
|
||||||
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
|
|
||||||
|
|
||||||
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
|
|
||||||
|
|
||||||
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
|
|
||||||
|
|
||||||
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
|
|
||||||
|
|
||||||
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
|
|
||||||
|
|
||||||
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
|
|
||||||
|
|
||||||
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
|
|
||||||
|
|
||||||
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
|
|
||||||
|
|
||||||
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
|
|
||||||
|
|
||||||
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
|
|
||||||
|
|
||||||
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
|
|
||||||
|
|
||||||
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
|
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
|
||||||
|
|
||||||
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
|
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
|
||||||
@ -186,113 +160,6 @@ public static function highSeverityValues(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function manualResolveReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::RESOLVE_REASON_REMEDIATED,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function systemResolveReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
|
||||||
self::RESOLVE_REASON_PERMISSION_GRANTED,
|
|
||||||
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
|
|
||||||
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
|
|
||||||
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function resolveReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
...self::manualResolveReasonKeys(),
|
|
||||||
...self::systemResolveReasonKeys(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function closeReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
|
||||||
self::CLOSE_REASON_DUPLICATE,
|
|
||||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
|
||||||
self::CLOSE_REASON_ACCEPTED_RISK,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function manualCloseReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
|
||||||
self::CLOSE_REASON_DUPLICATE,
|
|
||||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function reopenReasonKeys(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
|
|
||||||
self::REOPEN_REASON_VERIFICATION_FAILED,
|
|
||||||
self::REOPEN_REASON_MANUAL_REASSESSMENT,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isResolveReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isManualResolveReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isSystemResolveReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isCloseReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isManualCloseReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isRiskAcceptedReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isReopenReason(?string $reason): bool
|
|
||||||
{
|
|
||||||
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canonicalizeStatus(?string $status): ?string
|
public static function canonicalizeStatus(?string $status): ?string
|
||||||
{
|
{
|
||||||
if ($status === self::STATUS_ACKNOWLEDGED) {
|
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||||
|
|||||||
@ -25,17 +25,12 @@ public function toDatabase(object $notifiable): array
|
|||||||
{
|
{
|
||||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||||
$baselineTruthChanged = data_get($this->run->context, 'baseline_capture.current_baseline_changed');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
if ($reasonEnvelope !== null) {
|
||||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
$message['reason_translation'] = $reasonEnvelope->toArray();
|
||||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_bool($baselineTruthChanged)) {
|
|
||||||
$message['baseline_truth_changed'] = $baselineTruthChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
return $message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,6 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@ -65,16 +62,6 @@ public function startCapture(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$truthfulTypes = $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture')['truthful_types'] ?? null;
|
|
||||||
$inventoryEligibility = $this->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, is_array($truthfulTypes) ? $truthfulTypes : null);
|
|
||||||
|
|
||||||
if (! $inventoryEligibility['ok']) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => $inventoryEligibility['reason_code'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
: BaselineCaptureMode::Opportunistic;
|
: BaselineCaptureMode::Opportunistic;
|
||||||
@ -88,10 +75,6 @@ public function startCapture(
|
|||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
'baseline_capture' => [
|
|
||||||
'inventory_sync_run_id' => $inventoryEligibility['inventory_sync_run_id'],
|
|
||||||
'eligibility' => $this->eligibilityContextPayload($inventoryEligibility, phase: 'preflight'),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
@ -131,134 +114,4 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string>|null $truthfulTypes
|
|
||||||
* @return array{
|
|
||||||
* ok: bool,
|
|
||||||
* reason_code: ?string,
|
|
||||||
* inventory_sync_run_id: ?int,
|
|
||||||
* inventory_outcome: ?string,
|
|
||||||
* effective_types: list<string>,
|
|
||||||
* covered_types: list<string>,
|
|
||||||
* uncovered_types: list<string>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function latestInventoryEligibilityDecision(
|
|
||||||
Tenant $sourceTenant,
|
|
||||||
BaselineScope $effectiveScope,
|
|
||||||
?array $truthfulTypes = null,
|
|
||||||
): array {
|
|
||||||
$effectiveTypes = is_array($truthfulTypes) && $truthfulTypes !== []
|
|
||||||
? array_values(array_unique(array_filter($truthfulTypes, 'is_string')))
|
|
||||||
: $effectiveScope->allTypes();
|
|
||||||
|
|
||||||
sort($effectiveTypes, SORT_STRING);
|
|
||||||
|
|
||||||
$run = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $sourceTenant->getKey())
|
|
||||||
->where('type', OperationRunType::InventorySync->value)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
|
|
||||||
'inventory_sync_run_id' => null,
|
|
||||||
'inventory_outcome' => null,
|
|
||||||
'effective_types' => $effectiveTypes,
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => $effectiveTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$outcome = is_string($run->outcome) ? trim($run->outcome) : null;
|
|
||||||
|
|
||||||
if ($outcome === OperationRunOutcome::Blocked->value) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
|
||||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
|
||||||
'inventory_outcome' => $outcome,
|
|
||||||
'effective_types' => $effectiveTypes,
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => $effectiveTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($outcome === OperationRunOutcome::Failed->value) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
|
||||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
|
||||||
'inventory_outcome' => $outcome,
|
|
||||||
'effective_types' => $effectiveTypes,
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => $effectiveTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$coverage = InventoryCoverage::fromContext($run->context);
|
|
||||||
$coveredTypes = $coverage instanceof InventoryCoverage
|
|
||||||
? array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
sort($coveredTypes, SORT_STRING);
|
|
||||||
|
|
||||||
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
|
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
|
||||||
|
|
||||||
if ($coveredTypes === []) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
|
||||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
|
||||||
'inventory_outcome' => $outcome,
|
|
||||||
'effective_types' => $effectiveTypes,
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => $effectiveTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ok' => true,
|
|
||||||
'reason_code' => null,
|
|
||||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
|
||||||
'inventory_outcome' => $outcome,
|
|
||||||
'effective_types' => $effectiveTypes,
|
|
||||||
'covered_types' => $coveredTypes,
|
|
||||||
'uncovered_types' => $uncoveredTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* ok: bool,
|
|
||||||
* reason_code: ?string,
|
|
||||||
* inventory_sync_run_id: ?int,
|
|
||||||
* inventory_outcome: ?string,
|
|
||||||
* effective_types: list<string>,
|
|
||||||
* covered_types: list<string>,
|
|
||||||
* uncovered_types: list<string>
|
|
||||||
* } $decision
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function eligibilityContextPayload(array $decision, string $phase): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'phase' => $phase,
|
|
||||||
'ok' => (bool) ($decision['ok'] ?? false),
|
|
||||||
'reason_code' => is_string($decision['reason_code'] ?? null) ? $decision['reason_code'] : null,
|
|
||||||
'inventory_sync_run_id' => is_numeric($decision['inventory_sync_run_id'] ?? null)
|
|
||||||
? (int) $decision['inventory_sync_run_id']
|
|
||||||
: null,
|
|
||||||
'inventory_outcome' => is_string($decision['inventory_outcome'] ?? null) ? $decision['inventory_outcome'] : null,
|
|
||||||
'effective_types' => array_values(array_filter((array) ($decision['effective_types'] ?? []), 'is_string')),
|
|
||||||
'covered_types' => array_values(array_filter((array) ($decision['covered_types'] ?? []), 'is_string')),
|
|
||||||
'uncovered_types' => array_values(array_filter((array) ($decision['uncovered_types'] ?? []), 'is_string')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -213,12 +213,6 @@ public function buildSnapshotPayload(Tenant $tenant): array
|
|||||||
'state' => $item['state'],
|
'state' => $item['state'],
|
||||||
'required' => $item['required'],
|
'required' => $item['required'],
|
||||||
], $items),
|
], $items),
|
||||||
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
|
|
||||||
? $findingsSummary['outcome_counts']
|
|
||||||
: [],
|
|
||||||
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
|
|
||||||
? $findingsSummary['report_bucket_counts']
|
|
||||||
: [],
|
|
||||||
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
||||||
? $findingsSummary['risk_acceptance']
|
? $findingsSummary['risk_acceptance']
|
||||||
: [
|
: [
|
||||||
|
|||||||
@ -8,14 +8,12 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
|
||||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function key(): string
|
public function key(): string
|
||||||
@ -35,7 +33,6 @@ public function collect(Tenant $tenant): array
|
|||||||
$entries = $findings->map(function (Finding $finding): array {
|
$entries = $findings->map(function (Finding $finding): array {
|
||||||
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
||||||
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
||||||
$outcome = $this->findingOutcomeSemantics->describe($finding);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $finding->getKey(),
|
'id' => (int) $finding->getKey(),
|
||||||
@ -46,42 +43,10 @@ public function collect(Tenant $tenant): array
|
|||||||
'description' => $finding->description,
|
'description' => $finding->description,
|
||||||
'created_at' => $finding->created_at?->toIso8601String(),
|
'created_at' => $finding->created_at?->toIso8601String(),
|
||||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||||
'verification_state' => $outcome['verification_state'],
|
|
||||||
'report_bucket' => $outcome['report_bucket'],
|
|
||||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
|
||||||
'terminal_outcome_label' => $outcome['label'],
|
|
||||||
'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [
|
|
||||||
'key' => $outcome['terminal_outcome_key'],
|
|
||||||
'label' => $outcome['label'],
|
|
||||||
'verification_state' => $outcome['verification_state'],
|
|
||||||
'report_bucket' => $outcome['report_bucket'],
|
|
||||||
'governance_state' => $governanceState,
|
|
||||||
] : null,
|
|
||||||
'governance_state' => $governanceState,
|
'governance_state' => $governanceState,
|
||||||
'governance_warning' => $governanceWarning,
|
'governance_warning' => $governanceWarning,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);
|
|
||||||
$reportBucketCounts = [
|
|
||||||
'remediation_pending_verification' => 0,
|
|
||||||
'remediation_verified' => 0,
|
|
||||||
'administrative_closure' => 0,
|
|
||||||
'accepted_risk' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
$terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null;
|
|
||||||
$reportBucket = $entry['report_bucket'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) {
|
|
||||||
$outcomeCounts[$terminalOutcomeKey]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) {
|
|
||||||
$reportBucketCounts[$reportBucket]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$riskAcceptedEntries = $entries->filter(
|
$riskAcceptedEntries = $entries->filter(
|
||||||
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
||||||
);
|
);
|
||||||
@ -113,8 +78,6 @@ public function collect(Tenant $tenant): array
|
|||||||
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
|
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
|
||||||
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
|
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
|
||||||
],
|
],
|
||||||
'outcome_counts' => $outcomeCounts,
|
|
||||||
'report_bucket_counts' => $reportBucketCounts,
|
|
||||||
'entries' => $entries->all(),
|
'entries' => $entries->all(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -68,27 +68,12 @@ public function issueQuery(
|
|||||||
string $reasonFilter = self::FILTER_ALL,
|
string $reasonFilter = self::FILTER_ALL,
|
||||||
bool $applyOrdering = true,
|
bool $applyOrdering = true,
|
||||||
): Builder {
|
): Builder {
|
||||||
return $this->issueQueryForVisibleTenantIds(
|
$visibleTenants = $this->visibleTenants($workspace, $user);
|
||||||
$workspace,
|
$visibleTenantIds = array_map(
|
||||||
$this->visibleTenantIds($workspace, $user),
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
$tenantId,
|
$visibleTenants,
|
||||||
$reasonFilter,
|
|
||||||
$applyOrdering,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $visibleTenantIds
|
|
||||||
* @return Builder<Finding>
|
|
||||||
*/
|
|
||||||
private function issueQueryForVisibleTenantIds(
|
|
||||||
Workspace $workspace,
|
|
||||||
array $visibleTenantIds,
|
|
||||||
?int $tenantId = null,
|
|
||||||
string $reasonFilter = self::FILTER_ALL,
|
|
||||||
bool $applyOrdering = true,
|
|
||||||
): Builder {
|
|
||||||
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
||||||
$visibleTenantIds = [];
|
$visibleTenantIds = [];
|
||||||
} elseif ($tenantId !== null) {
|
} elseif ($tenantId !== null) {
|
||||||
@ -170,22 +155,9 @@ function ($join): void {
|
|||||||
*/
|
*/
|
||||||
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
||||||
{
|
{
|
||||||
return $this->summaryForVisibleTenantIds(
|
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
||||||
$workspace,
|
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
||||||
$this->visibleTenantIds($workspace, $user),
|
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $visibleTenantIds
|
|
||||||
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
|
|
||||||
*/
|
|
||||||
public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleTenantIds, ?int $tenantId = null): array
|
|
||||||
{
|
|
||||||
$allIssues = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
|
||||||
$brokenAssignments = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
|
||||||
$staleInProgress = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'unique_issue_count' => (clone $allIssues)->count(),
|
'unique_issue_count' => (clone $allIssues)->count(),
|
||||||
@ -194,17 +166,6 @@ public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleT
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
public function visibleTenantIds(Workspace $workspace, User $user): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
||||||
$this->visibleTenants($workspace, $user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
|
|||||||
|
|
||||||
private function findingRiskAcceptedReason(string $approvalReason): string
|
private function findingRiskAcceptedReason(string $approvalReason): string
|
||||||
{
|
{
|
||||||
return Finding::CLOSE_REASON_ACCEPTED_RISK;
|
return mb_substr($approvalReason, 0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||||
|
|||||||
@ -7,16 +7,11 @@
|
|||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\FindingExceptionDecision;
|
use App\Models\FindingExceptionDecision;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
final class FindingRiskGovernanceResolver
|
final class FindingRiskGovernanceResolver
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function resolveWorkflowFamily(Finding $finding): string
|
public function resolveWorkflowFamily(Finding $finding): string
|
||||||
{
|
{
|
||||||
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||||
@ -223,7 +218,11 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
|
|||||||
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||||
? 'Accepted risk remains visible because current governance is still valid.'
|
? 'Accepted risk remains visible because current governance is still valid.'
|
||||||
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||||
'historical' => $this->historicalPrimaryNarrative($finding),
|
'historical' => match ((string) $finding->status) {
|
||||||
|
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
default => 'This finding is historical workflow context.',
|
||||||
|
},
|
||||||
default => match ($finding->responsibilityState()) {
|
default => match ($finding->responsibilityState()) {
|
||||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
|
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
|
||||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
|
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
|
||||||
@ -254,14 +253,8 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $finding->status === Finding::STATUS_RESOLVED) {
|
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||||
return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING
|
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||||
? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.'
|
|
||||||
: 'Keep the finding closed unless later trusted evidence shows the issue has returned.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $finding->status === Finding::STATUS_CLOSED) {
|
|
||||||
return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($finding->responsibilityState()) {
|
return match ($finding->responsibilityState()) {
|
||||||
@ -347,33 +340,23 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
|||||||
|
|
||||||
private function resolvedHistoricalContext(Finding $finding): ?string
|
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||||
{
|
{
|
||||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
$reason = (string) ($finding->resolved_reason ?? '');
|
||||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.',
|
|
||||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.',
|
return match ($reason) {
|
||||||
|
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||||
|
'permission_granted',
|
||||||
|
'permission_removed_from_registry',
|
||||||
|
'role_assignment_removed',
|
||||||
|
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||||
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function closedHistoricalContext(Finding $finding): ?string
|
private function closedHistoricalContext(Finding $finding): ?string
|
||||||
{
|
{
|
||||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
return match ((string) ($finding->closed_reason ?? '')) {
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.',
|
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.',
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.',
|
|
||||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
|
||||||
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function historicalPrimaryNarrative(Finding $finding): string
|
|
||||||
{
|
|
||||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
|
||||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.',
|
|
||||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.',
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE,
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE,
|
|
||||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.',
|
|
||||||
default => 'This finding is historical workflow context.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Audit\AuditActorType;
|
use App\Support\Audit\AuditActorType;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -29,7 +28,6 @@ public function __construct(
|
|||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
private readonly FindingNotificationService $findingNotificationService,
|
private readonly FindingNotificationService $findingNotificationService,
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -275,7 +273,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
|
|||||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
|
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||||
$now = CarbonImmutable::now();
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
@ -301,7 +299,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
|
|||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
|
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
|
||||||
|
|
||||||
$reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
|
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||||
$now = CarbonImmutable::now();
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
@ -344,7 +342,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
|||||||
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
|
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
|
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||||
$now = CarbonImmutable::now();
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
@ -378,7 +376,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re
|
|||||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
|
$reason = $this->validatedReason($reason, 'reopen_reason');
|
||||||
$now = CarbonImmutable::now();
|
$now = CarbonImmutable::now();
|
||||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
||||||
@ -420,11 +418,11 @@ public function resolveBySystem(
|
|||||||
): Finding {
|
): Finding {
|
||||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||||
|
|
||||||
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
|
if (! $finding->hasOpenStatus()) {
|
||||||
throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
|
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
|
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
@ -458,7 +456,6 @@ public function reopenBySystem(
|
|||||||
CarbonImmutable $reopenedAt,
|
CarbonImmutable $reopenedAt,
|
||||||
?int $operationRunId = null,
|
?int $operationRunId = null,
|
||||||
?callable $mutate = null,
|
?callable $mutate = null,
|
||||||
?string $reason = null,
|
|
||||||
): Finding {
|
): Finding {
|
||||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||||
|
|
||||||
@ -466,11 +463,6 @@ public function reopenBySystem(
|
|||||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reason = $this->validatedReason(
|
|
||||||
$reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
|
|
||||||
'reopen_reason',
|
|
||||||
Finding::reopenReasonKeys(),
|
|
||||||
);
|
|
||||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||||
|
|
||||||
@ -482,7 +474,6 @@ public function reopenBySystem(
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'reopened_at' => $reopenedAt->toIso8601String(),
|
'reopened_at' => $reopenedAt->toIso8601String(),
|
||||||
'reopened_reason' => $reason,
|
|
||||||
'sla_days' => $slaDays,
|
'sla_days' => $slaDays,
|
||||||
'due_at' => $dueAt->toIso8601String(),
|
'due_at' => $dueAt->toIso8601String(),
|
||||||
'system_origin' => true,
|
'system_origin' => true,
|
||||||
@ -583,10 +574,7 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function validatedReason(string $reason, string $field): string
|
||||||
* @param array<int, string> $allowedReasons
|
|
||||||
*/
|
|
||||||
private function validatedReason(string $reason, string $field, array $allowedReasons): string
|
|
||||||
{
|
{
|
||||||
$reason = trim($reason);
|
$reason = trim($reason);
|
||||||
|
|
||||||
@ -598,14 +586,6 @@ private function validatedReason(string $reason, string $field, array $allowedRe
|
|||||||
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
|
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! in_array($reason, $allowedReasons, true)) {
|
|
||||||
throw new InvalidArgumentException(sprintf(
|
|
||||||
'%s must be one of: %s.',
|
|
||||||
$field,
|
|
||||||
implode(', ', $allowedReasons),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $reason;
|
return $reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,17 +637,12 @@ private function mutateAndAudit(
|
|||||||
$record->save();
|
$record->save();
|
||||||
|
|
||||||
$after = $this->auditSnapshot($record);
|
$after = $this->auditSnapshot($record);
|
||||||
$outcome = $this->findingOutcomeSemantics->describe($record);
|
|
||||||
$auditMetadata = array_merge($metadata, [
|
$auditMetadata = array_merge($metadata, [
|
||||||
'finding_id' => (int) $record->getKey(),
|
'finding_id' => (int) $record->getKey(),
|
||||||
'before_status' => $before['status'] ?? null,
|
'before_status' => $before['status'] ?? null,
|
||||||
'after_status' => $after['status'] ?? null,
|
'after_status' => $after['status'] ?? null,
|
||||||
'before' => $before,
|
'before' => $before,
|
||||||
'after' => $after,
|
'after' => $after,
|
||||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
|
||||||
'terminal_outcome_label' => $outcome['label'],
|
|
||||||
'verification_state' => $outcome['verification_state'],
|
|
||||||
'report_bucket' => $outcome['report_bucket'],
|
|
||||||
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
|
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -738,7 +713,6 @@ private function dedupeKey(
|
|||||||
'owner_user_id' => $metadata['owner_user_id'] ?? null,
|
'owner_user_id' => $metadata['owner_user_id'] ?? null,
|
||||||
'resolved_reason' => $metadata['resolved_reason'] ?? null,
|
'resolved_reason' => $metadata['resolved_reason'] ?? null,
|
||||||
'closed_reason' => $metadata['closed_reason'] ?? null,
|
'closed_reason' => $metadata['closed_reason'] ?? null,
|
||||||
'reopened_reason' => $metadata['reopened_reason'] ?? null,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$encoded = json_encode($payload);
|
$encoded = json_encode($payload);
|
||||||
|
|||||||
@ -29,8 +29,6 @@
|
|||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
use App\Support\Verification\BlockedVerificationReportFactory;
|
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -944,23 +942,11 @@ public function finalizeExecutionLegitimacyBlockedRun(
|
|||||||
'context' => $context,
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$run = $this->finalizeBlockedRun(
|
return $this->finalizeBlockedRun(
|
||||||
run: $run->fresh(),
|
run: $run->fresh(),
|
||||||
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
|
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
|
||||||
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
|
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($run->type === 'provider.connection.check') {
|
|
||||||
VerificationReportWriter::write(
|
|
||||||
run: $run,
|
|
||||||
checks: BlockedVerificationReportFactory::checks($run),
|
|
||||||
identity: BlockedVerificationReportFactory::identity($run),
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $run;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
@ -35,7 +34,6 @@ class QueuedExecutionLegitimacyGate
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
|
||||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||||
private readonly WriteGateInterface $writeGate,
|
private readonly WriteGateInterface $writeGate,
|
||||||
) {}
|
) {}
|
||||||
@ -73,8 +71,12 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($context->requiredCapability !== null) {
|
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) {
|
||||||
$checks['capability'] = $this->initiatorHasRequiredCapability($context) ? 'passed' : 'failed';
|
$checks['capability'] = $this->capabilityResolver->can(
|
||||||
|
$context->initiator,
|
||||||
|
$context->tenant,
|
||||||
|
$context->requiredCapability,
|
||||||
|
) ? 'passed' : 'failed';
|
||||||
|
|
||||||
if ($checks['capability'] === 'failed') {
|
if ($checks['capability'] === 'failed') {
|
||||||
return QueuedExecutionLegitimacyDecision::deny(
|
return QueuedExecutionLegitimacyDecision::deny(
|
||||||
@ -104,7 +106,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
tenant: $context->tenant,
|
tenant: $context->tenant,
|
||||||
question: $operabilityQuestion,
|
question: $operabilityQuestion,
|
||||||
workspaceId: $context->workspaceId,
|
workspaceId: $context->workspaceId,
|
||||||
lane: $this->laneForContext($context),
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
);
|
);
|
||||||
|
|
||||||
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
||||||
@ -226,35 +228,6 @@ private function resolveProviderConnectionId(array $context): ?int
|
|||||||
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initiatorHasRequiredCapability(QueuedExecutionContext $context): bool
|
|
||||||
{
|
|
||||||
if (! $context->initiator instanceof User || ! is_string($context->requiredCapability) || $context->requiredCapability === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($context->requiredCapability, 'workspace')) {
|
|
||||||
if ($context->workspaceId <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workspaceCapabilityResolver->can(
|
|
||||||
$context->initiator,
|
|
||||||
$context->run->tenant?->workspace ?? $context->run->workspace()->firstOrFail(),
|
|
||||||
$context->requiredCapability,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $context->tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->capabilityResolver->can(
|
|
||||||
$context->initiator,
|
|
||||||
$context->tenant,
|
|
||||||
$context->requiredCapability,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@ -289,16 +262,4 @@ private function requiresWriteGate(QueuedExecutionContext $context): bool
|
|||||||
{
|
{
|
||||||
return in_array('write_gate', $context->prerequisiteClasses, true);
|
return in_array('write_gate', $context->prerequisiteClasses, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function laneForContext(QueuedExecutionContext $context): TenantInteractionLane
|
|
||||||
{
|
|
||||||
$runContext = is_array($context->run->context) ? $context->run->context : [];
|
|
||||||
$wizardFlow = data_get($runContext, 'wizard.flow');
|
|
||||||
|
|
||||||
if (is_string($wizardFlow) && trim($wizardFlow) === 'managed_tenant_onboarding') {
|
|
||||||
return TenantInteractionLane::OnboardingWorkflow;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TenantInteractionLane::AdministrativeManagement;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,12 +83,6 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
|||||||
'status' => ReviewPackStatus::Queued->value,
|
'status' => ReviewPackStatus::Queued->value,
|
||||||
'options' => $options,
|
'options' => $options,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
|
|
||||||
? $snapshot->summary['finding_outcomes']
|
|
||||||
: [],
|
|
||||||
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
|
|
||||||
? $snapshot->summary['finding_report_buckets']
|
|
||||||
: [],
|
|
||||||
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
||||||
? $snapshot->summary['risk_acceptance']
|
? $snapshot->summary['risk_acceptance']
|
||||||
: [],
|
: [],
|
||||||
@ -174,12 +168,6 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
'review_status' => (string) $review->status,
|
'review_status' => (string) $review->status,
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
'review_completeness_state' => (string) $review->completeness_state,
|
||||||
'section_count' => $review->sections->count(),
|
'section_count' => $review->sections->count(),
|
||||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
|
||||||
? $review->summary['finding_outcomes']
|
|
||||||
: [],
|
|
||||||
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
|
|
||||||
? $review->summary['finding_report_buckets']
|
|
||||||
: [],
|
|
||||||
'evidence_resolution' => [
|
'evidence_resolution' => [
|
||||||
'outcome' => 'resolved',
|
'outcome' => 'resolved',
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
|||||||
@ -59,12 +59,6 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
|||||||
'publish_blockers' => $blockers,
|
'publish_blockers' => $blockers,
|
||||||
'has_ready_export' => false,
|
'has_ready_export' => false,
|
||||||
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
||||||
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
|
|
||||||
? data_get($sections, '0.summary_payload.finding_outcomes')
|
|
||||||
: [],
|
|
||||||
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
|
||||||
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
|
||||||
: [],
|
|
||||||
'report_count' => 2,
|
'report_count' => 2,
|
||||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||||
|
|||||||
@ -6,17 +6,12 @@
|
|||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
final class TenantReviewSectionFactory
|
final class TenantReviewSectionFactory
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@ -52,8 +47,6 @@ private function executiveSummarySection(
|
|||||||
$rolesSummary = $this->summary($rolesItem);
|
$rolesSummary = $this->summary($rolesItem);
|
||||||
$baselineSummary = $this->summary($baselineItem);
|
$baselineSummary = $this->summary($baselineItem);
|
||||||
$operationsSummary = $this->summary($operationsItem);
|
$operationsSummary = $this->summary($operationsItem);
|
||||||
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
|
||||||
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
|
||||||
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
||||||
|
|
||||||
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
||||||
@ -62,11 +55,9 @@ private function executiveSummarySection(
|
|||||||
$postureScore = $permissionSummary['posture_score'] ?? null;
|
$postureScore = $permissionSummary['posture_score'] ?? null;
|
||||||
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
||||||
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
||||||
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
|
|
||||||
|
|
||||||
$highlights = array_values(array_filter([
|
$highlights = array_values(array_filter([
|
||||||
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
||||||
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
|
|
||||||
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
||||||
sprintf('%d baseline drift findings remain open.', $driftCount),
|
sprintf('%d baseline drift findings remain open.', $driftCount),
|
||||||
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
||||||
@ -90,8 +81,6 @@ private function executiveSummarySection(
|
|||||||
'summary_payload' => [
|
'summary_payload' => [
|
||||||
'finding_count' => $findingCount,
|
'finding_count' => $findingCount,
|
||||||
'open_risk_count' => $openCount,
|
'open_risk_count' => $openCount,
|
||||||
'finding_outcomes' => $findingOutcomes,
|
|
||||||
'finding_report_buckets' => $findingReportBuckets,
|
|
||||||
'posture_score' => $postureScore,
|
'posture_score' => $postureScore,
|
||||||
'baseline_drift_count' => $driftCount,
|
'baseline_drift_count' => $driftCount,
|
||||||
'failed_operation_count' => $operationFailures,
|
'failed_operation_count' => $operationFailures,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
||||||
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
||||||
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
||||||
|
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
|
||||||
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
||||||
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
||||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ enum BadgeDomain: string
|
|||||||
case BooleanEnabled = 'boolean_enabled';
|
case BooleanEnabled = 'boolean_enabled';
|
||||||
case BooleanHasErrors = 'boolean_has_errors';
|
case BooleanHasErrors = 'boolean_has_errors';
|
||||||
case TenantStatus = 'tenant_status';
|
case TenantStatus = 'tenant_status';
|
||||||
|
case TenantAppStatus = 'tenant_app_status';
|
||||||
case TenantRbacStatus = 'tenant_rbac_status';
|
case TenantRbacStatus = 'tenant_rbac_status';
|
||||||
case TenantPermissionStatus = 'tenant_permission_status';
|
case TenantPermissionStatus = 'tenant_permission_status';
|
||||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class TenantAppStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||||
|
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@ -121,8 +120,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||||
$latestCaptureRun = self::latestBaselineCaptureRun($profile);
|
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode, $latestCaptureRun);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$profileScope = $profile->normalizedScope();
|
$profileScope = $profile->normalizedScope();
|
||||||
@ -907,35 +905,8 @@ private static function empty(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function latestBaselineCaptureRun(BaselineProfile $profile): ?OperationRun
|
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
||||||
{
|
{
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('type', OperationRunType::BaselineCapture->value)
|
|
||||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function missingSnapshotMessage(?string $reasonCode, ?OperationRun $latestCaptureRun = null): ?string
|
|
||||||
{
|
|
||||||
$latestCaptureEnvelope = $latestCaptureRun instanceof OperationRun
|
|
||||||
? app(ReasonPresenter::class)->forOperationRun($latestCaptureRun, 'artifact_truth')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($latestCaptureEnvelope !== null
|
|
||||||
&& in_array($latestCaptureEnvelope->internalCode, [
|
|
||||||
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
|
||||||
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
|
||||||
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
|
||||||
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
], true)
|
|
||||||
&& trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
|
||||||
return $latestCaptureEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($reasonCode) {
|
return match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
||||||
|
|||||||
@ -22,16 +22,6 @@ final class BaselineReasonCodes
|
|||||||
|
|
||||||
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
||||||
|
|
||||||
public const string CAPTURE_INVENTORY_MISSING = 'baseline.capture.inventory_missing';
|
|
||||||
|
|
||||||
public const string CAPTURE_INVENTORY_BLOCKED = 'baseline.capture.inventory_blocked';
|
|
||||||
|
|
||||||
public const string CAPTURE_INVENTORY_FAILED = 'baseline.capture.inventory_failed';
|
|
||||||
|
|
||||||
public const string CAPTURE_UNUSABLE_COVERAGE = 'baseline.capture.unusable_coverage';
|
|
||||||
|
|
||||||
public const string CAPTURE_ZERO_SUBJECTS = 'baseline.capture.zero_subjects';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||||
|
|
||||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||||
@ -83,11 +73,6 @@ public static function all(): array
|
|||||||
self::CAPTURE_ROLLOUT_DISABLED,
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||||
self::CAPTURE_INVENTORY_MISSING,
|
|
||||||
self::CAPTURE_INVENTORY_BLOCKED,
|
|
||||||
self::CAPTURE_INVENTORY_FAILED,
|
|
||||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
|
||||||
self::CAPTURE_ZERO_SUBJECTS,
|
|
||||||
self::SNAPSHOT_BUILDING,
|
self::SNAPSHOT_BUILDING,
|
||||||
self::SNAPSHOT_INCOMPLETE,
|
self::SNAPSHOT_INCOMPLETE,
|
||||||
self::SNAPSHOT_SUPERSEDED,
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
@ -143,12 +128,7 @@ public static function trustImpact(?string $reasonCode): ?string
|
|||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
|
||||||
self::CAPTURE_INVENTORY_MISSING,
|
|
||||||
self::CAPTURE_INVENTORY_BLOCKED,
|
|
||||||
self::CAPTURE_INVENTORY_FAILED,
|
|
||||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
|
||||||
self::CAPTURE_ZERO_SUBJECTS => 'unusable',
|
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -168,10 +148,6 @@ public static function absencePattern(?string $reasonCode): ?string
|
|||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
self::CAPTURE_ROLLOUT_DISABLED,
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
self::CAPTURE_INVENTORY_MISSING,
|
|
||||||
self::CAPTURE_INVENTORY_BLOCKED,
|
|
||||||
self::CAPTURE_INVENTORY_FAILED,
|
|
||||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
|
||||||
self::COMPARE_NO_ASSIGNMENT,
|
self::COMPARE_NO_ASSIGNMENT,
|
||||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||||
@ -183,7 +159,6 @@ public static function absencePattern(?string $reasonCode): ?string
|
|||||||
self::SNAPSHOT_SUPERSEDED,
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||||
self::CAPTURE_ZERO_SUBJECTS => 'missing_input',
|
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
||||||
default => null,
|
default => null,
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
@ -143,22 +142,6 @@ public static function findingWorkflowFamilies(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function findingTerminalOutcomes(): array
|
|
||||||
{
|
|
||||||
return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function findingVerificationStates(): array
|
|
||||||
{
|
|
||||||
return app(FindingOutcomeSemantics::class)->verificationStateOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,89 +6,19 @@
|
|||||||
|
|
||||||
class PanelThemeAsset
|
class PanelThemeAsset
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @var array<string, bool>
|
|
||||||
*/
|
|
||||||
private static array $hotAssetReachability = [];
|
|
||||||
|
|
||||||
public static function resolve(string $entry): ?string
|
public static function resolve(string $entry): ?string
|
||||||
{
|
{
|
||||||
if (app()->runningUnitTests()) {
|
if (app()->runningUnitTests()) {
|
||||||
return static::resolveFromManifest($entry);
|
return static::resolveFromManifest($entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (static::shouldUseHotAsset($entry)) {
|
if (is_file(public_path('hot'))) {
|
||||||
return Vite::asset($entry);
|
return Vite::asset($entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::resolveFromManifest($entry);
|
return static::resolveFromManifest($entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function shouldUseHotAsset(string $entry): bool
|
|
||||||
{
|
|
||||||
$hotFile = public_path('hot');
|
|
||||||
|
|
||||||
if (! is_file($hotFile)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hotUrl = trim((string) file_get_contents($hotFile));
|
|
||||||
|
|
||||||
if ($hotUrl === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetUrl = Vite::asset($entry);
|
|
||||||
|
|
||||||
if ($assetUrl === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists($assetUrl, static::$hotAssetReachability)) {
|
|
||||||
return static::$hotAssetReachability[$assetUrl];
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = parse_url($assetUrl);
|
|
||||||
|
|
||||||
if (! is_array($parts)) {
|
|
||||||
return static::$hotAssetReachability[$assetUrl] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$host = $parts['host'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($host) || $host === '') {
|
|
||||||
return static::$hotAssetReachability[$assetUrl] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheme = $parts['scheme'] ?? 'http';
|
|
||||||
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
|
|
||||||
$transport = $scheme === 'https' ? 'ssl://' : '';
|
|
||||||
$connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2);
|
|
||||||
|
|
||||||
if (! is_resource($connection)) {
|
|
||||||
return static::$hotAssetReachability[$assetUrl] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
|
|
||||||
$hostHeader = isset($parts['port']) ? $host.':'.$port : $host;
|
|
||||||
|
|
||||||
stream_set_timeout($connection, 0, 200000);
|
|
||||||
fwrite(
|
|
||||||
$connection,
|
|
||||||
"HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
$statusLine = fgets($connection);
|
|
||||||
|
|
||||||
fclose($connection);
|
|
||||||
|
|
||||||
if (! is_string($statusLine)) {
|
|
||||||
return static::$hotAssetReachability[$assetUrl] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveFromManifest(string $entry): ?string
|
private static function resolveFromManifest(string $entry): ?string
|
||||||
{
|
{
|
||||||
$manifest = public_path('build/manifest.json');
|
$manifest = public_path('build/manifest.json');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user