diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..f9e1fd3 --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,42 @@ +/** + * Architecture rules for earsyntax. + * + * Package boundaries (centripetal: outer packages depend inward, never outward): + * + * @earsyntax/core parser, AST, linter, diagnostics. No runtime deps. + * @earsyntax/cli-contract shared report output contracts. Depends on core types. + * @earsyntax/extract requirement extraction from files. Depends on core types. + * @earsyntax/cli command-line tool. Depends on core, extract, cli-contract. + * + * core must never depend on extract, cli-contract, or cli. + * + * @type {import('dependency-cruiser').IConfiguration} + */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'error', + comment: 'Circular dependencies are not allowed.', + from: {}, + to: { circular: true }, + }, + { + name: 'core-stays-pure', + severity: 'error', + comment: + 'packages/core is the deterministic parser core and must not depend on the extract, cli-contract, or cli packages.', + from: { path: '^packages/core/src/' }, + to: { path: '^packages/(extract|cli-contract|cli)/src/' }, + }, + ], + options: { + doNotFollow: { path: 'node_modules' }, + tsConfig: { fileName: 'tsconfig.json' }, + tsPreCompilationDeps: true, + enhancedResolveOptions: { + exportsFields: ['exports'], + conditionNames: ['import', 'require', 'node', 'default'], + }, + }, +}; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..08b98cf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @omermorad diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..107e2c4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [omermorad] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..817d01c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Report a problem with the earsyntax toolkit +title: '[bug] ' +labels: bug +assignees: '' +--- + +## Describe the bug + +A clear description of what the bug is. + +## Reproduction + +Steps or a minimal repo that reproduces the issue. + +## Expected behavior + +What you expected to happen. + +## Environment + +- earsyntax package + version (e.g. `@earsyntax/core`): +- Node version: +- Package manager (npm/pnpm/yarn) + version: +- OS: + +## Additional context + +Logs, diagnostics output, or anything else relevant. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..aaf555a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for the earsyntax toolkit +title: '[feature] ' +labels: enhancement +assignees: '' +--- + +## Problem + +What problem are you trying to solve? What is the use case? + +## Proposed solution + +What you'd like to happen. + +## Alternatives considered + +Other approaches you've thought about. + +## Additional context + +Anything else that helps explain the request. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ab386ef --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: '/' + schedule: + interval: weekly + open-pull-requests-limit: 10 + commit-message: + prefix: chore + include: scope + groups: + eslint: + patterns: + - 'eslint' + - 'eslint-*' + - '@typescript-eslint/*' + - 'typescript-eslint' + + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: weekly + commit-message: + prefix: ci diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0629b38 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ + + +## Summary + + + +## Type of change + +- [ ] Bug fix (non-breaking) +- [ ] New feature (non-breaking) +- [ ] Breaking change +- [ ] Docs / tooling only + +## Checklist + +- [ ] Commits follow Conventional Commits (enforced by commitlint) +- [ ] `pnpm lint` and `pnpm typecheck` pass +- [ ] `pnpm build` succeeds +- [ ] Tests added or updated where relevant + +## Notes for reviewers + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e0420f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,172 @@ +name: Continuous Integration + +on: + pull_request: + branches: + - main + - next + +permissions: + contents: read + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Typed ESLint resolves @earsyntax/* imports through each package's + # exports map (dist/*.d.ts), which only exists after a build. + - name: Build + run: pnpm build + + - name: Lint + run: pnpm lint + + format: + name: Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format check + run: pnpm format:check + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck (build) + run: pnpm typecheck + + - name: Typecheck (tests) + run: pnpm typecheck:tests + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Cross-package test imports resolve @earsyntax/* through the exports + # map (dist/*.d.ts), which only exists after a build. + - name: Build + run: pnpm build + + - name: Test + run: pnpm test + + deps: + name: Dependency Boundaries + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check dependency boundaries + run: pnpm check:deps diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..1415992 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,50 @@ +name: PR Title + +# Enforces a Conventional Commits prefix on every pull request title, e.g. +# feat(core): add catalog glob matcher +# fix(extract): resolve markdown table extraction +# PR titles become the squash-merge commit subject, which lerna reads for +# conventional versioning, so the title must be prefixed. + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + pull-requests: read + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Check Conventional Commits prefix + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Allowed types (mirror @commitlint/config-conventional). + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + # Scopes are optional and free-form (e.g. core, extract, cli, + # cli-contract). + requireScope: false + # Subject must not start with an uppercase letter. + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" must start with a lowercase letter, following + Conventional Commits (e.g. "feat(core): add catalog glob matcher"). diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000..107c88e --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,81 @@ +name: Publish Packages + +env: + CI: true + +on: + workflow_dispatch: + inputs: + dist_tag: + description: 'Distribution Tag' + type: choice + options: + - 'alpha' + - 'beta' + - 'rc' + - 'next' + - 'latest' + required: true + default: 'alpha' + target_branch: + description: 'Branch to publish from' + type: choice + options: + - 'next' + - 'main' + required: true + default: 'next' + +permissions: + contents: read + id-token: write + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.target_branch }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org/ + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Capture versions for report + run: | + echo "## Publishing to npm" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Dist Tag:** \`${{ github.event.inputs.dist_tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** \`${{ github.event.inputs.target_branch }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npx lerna ls --json | jq -r '.[] | "- **\(.name)** -> `v\(.version)`"' >> $GITHUB_STEP_SUMMARY + + # pnpm (NOT lerna) does the publish: it natively rewrites catalog: and + # workspace: to concrete ranges, and supports OIDC provenance. + - name: Publish + run: pnpm -r --filter './packages/**' publish --tag ${{ github.event.inputs.dist_tag }} --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml new file mode 100644 index 0000000..8bc440e --- /dev/null +++ b/.github/workflows/release-packages.yml @@ -0,0 +1,125 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + release_type: + description: 'Release Type' + type: choice + options: + - 'prerelease' + - 'graduate' + - 'stable' + required: true + default: 'prerelease' + target_branch: + description: 'Target Branch' + type: choice + options: + - 'next' + - 'main' + required: true + default: 'next' + preid: + description: 'Prerelease ID (prerelease type only)' + type: choice + options: + - 'alpha' + - 'beta' + - 'rc' + - 'next' + required: true + default: 'alpha' + force: + description: 'Force-version all packages (use for first release / bootstrap)' + type: boolean + required: false + default: false + +permissions: + contents: write + id-token: write + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + tag-version: + name: Version & Tag + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.target_branch }} + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Config Git + run: | + git config --global user.name "${{ github.actor }}" + git config --global user.email "${{ github.actor }}@users.noreply.github.com" + + - name: Prerelease Version + if: ${{ github.event.inputs.release_type == 'prerelease' }} + run: | + npx lerna version --yes \ + --conventional-commits \ + --conventional-prerelease ${{ github.event.inputs.force == 'true' && '"*"' || '' }} \ + --preid ${{ github.event.inputs.preid }} \ + --allow-branch ${{ github.event.inputs.target_branch }} \ + ${{ github.event.inputs.force == 'true' && '--force-publish' || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Graduate Version + if: ${{ github.event.inputs.release_type == 'graduate' }} + run: | + npx lerna version --yes \ + --conventional-graduate ${{ github.event.inputs.force == 'true' && '"*"' || '' }} \ + --allow-branch ${{ github.event.inputs.target_branch }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Stable Version + if: ${{ github.event.inputs.release_type == 'stable' }} + run: | + npx lerna version --yes \ + --conventional-commits \ + --allow-branch ${{ github.event.inputs.target_branch }} \ + ${{ github.event.inputs.force == 'true' && '--force-publish' || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Release Summary + run: | + echo "## Release Prepared" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** \`${{ github.event.inputs.target_branch }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Type:** \`${{ github.event.inputs.release_type }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Preid:** \`${{ github.event.inputs.preid }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Tagged" >> $GITHUB_STEP_SUMMARY + git tag --sort=-creatordate | head -10 | while read tag; do + echo "- \`$tag\`" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "Next: run the **Publish Packages** workflow." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 0000000..1fc27b6 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,127 @@ +name: Release Preview + +on: + workflow_dispatch: + inputs: + target_branch: + description: 'Branch to preview release from' + type: choice + options: + - 'next' + - 'main' + required: true + default: 'next' + release_type: + description: 'Release Type' + type: choice + options: + - 'prerelease' + - 'graduate' + - 'auto' + required: true + default: 'prerelease' + preid: + description: 'Prerelease ID (prerelease type only)' + type: choice + options: + - 'alpha' + - 'beta' + - 'rc' + - 'next' + required: false + default: 'alpha' + +permissions: + contents: read + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + preview: + name: Preview Versions + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Config Git + run: | + git config --global user.email "preview@earsyntax.dev" + git config --global user.name "Release Preview Bot" + + - name: Show changed packages + run: | + echo "## Changed Packages" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npx lerna changed --json 2>/dev/null | jq -r '.[] | "- **\(.name)** (current: `v\(.version)`)"' >> $GITHUB_STEP_SUMMARY || echo "No changed packages" >> $GITHUB_STEP_SUMMARY + + - name: Preview Versions (Prerelease) + if: ${{ inputs.release_type == 'prerelease' }} + run: | + npx lerna version --yes \ + --conventional-commits \ + --conventional-prerelease \ + --preid ${{ inputs.preid }} \ + --no-git-tag-version \ + --no-push \ + --allow-branch ${{ inputs.target_branch }} + + - name: Preview Versions (Graduate) + if: ${{ inputs.release_type == 'graduate' }} + run: | + npx lerna version --yes \ + --conventional-graduate \ + --no-git-tag-version \ + --no-push \ + --allow-branch ${{ inputs.target_branch }} + + - name: Preview Versions (Auto) + if: ${{ inputs.release_type == 'auto' }} + run: | + npx lerna version --yes \ + --conventional-commits \ + --no-git-tag-version \ + --no-push \ + --allow-branch ${{ inputs.target_branch }} + + - name: Proposed versions + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Proposed Versions" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npx lerna ls --json | jq -r '.[] | "- **\(.name)** -> `v\(.version)`"' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + git diff packages/*/package.json | head -100 >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Cleanup + if: always() + run: | + git reset --hard HEAD + git clean -fd + + - name: Footer + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "This is a preview only. No changes committed or pushed." >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0f9467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +node_modules +.DS_Store + +# build outputs +dist +**/dist +*.tsbuildinfo +*.tsbuildinfo.* + +# eslint cache +.eslintcache + +# test outputs +test-results +.tarballs +.baselines/*.json + +# package managers +# pnpm-lock.yaml is committed; package-lock.json is the legacy npm artifact, ignore it +package-lock.json + +# env +.env +.env.local +.env.*.local + +# misc +.DS_Store + +*.js.map +*.d.ts.map + +knowledgebase +poc +.claude +.claire +research +.idea +*.zip +.baseline +/worktrees/ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..70bd3dd --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..e02c24e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..74ba3a1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +**/node_modules/** +dist +**/dist/** +coverage +**/coverage/** +*.tsbuildinfo +pnpm-lock.yaml +package-lock.json +**/CHANGELOG.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..73576ff --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/EARSYNTAX-CLI-FACADE-ALPHA-0.md b/EARSYNTAX-CLI-FACADE-ALPHA-0.md new file mode 100644 index 0000000..6def132 --- /dev/null +++ b/EARSYNTAX-CLI-FACADE-ALPHA-0.md @@ -0,0 +1,271 @@ +# Orchestration Brief: Align the earsyntax CLI to the Dual-Ring Facade + +You are the orchestrator agent for a refactor of the earsyntax CLI. The CLI +today is sovereign-only: every command assumes an `.earsyntax/` workspace, +work items, and manifests. The target design splits the CLI into two rings — +a **stateless guest ring** that works in any repository with zero setup +(this is how Spec Kit, Kiro, and OpenSpec users will consume earsyntax), and +the existing **stateful project ring** for teams that adopt earsyntax as +their requirements home. Most future users will only ever touch ring 1. + +Read this entire brief before dispatching any work. Phase 0 freezes +contracts that every later phase depends on; nothing may run in parallel +with Phase 0. + +--- + +## 0. Prime directives (apply to every phase, every subagent) + +1. **The core never calls an LLM.** Validation, extraction, parsing, + linting, hashing, and gating are pure code. If a task seems to need + model judgment, the design is wrong — stop and escalate. +2. **No new lifecycle verbs. Ever.** The command surface defined in §2 is + closed. Do not add `plan`, `tasks`, `design`, `implement`, or any other + orchestration verb, regardless of how natural it seems. Narrowness is + the product position. +3. **Diagnostic IDs are append-only and namespaced.** All IDs use the + `EARS-` prefix (`EARS-E###` errors, `EARS-W###` warnings). Existing + unprefixed IDs get a one-time migration with an alias table; after that, + never renumber, never reuse, never delete. +4. **Fixtures are the specification.** Every behavior change lands with + fixture pairs (accepted / rejected) before or with the code. A profile + or rule without fixtures on both sides of its line does not exist. +5. **Existing sovereign-mode behavior must not break.** The + `agentic-loop-demo.sh` script is the compatibility oracle: it must run + green at the end of every phase (updated in the same commit if flags + change, with the change noted). +6. **Docs tell the truth.** Any example added to help text or docs must be + executed against the built CLI in-session before commit. + +## 1. Context: what exists and what changes + +Existing surface (sovereign): `init --tools claude|none`, `new`, +`instructions --work `, `validate +--source --work `, `status`, `list`, `show --artifact …`, +`accept --by `, `doctor`, `version --features`. State in +`.earsyntax/` (config.json, work//{manifest.json, requirements.ears, +questions.md, traceability.json, validation.json, validation.md}). Source +and output files content-hashed in manifests. + +What the refactor delivers: + +- Guest mode: `validate` (and `extract`, `doctor`, `explain`, `profiles`) + run stateless against arbitrary host files — no workspace. +- Markdown extraction as a first-class pipeline stage: hosts embed EARS + inside larger documents; there are no `.ears` files in guest repos. +- Profiles upgraded from dialects to **host adapters**: dialect + document + schema + severity policy, one per host. +- `init` split into `--agent` (where to write agent wrappers) and `--host` + (which framework's documents to target). `--tools` becomes a deprecated + alias for `--agent`. +- `instructions` gains `--file ` so the repair protocol travels into + guest repos that have no work items. +- SARIF output alongside JSON. +- `explain ` command. +- `accept` git-anchored; new `check` command for cross-artifact drift. + +## 2. Target facade (frozen — implement exactly this) + +### Ring 1 — stateless (no workspace required) + +| Command | Behavior | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validate ` | Locate → extract → parse → lint EARS in the given files. Accepts globs and `-` (stdin). Works with or without a workspace; workspace only adds manifest recording when `--work` is passed. | +| `extract ` | Print the requirement candidates the active profile locates, with `file:line` positions and the locator rule that matched. Debugging surface for profiles. | +| `explain ` | Full write-up of one diagnostic: meaning, rationale (with EARS ruleset citation), before/after example. Examples executed at build time. | +| `profiles` | List available profiles; for each, exactly what it relaxes/adds relative to `strict`. | +| `doctor` | Environment + host/agent detection. In a guest repo: detect `.kiro/specs/`, `specs/**/spec.md`, `openspec/`, `.claude/`, `AGENTS.md`, `.cursor/` and print the exact suggested `init`/`validate` invocation. Works without a workspace. | +| `version [--features]` | Unchanged. | + +Global flags (all commands): `--profile `, `--json`, `--sarif` +(validate only), `--strict` (warnings → errors), `--quiet`, `--cwd `. + +Exit codes (frozen): `0` valid / success; `1` findings (errors present); +`2` usage or environment failure (bad path, malformed workspace, unknown +profile). `0`/`1` are gate results; `2` means the invocation is wrong. + +### Ring 2 — project mode (requires `.earsyntax/`) + +| Command | Behavior | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init [--agent [,…]] [--host [,…]]` | Create workspace; render agent wrappers and host adapters (§5). `--tools` kept as deprecated alias for `--agent` with a warning. | +| `new --source --mode convert` / `--prompt "…" --mode author` | Unchanged. | +| `instructions (--work \| --file )` | Unchanged protocol; `--file` renders the same payload against a bare file (no manifest paths, output-in-place semantics). | +| `validate … --work ` | Ring-1 validator + manifest recording. | +| `status ` / `list` / `show --artifact …` | Unchanged. | +| `accept --by ` | Git-anchored: `--by` defaults from `git config user.name `; refuse if the work item's files are dirty/untracked; record HEAD SHA + output hash in the manifest. `--no-git` escape hatch for non-repo use, recorded as unanchored. | +| `check` | Cross-artifact drift gate (§7). Distinct from `validate`: `validate` is per-file grammar; `check` is workspace consistency. | + +## 3. Phase 0 — freeze contracts (blocking; single agent) + +Deliverables, in one PR, before anything else starts: + +1. **Findings model v1** (`docs/contracts/findings.md` + a TypeScript type + exported from core): `{ ok, summary{files, requirements, valid, errors, +warnings}, diagnostics[{ id, severity, file, line, col?, message, fix?, +requirementId? }], work? }`. The existing passing-run JSON shape is + grandfathered; `diagnostics[]` is additive. SARIF is a projection of + this model, never a second pipeline. +2. **Diagnostic registry migration**: every existing ID mapped to its + `EARS-`-prefixed successor in an alias table; old IDs still resolve in + `explain` with a deprecation note. Registry file gains a header comment + stating the append-only rule. +3. **Profile schema v1** (`docs/contracts/profile.md`): a profile is data, + not code — `{ name, notation: "ears", dialect{…}, locator{…}, +severity{…}, idFormat{…} }`. Dialect options minimally: keyword case + policy, literal system-name allowances (`THE SYSTEM`), comma-optional, + story-wrapper skip. Locator: markdown selector rules (heading patterns, + block types, list-item filters, code-fence policy). Severity: per-ID + overrides (error/warn/off). Unknown keys are a validation error — no + silent extension. +4. **Inventory**: read the current CLI end to end; produce + `docs/refactor/inventory.md` listing every command, flag, output shape, + and workspace file with its call sites. Later phases cite this instead + of re-reading the world. + +Gate: contracts merged; `agentic-loop-demo.sh` still green (nothing +behavioral changed yet). + +## 4. Phase 1 — engine split and guest-mode validate + +- Extract the pipeline into a workspace-free core library with four + stages: **locate** (profile locator over the document) → **extract** + (candidate requirement strings with source positions) → **parse** + (grammar) → **lint** (rules). For `.ears` files, locate/extract is the + trivial every-line locator. All diagnostics map positions back to the + original `file:line`. +- `validate` accepts multiple paths, globs, and stdin; drops the hard + requirement on `--work`/workspace. When `--work` is present, behavior is + exactly today's (validate + record). +- `extract` command exposes stage 2 output. +- `doctor` runs workspace-free; detection table per §2. + +Gate: `echo 'When a payment webhook arrives, the billing service shall +verify the signature.' | earsyntax validate -` returns ok in an empty +directory; demo script green; fixtures for multi-file/glob/stdin runs. + +## 5. Phase 2 — profiles as host adapters + +Ship four profiles as data files conforming to the schema, each with a +fixture directory (`fixtures/profiles//`) containing real-world-shaped +documents: + +- **`strict`** (default): canonical Mavin ruleset; `.ears` and plain-text + locators only. +- **`ears-x`**: strict superset — REQ-### frame, `[source:]` tags, + prohibition kind, reserved timing qualifiers. (Today's sovereign + behavior becomes `ears-x`; sovereign commands default to it.) +- **`kiro`**: dialect (case-insensitive/all-caps keywords, `THE SYSTEM`, + optional comma, user-story wrapper lines skipped as frame content); + locator (bullet/numbered acceptance-criteria lines under + `#### Acceptance Criteria` headings in `requirements.md`); severity + tuned so Kiro's house style validates clean. +- **`speckit`**: locator for `specs/**/spec.md` (EARS-shaped sentences in + requirements sections; ignore prose that opens with EARS keywords but + parses as nothing — extraction must not false-positive on narrative + markdown); dialect near-strict. +- **`openspec`**: locator for `### Requirement:` bodies and + `#### Scenario:` blocks inside `openspec/changes/**` and + `openspec/specs/**`; delta-aware path conventions documented. + +Each profile's fixture set includes at least one document that validates +clean under its host profile and fails under `strict`, and one the locator +must _skip entirely_ (prose false-positive guard). + +Gate: `earsyntax validate fixtures/profiles/kiro/requirements.md --profile +kiro` clean; same file under `--profile strict` fails with dialect +diagnostics; `extract` output on each fixture matches a committed snapshot. + +## 6. Phase 3 — outputs and explainability + +- **SARIF emitter** (`--sarif`): projection of findings model v1; rule + metadata (id, shortDescription, helpUri to the docs anchor) populated + from the registry; verified by uploading a sample run to a scratch + GitHub repo and confirming PR annotations render on the correct lines. +- **`explain`**: renders from the registry + a per-ID markdown snippet + directory; every snippet's before/after example executed in-session by a + test that runs `validate` on both sides. +- **`profiles`**: renders from profile data files — no hand-written + descriptions that can drift. + +Gate: SARIF validates against the 2.1.0 schema; `explain` covers 100% of +registry IDs (test enumerates the registry); demo script green. + +## 7. Phase 4 — ring-2 upgrades + +- **`instructions --file `**: same protocol payload as `--work`, + with output paths replaced by in-place file semantics and no manifest + section. The wrapper files rendered in §8 use `--file` in guest repos. +- **`accept` git anchoring** per §2. Manifest gains + `acceptance: { by, email?, at, headSha?, outputHash, anchored }`. +- **`check`**: workspace-wide, exit-code-gated. Verifies: (a) every + manifest source hash matches the file on disk (else: stale conversion); + (b) every accepted work item's recorded output hash matches the current + artifact (else: stale acceptance); (c) every `[source: path:line]` tag + resolves into the hashed source; (d) traceability.json recomputes + identically from the `.ears` file (agent-written matrix is a courtesy; + the CLI's recomputation is the truth). `--json` output follows the + findings model with `EARS-C###` check-diagnostic IDs (new registry + section, same append-only rule). + +Gate: fixture workspace with one fresh, one source-drifted, and one +acceptance-stale item produces exactly the expected three-way `check` +report; demo script extended with a `check` step and green. + +## 8. Phase 5 — init renderers and deprecations + +- `init --agent claude|codex|cursor|copilot|gemini|generic` (comma list) + renders wrapper files: `.claude/commands/earsyntax-*.md`; `AGENTS.md` + managed section (codex + generic); `.cursor/rules/earsyntax.mdc`; + `.github/prompts/earsyntax.prompt.md`; `GEMINI.md` managed section. + Every wrapper body is a thin pointer: fetch + `earsyntax instructions --json` (with `--file` in guest mode) + and follow it. No protocol content is duplicated into wrappers. +- `init --host speckit|kiro|openspec` renders host adapters: + Spec Kit extension command files; `.kiro/hooks/ears-validate.yaml` + + steering file; OpenSpec `AGENTS.md` block — each invoking ring-1 + `validate` with the matching `--profile`. +- Managed sections use begin/end markers and are idempotent on re-run. +- `--tools` prints a deprecation warning, maps to `--agent`, and is + removed from help text. +- `doctor` suggestions updated to emit the new two-flag invocation. + +Gate: running each renderer twice is a no-op the second time; a matrix +test renders every agent × host combination into a temp dir and snapshots +it; demo script updated (`init --agent claude`) and green. + +## 9. Phase 6 — conformance and docs sync + +- Full-suite run; registry/explain/fixture coverage checks from earlier + gates re-run as one conformance target (`npm run conformance`). +- README updated: guest-mode quick start becomes the first example + (validate a Kiro `requirements.md` in one line, no init); command + reference table replaced with §2; CI section gains the SARIF upload + step; `--tools` references removed. +- `docs/refactor/CHANGES.md`: every behavioral delta, one line each, for + the human reviewer. + +Final acceptance checklist (all must hold): + +- Ring-1 commands run in an empty directory with no workspace, correct + exit codes. +- `validate` on each host-profile fixture: clean under its profile, + correctly failing under `strict`. +- SARIF renders PR annotations on correct lines (evidence: screenshot or + check-run link in the PR description). +- `explain` resolves every registry ID including deprecated aliases. +- `accept` refuses on a dirty tree; records SHA when clean; `--no-git` + records unanchored. +- `check` distinguishes fresh / source-drifted / acceptance-stale. +- `agentic-loop-demo.sh` green end-to-end. +- Zero occurrences of new lifecycle verbs in the command tree. +- No LLM call anywhere in core (grep-audited: no fetch to model endpoints, + no SDK imports). + +## 10. Sequencing and parallelism + +Phase 0 is serial and blocking. Phases 1→2→3 are sequential (each consumes +the previous phase's contract). Phase 4 may run in parallel with Phase 3 +once Phase 1 lands (it touches ring 2 only). Phase 5 requires Phases 2 and 4. Phase 6 is serial and last. If any phase discovers a contract gap, +amend the contract doc first, in its own commit, with a one-line rationale +— never patch around it silently. diff --git a/EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md b/EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md new file mode 100644 index 0000000..0558d2f --- /dev/null +++ b/EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md @@ -0,0 +1,1274 @@ +# Fable Orchestration Plan: Host-Native earsyntax CLI + +This plan tells Fable how to coordinate many Claude Code agents to reshape +`earsyntax` into a host-native CLI for existing SDD frameworks. + +The target product is not an `earsyntax` workspace manager. Existing tools +such as Kiro, Spec Kit, and OpenSpec own the specification lifecycle. +`earsyntax` owns deterministic EARS extraction, validation, explanation, +SARIF output, and agent instructions inside those host documents. + +## Target Facade + +The final alpha command surface is closed: + +```bash +earsyntax validate +earsyntax extract +earsyntax instructions --file +earsyntax explain +earsyntax profiles +earsyntax doctor +earsyntax init --agent --host +earsyntax version --features +``` + +Global flags: + +```bash +--profile +--json +--sarif +--strict +--quiet +--cwd +``` + +Removed from the product surface: + +```bash +earsyntax new +earsyntax list +earsyntax status +earsyntax show +earsyntax accept +earsyntax check +``` + +Those commands belong to the old stateful workspace model. Do not keep them +in help output, README examples, or new docs. If temporary compatibility code +is needed during the refactor, keep it internal and remove it before final +alpha acceptance. + +## Non-Negotiable Rules + +1. The core never calls an LLM. +2. Claude calls `earsyntax`; `earsyntax` never calls Claude. +3. No `.earsyntax/` workspace is created. +4. No work items, manifests, acceptance records, or project lifecycle. +5. No new lifecycle verbs beyond the target facade. +6. Diagnostic IDs are append-only and namespaced: + - `EARS-E###` for errors + - `EARS-W###` for warnings + - deprecated aliases continue to resolve in `explain` +7. Profiles are data, not hard-coded conditionals scattered through parser + logic. +8. Fixtures are the specification. A behavior without fixture coverage does + not exist. +9. Docs and help examples must be executed against the built CLI before they + are published. +10. The agentic demo must remain green, but it should become host-native. + +## Fable's Job + +Fable is the agent manager. Fable does not implement everything personally. +Fable owns: + +- sequencing +- file ownership boundaries +- contract freeze +- merge order +- conflict resolution +- final conformance +- keeping agents from reintroducing workspace concepts + +Fable should activate as many Claude Code agents as useful after the blocking +contract phase. Every agent must receive a narrow brief, a file ownership +range, expected tests, and the target facade above. + +## Parallelization Model + +Phase 0 is serial. Do not run implementation agents before Phase 0 lands. + +After Phase 0, split agents by ownership boundaries: + +```text +Wave 0: Contract freeze serial +Wave 1: Core contracts and registry mostly parallel +Wave 2: Pipeline, CLI shell, profiles parallel +Wave 3: Host adapters and renderers parallel +Wave 4: Docs, demo, conformance parallel with integration gates +Wave 5: Final hardening serial +``` + +Recommended worktree pattern: + +```bash +git worktree add ../earsyntax-agent-contracts -b fable/contracts +git worktree add ../earsyntax-agent-pipeline -b fable/pipeline +git worktree add ../earsyntax-agent-cli -b fable/cli +git worktree add ../earsyntax-agent-profiles -b fable/profiles +git worktree add ../earsyntax-agent-init -b fable/init +git worktree add ../earsyntax-agent-docs -b fable/docs +``` + +Fable merges through one integration branch: + +```bash +feat/host-native-cli +``` + +Agents should not share mutable files unless Fable explicitly sequences them. + +## Phase 0: Contract Freeze + +Single agent only. + +### Agent 00: Contract Freeze Agent + +Goal: freeze the target contracts before code movement starts. + +Owns: + +- `docs/contracts/findings.md` +- `docs/contracts/profile.md` +- `docs/refactor/inventory.md` +- `docs/refactor/host-native-facade.md` +- exported TypeScript contract types, if needed + +Tasks: + +1. Read current CLI source end to end. +2. Inventory every current command, flag, output shape, and workspace file. +3. Define findings model v1: + +```ts +interface Findings { + ok: boolean; + summary: { + files: number; + requirements: number; + valid: number; + errors: number; + warnings: number; + }; + diagnostics: Diagnostic[]; +} + +interface Diagnostic { + id: string; + severity: 'error' | 'warning'; + file: string; + line: number; + col?: number; + message: string; + fix?: string; + requirementId?: string; +} +``` + +4. Define profile schema v1: + +```ts +interface Profile { + name: 'strict' | 'ears-x' | 'kiro' | 'speckit' | 'openspec'; + notation: 'ears'; + dialect: { + keywordCase: 'strict' | 'case-insensitive'; + allowLiteralSystemName: string[]; + commaAfterLeadingClause: 'required' | 'optional'; + allowStoryWrapper: boolean; + allowFrameMetadata: boolean; + allowProhibition: boolean; + }; + locator: { + documentKinds: string[]; + include: LocatorRule[]; + exclude: LocatorRule[]; + codeFences: 'ignore' | 'include'; + }; + severity: Record; + idFormat: { + required: boolean; + pattern?: string; + }; +} +``` + +5. Define final command output shapes for: + - `validate --json` + - `extract --json` + - `instructions --json` + - `explain --json` + - `profiles --json` + - `doctor --json` + - `init --json` + - `version --features --json` + +6. State explicitly that old workspace commands are removed from the target + alpha surface. + +Acceptance gate: + +```bash +pnpm test +pnpm typecheck +pnpm build +``` + +Fable gate: + +- No implementation phase starts until this contract is merged. +- Fable reviews the contracts for hidden workspace assumptions. + +## Phase 1: Core Foundation + +These agents can run in parallel after Phase 0. + +### Agent 01: Diagnostic Registry Agent + +Goal: migrate all diagnostics to the new append-only registry. + +Owns: + +- `packages/core/src/diagnostics*` +- `docs/diagnostics.md` +- `docs/contracts/findings.md` updates if needed +- `fixtures/diagnostics/**` + +Tasks: + +1. Create a registry with stable IDs: + - `EARS-E###` + - `EARS-W###` +2. Map every old diagnostic ID to a new ID. +3. Keep aliases resolvable for `explain`. +4. Add registry metadata: + - title + - severity + - rationale + - before example + - after example + - profile notes +5. Add tests that fail if an ID is removed, reused, or duplicated. + +Outputs: + +- registry module +- alias table +- coverage test for registry uniqueness + +Acceptance gate: + +```bash +pnpm --filter @earsyntax/core test +pnpm typecheck +``` + +### Agent 02: Findings Model Agent + +Goal: make validation return one canonical findings model. + +Owns: + +- `packages/core/src/findings*` +- `packages/cli-contract/**` +- CLI contract fixtures + +Tasks: + +1. Implement shared `Findings` and `Diagnostic` types. +2. Convert existing parse/lint output into `Findings`. +3. Preserve useful old result detail only as optional command-specific data. +4. Ensure `ok` means no error diagnostics. +5. Ensure `--strict` can upgrade warnings to errors at the findings layer. + +Acceptance gate: + +```bash +pnpm --filter @earsyntax/cli-contract test +pnpm --filter @earsyntax/core test +``` + +### Agent 03: Profile Schema Agent + +Goal: profiles are validated data files. + +Owns: + +- `packages/core/src/profiles*` +- `profiles/*.json` or `packages/core/src/profiles/*.ts` +- `docs/contracts/profile.md` +- `fixtures/profiles/schema/**` + +Tasks: + +1. Implement strict profile schema validation. +2. Reject unknown keys. +3. Load built-in profiles: + - `strict` + - `ears-x` + - `kiro` + - `speckit` + - `openspec` +4. Add `resolveProfile(name)` API. +5. Add profile-diff data for `profiles` command. + +Acceptance gate: + +```bash +pnpm --filter @earsyntax/core test +pnpm typecheck +``` + +### Agent 04: Grammar/Profile Semantics Agent + +Goal: reconcile parser behavior with strict and `ears-x`. + +Owns: + +- parser grammar files +- linter grammar decisions +- `fixtures/valid/**` +- `fixtures/invalid/**` +- profile-specific grammar fixtures + +Tasks: + +1. Make `strict` match canonical EARS: + - comma required after leading `When`, `While`, `Where`, `If` + - `If , then ...` required + - `then` forbidden outside unwanted behavior + - max one `When` + - exact `the ` system form + - pronoun system references rejected + - strict keyword casing + - one requirement per line/sentence + - `shall not` rejected +2. Make `ears-x` a strict superset: + - allow `REQ-###` + - allow `[source: path:line]` + - allow `shall not` as prohibition +3. Ensure Complex is first-class in parser, docs, and CLI feature output. +4. Add fixture pairs for every grammar decision. + +Acceptance gate: + +```bash +pnpm --filter @earsyntax/core test +pnpm --filter @earsyntax/cli test +``` + +## Phase 2: Stateless Pipeline And Commands + +These agents run in parallel, but Agent 05 defines APIs that Agents 06 and 07 +consume. Fable should merge Agent 05 first. + +### Agent 05: Locate Extract Parse Lint Pipeline Agent + +Goal: create the host-native validation pipeline. + +Owns: + +- `packages/core/src/pipeline*` +- `packages/extract/**` +- pipeline fixtures + +Pipeline: + +```text +locate host document regions +extract candidate requirement text with source position +parse EARS sentence +lint parsed requirement +return findings +``` + +Tasks: + +1. Support file paths, globs, and stdin. +2. Preserve original `file:line:col` through all stages. +3. Treat `.ears` and plain text as trivial every-line extraction. +4. Let profiles control Markdown section/list/code-fence behavior. +5. Ensure prose that looks vaguely EARS-like can be skipped by locator rules + rather than turned into false parser errors. + +Acceptance gate: + +```bash +echo 'When a payment webhook arrives, the billing service shall verify the signature.' | node packages/cli/bin/run.js validate - +pnpm --filter @earsyntax/extract test +pnpm --filter @earsyntax/core test +``` + +### Agent 06: Validate Command Agent + +Goal: make `validate` work without any workspace. + +Owns: + +- `packages/cli/src/commands/validate.ts` +- validate CLI tests +- validate fixtures + +Tasks: + +1. Remove workspace requirement. +2. Accept multiple paths. +3. Accept globs. +4. Accept `-` for stdin. +5. Add `--profile`. +6. Add `--strict`. +7. Preserve exit codes: + - `0` success or no error findings + - `1` error findings + - `2` usage/environment failure +8. Support `--json`. +9. Leave `--sarif` stubbed only if SARIF agent has not merged yet, but the + final branch must implement it. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js validate fixtures/demo/valid-only.ears --profile ears-x +node packages/cli/bin/run.js validate does-not-exist.ears --json +printf 'When x, the y shall z.\n' | node packages/cli/bin/run.js validate - --json +pnpm --filter @earsyntax/cli test +``` + +### Agent 07: Extract Command Agent + +Goal: expose the locator/extractor output. + +Owns: + +- `packages/cli/src/commands/extract.ts` +- extract CLI tests +- extract snapshots + +Tasks: + +1. Add `extract `. +2. Return candidates with: + - file + - line + - col + - text + - profile + - locator rule ID +3. Support `--json`. +4. Support profile-specific Markdown locators. +5. Add snapshot fixtures per host profile. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js extract fixtures/profiles/kiro/requirements.md --profile kiro --json +pnpm --filter @earsyntax/cli test +``` + +### Agent 08: CLI Shell Cleanup Agent + +Goal: align command dispatcher, help, flags, and exit code behavior. + +Owns: + +- `packages/cli/src/cli.ts` +- `packages/cli/src/args.ts` +- help/version fixtures + +Tasks: + +1. Add commands: + - `extract` + - `explain` + - `profiles` +2. Remove old commands from help: + - `new` + - `list` + - `status` + - `show` + - `accept` + - `check` +3. Keep compatibility code only if Fable explicitly approves, but do not + document it. +4. Add global `--profile`, `--strict`, `--quiet`. +5. Restrict `--sarif` to `validate`. +6. Update `version --features`. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js --help +node packages/cli/bin/run.js version --features --json +pnpm --filter @earsyntax/cli test +``` + +## Phase 3: Host Profiles + +Run these agents in parallel after profile schema and pipeline APIs merge. +Each host agent owns its fixture directory. + +### Agent 09: Strict And ears-x Profile Agent + +Goal: implement base profiles. + +Owns: + +- strict profile data +- ears-x profile data +- `fixtures/profiles/strict/**` +- `fixtures/profiles/ears-x/**` + +Tasks: + +1. `strict` accepts canonical Mavin EARS only. +2. `ears-x` accepts strict plus: + - `REQ-###` frame metadata + - `[source: path:line]` + - prohibition via `shall not` +3. Every strict-valid requirement is `ears-x` valid unchanged. +4. Add strict-fail / ears-x-pass fixture pairs. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js validate fixtures/profiles/strict/valid.ears --profile strict +node packages/cli/bin/run.js validate fixtures/profiles/ears-x/prohibition.ears --profile ears-x +node packages/cli/bin/run.js validate fixtures/profiles/ears-x/prohibition.ears --profile strict --json +``` + +### Agent 10: Kiro Profile Agent + +Goal: validate EARS embedded in Kiro `requirements.md`. + +Owns: + +- Kiro profile data +- `fixtures/profiles/kiro/**` +- Kiro docs notes + +Profile behavior: + +- case-insensitive/all-caps keywords +- literal `THE SYSTEM` allowed +- comma after leading clause optional +- user-story wrapper lines skipped as frame content +- `#### Acceptance Criteria` sections are locator anchors +- list items under acceptance criteria are candidate requirements + +Tasks: + +1. Create real-world-shaped Kiro fixture. +2. Add one clean-under-kiro / fail-under-strict fixture. +3. Add one false-positive guard fixture that extracts no requirements. +4. Snapshot `extract` output. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js extract fixtures/profiles/kiro/requirements.md --profile kiro --json +node packages/cli/bin/run.js validate fixtures/profiles/kiro/requirements.md --profile kiro +node packages/cli/bin/run.js validate fixtures/profiles/kiro/requirements.md --profile strict --json +``` + +### Agent 11: Spec Kit Profile Agent + +Goal: validate EARS in Spec Kit-style `specs/**/spec.md`. + +Owns: + +- Spec Kit profile data +- `fixtures/profiles/speckit/**` +- Spec Kit docs notes + +Profile behavior: + +- near-strict dialect +- locator targets requirements sections in `specs/**/spec.md` +- prose under design/background sections is skipped +- narrative Markdown that starts with EARS keywords but is not a requirement + must not become a false positive + +Tasks: + +1. Create Spec Kit fixture with requirements section. +2. Create strict failure pair if Spec Kit syntax needs relaxation. +3. Create locator skip fixture. +4. Snapshot `extract` output. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js validate fixtures/profiles/speckit/spec.md --profile speckit +node packages/cli/bin/run.js extract fixtures/profiles/speckit/spec.md --profile speckit --json +``` + +### Agent 12: OpenSpec Profile Agent + +Goal: validate EARS in OpenSpec specs and changes. + +Owns: + +- OpenSpec profile data +- `fixtures/profiles/openspec/**` +- OpenSpec docs notes + +Profile behavior: + +- locator targets: + - `openspec/specs/**` + - `openspec/changes/**` + - `### Requirement:` bodies + - `#### Scenario:` blocks +- delta-aware path conventions documented +- non-requirement prose skipped + +Tasks: + +1. Create OpenSpec spec fixture. +2. Create OpenSpec change fixture. +3. Create strict failure pair if profile relaxes syntax. +4. Create false-positive guard. +5. Snapshot `extract` output. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js validate fixtures/profiles/openspec/change.md --profile openspec +node packages/cli/bin/run.js extract fixtures/profiles/openspec/change.md --profile openspec --json +``` + +## Phase 4: Agent Instructions And Init + +These agents can run in parallel after the command shell and profile runtime +are in place. + +### Agent 13: Instructions Command Agent + +Goal: make the agent loop work against host files. + +Owns: + +- `packages/cli/src/commands/instructions.ts` +- `packages/cli/src/rules.ts` +- instruction fixtures + +Command: + +```bash +earsyntax instructions --file --profile --json +``` + +Response must include: + +- mode +- file path +- profile name +- profile locator summary +- dialect constraints +- edit policy +- output policy: edit the host file in place +- diagnostics for repair/review modes +- next command + +Repair flow: + +```bash +earsyntax validate --profile --json +earsyntax instructions repair --file --profile --json +# agent edits only +earsyntax validate --profile --json +``` + +Author/convert flow: + +- `author`: use a prompt or existing host section if the file has a marked + empty requirements area. +- `convert`: transform natural-language requirements already present in the + host file into EARS-shaped requirements in place. +- The CLI returns rules; it does not perform semantic conversion. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js instructions repair --file fixtures/profiles/kiro/requirements.md --profile kiro --json +pnpm --filter @earsyntax/cli test +``` + +### Agent 14: Init Renderer Agent + +Goal: implement integration init. + +Owns: + +- `packages/cli/src/commands/init.ts` +- renderer modules +- renderer snapshots + +Command: + +```bash +earsyntax init --agent claude,codex,cursor,copilot,gemini,generic --host kiro,speckit,openspec +``` + +Aliases: + +```bash +earsyntax init --tools claude +``` + +`--tools` is deprecated. It should work, emit a warning, and not appear in +help. + +`init` must not: + +- create `.earsyntax/` +- create work items +- edit existing requirement/spec documents +- run validation as a side effect +- call an LLM + +`init` must: + +1. Detect the repo root from `--cwd`. +2. Validate requested agents and hosts. +3. Render managed files only. +4. Use begin/end markers for files that may already exist. +5. Be idempotent. Running the same command twice should produce no diff. +6. Return exact JSON: + +```json +{ + "version": "0.0.0", + "command": "init", + "ok": true, + "root": "/repo", + "agents": ["claude"], + "hosts": ["kiro"], + "written": [".claude/commands/earsyntax-repair.md"], + "updated": [], + "skipped": [], + "warnings": [], + "next": [ + { + "command": "earsyntax validate \".kiro/specs/**/requirements.md\" --profile kiro", + "reason": "Validate Kiro requirements with the Kiro profile." + } + ] +} +``` + +Renderer matrix: + +| Agent | Files | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude` | `.claude/commands/earsyntax-author.md`, `.claude/commands/earsyntax-convert.md`, `.claude/commands/earsyntax-repair.md`, `.claude/commands/earsyntax-review.md` | +| `codex` | managed `AGENTS.md` section | +| `cursor` | `.cursor/rules/earsyntax.mdc` | +| `copilot` | `.github/prompts/earsyntax.prompt.md` | +| `gemini` | managed `GEMINI.md` section | +| `generic` | managed `AGENTS.md` section | + +Host renderer matrix: + +| Host | Files | +| ---------- | -------------------------------------------------------------------------------------------------- | +| `kiro` | `.kiro/steering/earsyntax.md`, `.kiro/hooks/ears-validate.yaml` | +| `speckit` | Spec Kit extension command files, plus managed instructions if the framework supports them locally | +| `openspec` | managed `AGENTS.md` section with OpenSpec validation commands | + +Every wrapper must be thin: + +```text +Run earsyntax instructions --file --profile --json. +Follow the returned rules exactly. +Edit only the host file. +Run earsyntax validate --profile --json. +Repeat until clean. +Do not approve, accept, or merge. +``` + +Acceptance gate: + +```bash +node packages/cli/bin/run.js init --agent claude --host kiro --cwd --json +node packages/cli/bin/run.js init --agent claude --host kiro --cwd --json +git diff --exit-code +pnpm --filter @earsyntax/cli test +``` + +### Agent 15: Doctor Command Agent + +Goal: make `doctor` useful in existing SDD repos. + +Owns: + +- `packages/cli/src/commands/doctor.ts` +- detection tests +- fixtures for fake host repos + +Doctor detects: + +- `.kiro/specs/` +- `.kiro/steering/` +- `.kiro/hooks/` +- `specs/**/spec.md` +- `.specify/` +- `openspec/` +- `.claude/` +- `AGENTS.md` +- `.cursor/` +- `.github/prompts/` +- `GEMINI.md` + +Output should recommend exact commands: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +earsyntax init --agent claude --host kiro +``` + +Acceptance gate: + +```bash +node packages/cli/bin/run.js doctor --cwd fixtures/host-repos/kiro --json +pnpm --filter @earsyntax/cli test +``` + +## Phase 5: Explainability And Outputs + +### Agent 16: Explain Command Agent + +Goal: every diagnostic has a human-readable explanation. + +Owns: + +- `packages/cli/src/commands/explain.ts` +- `docs/explain/*.md` or registry snippets +- explain tests + +Command: + +```bash +earsyntax explain EARS-E001 +earsyntax explain ears.missing_shall +``` + +Tasks: + +1. Resolve current IDs. +2. Resolve deprecated aliases. +3. Print deprecation note when alias is used. +4. Include: + - meaning + - rationale + - bad example + - good example + - profile notes +5. Add a test that every registry ID has an explanation. +6. Add a test that every example in explanations validates as claimed. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js explain EARS-E001 +pnpm --filter @earsyntax/cli test +``` + +### Agent 17: Profiles Command Agent + +Goal: expose profile data without hand-written drift. + +Owns: + +- `packages/cli/src/commands/profiles.ts` +- profile output fixtures + +Tasks: + +1. Render built-in profiles from profile data. +2. For each profile, list: + - what it locates + - what it relaxes + - what it adds + - severity overrides +3. Support `--json`. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js profiles +node packages/cli/bin/run.js profiles --json +``` + +### Agent 18: SARIF Agent + +Goal: make `validate --sarif` work from the findings model. + +Owns: + +- SARIF emitter module +- CLI `--sarif` integration +- SARIF fixtures + +Tasks: + +1. Project `Findings` to SARIF 2.1.0. +2. Use registry metadata for SARIF rules. +3. Populate: + - rule ID + - short description + - help URI or docs anchor + - file locations +4. Validate SARIF schema in tests. +5. Ensure `--sarif` and `--json` are mutually exclusive or define a clear + precedence. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js validate fixtures/profiles/kiro/requirements.md --profile kiro --sarif +pnpm --filter @earsyntax/cli test +``` + +## Phase 6: Docs, Demo, And CI + +These can run in parallel after most command behavior exists. + +### Agent 19: README And CLI Docs Agent + +Goal: make docs match the host-native CLI. + +Owns: + +- `README.md` +- `docs/quickstart.md` +- `docs/cli.md` +- `docs/input-formats.md` +- `docs/agent-rules.md` + +Tasks: + +1. First README screen shows host-native usage: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +2. Remove workspace-first flow. +3. Remove `new`, `list`, `status`, `show`, `accept`. +4. Document `init` as integration setup, not project setup. +5. Add CI example with SARIF. +6. Mark unsupported/planned behavior honestly. +7. Execute every command example before declaring done. + +Acceptance gate: + +```bash +node packages/cli/bin/run.js --help +node packages/cli/bin/run.js version --features --json +pnpm format:check +``` + +### Agent 20: Host-Native Demo Agent + +Goal: replace the current workspace demo with a host-native demo. + +Owns: + +- `scripts/agentic-loop-demo.sh` +- demo fixtures if needed + +Demo should show: + +1. Write a Kiro-style `requirements.md`. +2. Run `earsyntax doctor`. +3. Run `earsyntax init --agent claude --host kiro`. +4. Run `earsyntax extract`. +5. Run `earsyntax validate` and fail. +6. Call Claude: + +```bash +claude -p "/earsyntax-repair .kiro/specs/checkout/requirements.md --profile kiro" +``` + +7. Revalidate until clean. +8. Show SARIF output. +9. Finish with CI command suggestion. + +Rules: + +- Keep colors and pauses. +- Keep `RUN_CLAUDE=1`. +- Keep deterministic fallback. +- Remove `.earsyntax/` work-item assumptions. + +Acceptance gate: + +```bash +PAUSE=0 scripts/agentic-loop-demo.sh +RUN_CLAUDE=0 PAUSE=0 scripts/agentic-loop-demo.sh +``` + +### Agent 21: Conformance Target Agent + +Goal: create one command that proves the alpha facade. + +Owns: + +- root package scripts +- conformance tests +- fixture snapshot tests + +Add: + +```bash +pnpm conformance +``` + +It should run: + +- build +- typecheck +- unit tests +- CLI command smoke tests +- profile fixture matrix +- explain coverage +- SARIF schema validation +- help text forbidden-command check +- demo no-pause smoke test + +Acceptance gate: + +```bash +pnpm conformance +``` + +## Phase 7: Integration And Hardening + +Fable should run this phase mostly serially. + +### Agent 22: Integration Auditor + +Goal: find contradictions after all branches merge. + +Owns no files initially. Reads everything. + +Audit checklist: + +- `--help` contains only target commands. +- `version --features --json` reports all profiles and outputs. +- no `.earsyntax/` path appears in new docs, tests, help, or demo except in + migration notes if Fable intentionally keeps them. +- no command examples use removed verbs. +- `strict` and `ears-x` behavior align with grammar brief. +- `instructions` never tells an agent to approve or accept. +- `init` is idempotent. +- `validate` works in an empty directory. +- `extract` source positions are stable. +- SARIF uses registry IDs. +- profile descriptions come from profile data. + +Acceptance gate: + +```bash +pnpm conformance +git grep -n ".earsyntax\\|earsyntax new\\|earsyntax accept\\|earsyntax status\\|earsyntax show\\|earsyntax list" +``` + +Fable decides whether any matches are legitimate migration references. + +### Agent 23: Final Fix Agent + +Goal: handle small cross-cutting defects found by the integration auditor. + +Rules: + +- No refactors. +- No new commands. +- No new profiles. +- Fix only acceptance-gate failures. + +Acceptance gate: + +```bash +pnpm conformance +pnpm format:check +git status -sb +``` + +## Developer Lifecycle After Implementation + +Kiro example: + +```bash +earsyntax doctor +earsyntax init --agent claude --host kiro +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +If validation fails: + +```bash +claude -p "/earsyntax-repair .kiro/specs/checkout/requirements.md --profile kiro" +earsyntax validate ".kiro/specs/checkout/requirements.md" --profile kiro +``` + +CI: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif +``` + +Human approval happens in the normal host workflow: PR review, Kiro review, +Spec Kit review, or OpenSpec change review. `earsyntax` does not record +acceptance. + +## Agent Lifecycle After Implementation + +The agent receives a host file path and profile. + +1. Inspect profile and diagnostics: + +```bash +earsyntax validate --profile --json +``` + +2. Fetch phase instructions: + +```bash +earsyntax instructions repair --file --profile --json +``` + +3. Edit only the host file. +4. Preserve surrounding host document structure. +5. Re-run validation: + +```bash +earsyntax validate --profile --json +``` + +6. Repeat until clean. +7. Present a review summary: + - changed lines + - requirements repaired + - diagnostics resolved + - unresolved ambiguity + - validation command and result + +The agent must not call `accept`, create `.earsyntax/`, or invent behavior. + +## File Ownership Map + +Use this to reduce conflicts: + +| Area | Primary Agent | +| ---------------------- | ------------- | +| contracts docs | Agent 00 | +| diagnostic registry | Agent 01 | +| findings model | Agent 02 | +| profile schema/runtime | Agent 03 | +| parser semantics | Agent 04 | +| pipeline/extract core | Agent 05 | +| validate command | Agent 06 | +| extract command | Agent 07 | +| CLI dispatcher/help | Agent 08 | +| strict/ears-x profiles | Agent 09 | +| Kiro profile | Agent 10 | +| Spec Kit profile | Agent 11 | +| OpenSpec profile | Agent 12 | +| instructions command | Agent 13 | +| init renderers | Agent 14 | +| doctor | Agent 15 | +| explain | Agent 16 | +| profiles command | Agent 17 | +| SARIF | Agent 18 | +| README/docs | Agent 19 | +| demo script | Agent 20 | +| conformance target | Agent 21 | +| integration audit | Agent 22 | +| final fixes | Agent 23 | + +## Merge Order + +Fable should merge in this order: + +1. Agent 00 +2. Agents 01, 02, 03 +3. Agent 04 +4. Agent 05 +5. Agents 06, 07, 08 +6. Agents 09, 10, 11, 12 +7. Agents 13, 14, 15 +8. Agents 16, 17, 18 +9. Agents 19, 20, 21 +10. Agent 22 +11. Agent 23 + +Run the relevant gate after each merge group. If a group fails, Fable stops +parallel intake and assigns a focused fix agent. + +## Claude Agent Prompt Template + +Fable can launch each Claude Code agent with this shape: + +```text +You are Agent : . + +Goal: + + +Read first: +- EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md +- EARSYNTAX-CLI-FACADE-ALPHA-0.md +- GRAMMAR-AGENT-BRIEF-FABLE.md when parser/profile behavior is involved +- relevant source files for your ownership area + +Hard rules: +- Do not add commands outside the target facade. +- Do not create .earsyntax workspace behavior. +- Do not call an LLM from core or CLI. +- Add fixtures with behavior changes. +- Keep diagnostics in the EARS-E### / EARS-W### registry. + +Owned files: + + +Deliverables: + + +Verification: + + +Stop and report if: +- the contract is ambiguous +- another agent owns the file you need +- parser behavior conflicts with the grammar brief +- implementing your task requires reviving workspace mode +``` + +## Final Alpha Acceptance + +The refactor is complete only when all hold: + +```bash +pnpm conformance +node packages/cli/bin/run.js --help +node packages/cli/bin/run.js version --features --json +PAUSE=0 scripts/agentic-loop-demo.sh +``` + +Manual checks: + +- Empty-directory validation works. +- Stdin validation works. +- Kiro fixture validates clean under `kiro` and fails under `strict`. +- Spec Kit fixture validates under `speckit`. +- OpenSpec fixture validates under `openspec`. +- `extract` snapshots show correct source positions. +- `explain` resolves every registry ID and deprecated alias. +- `init` renders agent and host integration files idempotently. +- `validate --sarif` emits schema-valid SARIF. +- No public doc or help text advertises removed workspace commands. diff --git a/GRAMMAR-AGENT-BRIEF-FABLE.md b/GRAMMAR-AGENT-BRIEF-FABLE.md new file mode 100644 index 0000000..7497fff --- /dev/null +++ b/GRAMMAR-AGENT-BRIEF-FABLE.md @@ -0,0 +1,215 @@ +# Task Brief: Author `GRAMMAR.md` for earsyntax + +You are writing the reference grammar for EARS (Easy Approach to Requirements +Syntax). No formal grammar for EARS has ever been published — only Alistair +Mavin's prose ruleset. This document is therefore not internal documentation: +it is the artifact that makes earsyntax the reference implementation. Write it +as a normative specification, in the register of an RFC or a language spec, +not a README. + +The file lives at the repository root as `GRAMMAR.md`. It is versioned with +the package and linked prominently from the README. + +--- + +## 1. Authority hierarchy + +Resolve every question in this order: + +1. **Mavin's published ruleset** (alistairmavin.com/ears, the RE'09 paper, + and "Ten Years of EARS", IEEE Software 2019). Canon: + - zero or many preconditions + - zero or one trigger + - one system name + - one or many system responses + - clauses appear in temporal order + - the modal is **shall** + The five patterns: Ubiquitous, State-driven (`While`), Event-driven + (`When`), Optional feature (`Where`), Unwanted behaviour (`If … then`), + plus Complex (combinations). Quote the ruleset once, briefly, with + attribution; do not paraphrase it into something new. +2. **The implemented parser.** The grammar must describe what the CLI + actually accepts and rejects. Where the parser and Mavin's prose + conflict, do NOT silently paper over it: either fix the parser or record + the divergence as an open issue reference in the Decisions section. + The grammar document may never be aspirational about the strict core. +3. **Our adjudication.** Where Mavin's prose is silent (comma placement, + case, negation, etc.), we decide. Every such decision is recorded + explicitly — see §4. + +## 2. Required document structure + +Produce these sections in this order: + +1. **Status** — semver of this document, conformance keywords (RFC 2119: + MUST/SHOULD/MAY), and a one-paragraph statement of intent: this document + formalizes Mavin's canonical ruleset; it does not extend EARS in the + strict core. +2. **Conformance levels** — define exactly three: + - `strict` (default): Mavin's ruleset, nothing more. + - `profile:kiro`: dialect acceptance for AWS Kiro's requirements.md + style (see §5). + - `profile:ears-x`: earsyntax extensions (see §5). + A conforming implementation MUST implement `strict`; profiles are opt-in + and MUST be supersets or relaxations that are explicitly enumerated — + never silent. +3. **Lexical layer** — keywords (`When`, `While`, `Where`, `If`, `then`, + `shall`, and the system-name article), case policy (strict core: + keyword-initial capitalization as in Mavin's examples; case-insensitive + matching is a profile concern), sentence terminator (full stop required), + and the metadata layer: `REQ-###` identifiers and `[source: path:line]` + tags are **frame metadata**, not part of the EARS sentence grammar — + define them in their own subsection with their own productions. +4. **The patterns** — one subsection per pattern (Ubiquitous, Event-driven, + State-driven, Unwanted behaviour, Optional feature, Complex). Each + subsection MUST contain: + - the template in Mavin's wording + - the EBNF production(s) + - at least 3 valid examples + - at least 3 invalid examples, each annotated with the exact diagnostic + ID the validator emits for it + For Complex: clause composition rules and the temporal-order constraint + (precondition clauses before trigger clause before system clause), with + at least one While+When and one Where+If example. +5. **EBNF appendix** — one complete, self-contained grammar block covering + everything, machine-readable (ISO-style EBNF; pick one dialect and name + it). This block is authoritative; the per-pattern productions in §4 are + excerpts of it, never variants. +6. **Decisions** — see §4 below. +7. **Profiles** — see §5 below. +8. **Prior art and non-goals** — see §6 below. +9. **Diagnostics mapping** — a table from every grammar rule to the stable + diagnostic ID(s) it can raise. Diagnostic IDs come from the append-only + registry in the codebase; do not invent new IDs in the document — if a + rule has no ID yet, add it to the registry first, then reference it. +10. **Conformance test suite** — state that `fixtures/valid/**` and + `fixtures/invalid/**` are the executable definition of this grammar, + and that every production and every Decision entry MUST be witnessed by + at least one fixture on each side of the line it draws. + +## 3. Grammar content rules + +- Every example in the document MUST round-trip through the actual CLI + before you commit: valid examples pass `earsyntax validate`, invalid + examples fail with exactly the diagnostic ID stated. Run them; do not + transcribe from memory. If an example doesn't behave as documented, the + parser or the document is wrong — resolve it, don't fudge it. +- Placeholders in templates use angle brackets (``, ``, + ``) and are defined once in the lexical section. +- Response grammar: define what a single response is, and specify that + responses joined by `and` within one requirement are permitted by Mavin + ("one or many system responses") but SHOULD-level linted when they bundle + independently testable obligations (the compound-response lint). The + grammar accepts; the linter advises. Keep that split explicit. +- Do not define semantics. This is a syntax specification. Vague-term + detection (the INCOSE word list), weak modals (`should`, `must`, `will`), + and testability advice are **lint rules**, documented in the diagnostics + mapping as warnings — they are not grammar productions and must not be + presented as conformance requirements. + +## 4. Decisions section — the edges you must adjudicate + +Record each as: **ES-D-### — question — ruling — rationale — fixtures**. +Rulings you must include (rule as specified here; if the implemented parser +disagrees, reconcile first): + +- **ES-D-001 Comma after leading clause.** Strict core: REQUIRED after a + precondition/trigger clause (`When , the …`). Rationale: + Mavin's examples are consistent; determinism needs a delimiter. + `profile:kiro` relaxes to optional. +- **ES-D-002 `then` keyword.** REQUIRED in Unwanted behaviour + (`If , then …`); FORBIDDEN elsewhere. This is the discriminator + between If-pattern and When-pattern misuse. +- **ES-D-003 Multiple triggers.** One trigger clause maximum (Mavin: "zero + or one trigger"). Two `When` clauses in one requirement is an error, with + a fix-message telling the author to split. +- **ES-D-004 Negative responses (`shall not`).** The most important entry — + write it as a full paragraph. Canonical EARS and classical RM practice + disallow negative requirements because an absence is not conventionally + verifiable. Strict core therefore flags `shall not` as an error whose + message states this rationale. `profile:ears-x` legalizes it as a distinct + **prohibition** requirement kind (its own production, its own diagnostic + space), because the downstream verification layer (Suites Blackbox + `forbids`) can verify absence at runtime. The document states this + reasoning explicitly: the profile exists because verification technology + changed, not because the classical rule was wrong. +- **ES-D-005 System name.** Exactly one, definite-article form + (`the `). Pronouns (`it`) as system reference: error. +- **ES-D-006 Timing/quantity qualifiers (`within`, `at least`, `exactly`).** + Not in the strict core. RESERVED in `profile:ears-x` with productions + marked _(reserved, not yet implemented)_ and a pointer to the limitations + doc. Do not silently omit this; naming the gap is required. +- **ES-D-007 Case sensitivity.** Strict core: keywords as capitalized in + the templates, `shall` lowercase. `profile:kiro`: fully case-insensitive + keywords, all-caps accepted. +- **ES-D-008 One requirement per line/sentence.** Exactly one EARS sentence + per requirement entry; a second `shall` outside an `and`-joined response + list is an error. + +Add further ES-D entries for any edge you hit while verifying examples +against the parser. Silence is the only forbidden ruling. + +## 5. Profiles + +- **`profile:kiro`** — enumerate exactly what it relaxes relative to + strict, nothing else: case-insensitive/all-caps keywords, literal + `THE SYSTEM` as system name, optional comma, tolerance for the + user-story wrapper lines (`As a … I want …` and `#### Acceptance +Criteria` headers are skipped as non-EARS frame content, not parsed). + Each relaxation gets a fixture pair (accepted under profile, rejected + under strict). +- **`profile:ears-x`** — enumerate exactly what it adds: `REQ-###` ID + frame, `[source: path:line]` tags, the prohibition kind (ES-D-004), + reserved timing qualifiers (ES-D-006). State that ears-x is a strict + superset: every strict-valid requirement is ears-x-valid unchanged. + +## 6. Prior art and non-goals + +- **Adv-EARS**: one paragraph. Cite Majumdar et al., ACITY 2011 (Springer + CCIS 198). State plainly: it published a formal grammar for a _modified_ + EARS dialect aimed at deriving UML use-case models; this document instead + formalizes Mavin's canonical ruleset as written, unmodified, for + validation purposes. This paragraph preempts "a grammar already exists." +- **GEARS**: one sentence acknowledging the 2026 community variant exists + and is out of scope; fragmentation of the notation is part of why a + reference grammar is needed. +- **Non-goals**: semantic contradiction checking, requirement quality + scoring beyond lint warnings, code or test generation, natural-language + conversion (that is the agent's job; this grammar defines what the agent's + output must satisfy). + +## 7. Style constraints + +- Normative voice. RFC 2119 keywords in small caps or bold consistently. +- No marketing, no comparisons to competitors, no adjectives about + ourselves. The document's authority comes from precision. +- Attribute Mavin by name wherever the ruleset is stated. The tone toward + the canonical source is deferential: we formalize, we do not amend (the + strict core), and we clearly fence what is ours (profiles, decisions). +- Keep total length in the 600–900 line range. Every line either defines, + exemplifies, or adjudicates. No filler. + +## 8. Acceptance criteria + +The task is complete when all of the following hold: + +- The document shall contain a single authoritative EBNF block from which + every per-pattern production is an exact excerpt. +- Every valid example shall pass `earsyntax validate` under the stated + conformance level, verified by execution in this session. +- Every invalid example shall fail with exactly the diagnostic ID printed + beside it, verified by execution in this session. +- Every ES-D decision shall reference at least one fixture on each side of + its ruling, and those fixtures shall exist in the repository. +- The diagnostics table shall reference only IDs present in the registry, + and every grammar-layer registry ID shall appear in the table. +- `README.md` shall link to `GRAMMAR.md` from its first screen. +- If any divergence between parser and document was found and could not be + fixed in-session, it shall be recorded in Decisions with an issue link — + zero silent divergences. + +Sequencing: read the parser and the diagnostic registry first; draft the +EBNF against the implementation; verify every example by running the CLI; +only then write prose. If you find the parser accepts something this brief +rules out (or vice versa), stop and reconcile before continuing the +document. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8789bb --- /dev/null +++ b/README.md @@ -0,0 +1,446 @@ +# earsyntax + +Deterministic EARS extraction, validation, and agent instructions for +requirements that already live in Kiro, Spec Kit, OpenSpec, or plain files. + +```bash +npx @earsyntax/cli validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +Successful output looks like: + +```text +12/12 valid across 3 file(s), 0 error(s), 0 warning(s) +``` + +`earsyntax` formalizes Easy Approach to Requirements Syntax (EARS) as a +Node.js parser, linter, extractor, and CLI facade. It locates requirement +candidates, checks them against a named profile, reports stable diagnostic IDs, +and gives coding agents the exact rules they need to author, convert, repair, +or review EARS requirements. + +The CLI is deterministic. It does not call an LLM, approve changes, create an +`earsyntax` workspace, or take ownership of your specification lifecycle. +Agents and host tools call `earsyntax`; `earsyntax` never calls them. + +> [!WARNING] +> The public packages are alpha. The host-native facade is the intended command +> surface for this branch; run `earsyntax version --features` in a local build to +> see exactly which commands, profiles, agents, hosts, and output formats are +> available. + +## Choose A Workflow + +| Need | Start with | Result | +| ------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| Validate a plain EARS file | `earsyntax validate requirements.ears --profile strict` | Human output and exit code for CI | +| Validate Kiro requirements | `earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro` | Findings against Kiro acceptance criteria | +| Convert natural-language prose | `earsyntax instructions convert --file requirements.ears --from feature.md --profile strict --json` | Agent rules for writing structured EARS | +| Repair validation findings | `earsyntax instructions repair --file requirements.ears --profile strict --json` | Per-diagnostic repair rules and next command | +| Inspect host extraction | `earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json` | Candidate lines with locator rule IDs | +| Install agent and host wrappers | `earsyntax init --agent claude --host kiro` | Managed wrapper and hook files, no spec edits | +| Discover capabilities | `earsyntax version --features` | Machine-readable facade capabilities | + +## Install + +Node.js 22 or newer is required. + +Run without a global install: + +```bash +npx @earsyntax/cli validate requirements.ears --profile strict +``` + +Install in a repository: + +```bash +npm install --save-dev @earsyntax/cli +``` + +Use the local binary from package scripts or through your package manager: + +```bash +npx @earsyntax/cli version --features +``` + +## Validate Requirements + +Plain EARS files use the `strict` profile. Each non-empty line is treated as one +requirement. + +```bash +printf 'The billing service shall verify the HMAC signature.\n' \ + | npx @earsyntax/cli validate - --profile strict +``` + +```text +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +Host files use profiles that know where requirements live inside the host +document. + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +earsyntax validate "specs/**/spec.md" --profile speckit +earsyntax validate "openspec/specs/**/*.md" "openspec/changes/**/*.md" --profile openspec +``` + +Exit codes: + +| Code | Meaning | +| ---- | ------------------------------------------------------------------------ | +| `0` | The command succeeded, and validation found no error diagnostics. | +| `1` | Validation completed and found at least one error diagnostic. | +| `2` | Usage or environment failure, such as a missing file or unknown profile. | + +Warnings do not make `validate` return `1` unless `--strict` upgrades surviving +warnings to errors. + +## Natural Language To EARS + +The conversion loop is agentic, but the CLI contract is not agent-specific. The +developer supplies a source document and a target requirement file. The agent +asks `earsyntax` for rules, edits the target file, validates, and repeats until +the file is clean. + +Example source: + +```markdown +# Checkout webhooks + +The billing service needs to process payment webhooks. It must verify the HMAC +signature on every webhook. When the payment provider is unavailable, retryable +events should be queued. Invalid signatures must be rejected. +``` + +Start the portable loop: + +```bash +earsyntax instructions convert \ + --file requirements.ears \ + --from feature.md \ + --profile strict \ + --json +``` + +The response tells the agent to: + +1. read `feature.md` as input and leave it unchanged +2. write EARS requirements into `requirements.ears` +3. choose the narrowest EARS pattern that fits each behavior +4. write one obligation per requirement +5. avoid inventing behavior that the source does not state +6. preserve the target file structure +7. run `earsyntax validate requirements.ears --profile strict --json` +8. repair and re-validate until no error diagnostics remain + +The resulting file is ordinary EARS: + +```text +The billing service shall verify the HMAC signature on every payment webhook. +When a payment webhook arrives, the billing service shall process the webhook. +While the payment provider is unavailable, the billing service shall queue retryable events. +If the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +Then validate it: + +```bash +earsyntax validate requirements.ears --profile strict +``` + +```text +4/4 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +If validation fails, the JSON response includes a `next` action such as: + +```json +{ + "command": "earsyntax instructions repair --file requirements.ears --profile strict --json", + "reason": "Get repair rules for the reported diagnostics.", + "forAgent": true +} +``` + +The CLI does not decide what the source means. The coding agent performs the +language work, and the CLI checks whether the result is valid EARS. + +## EARS In One Minute + +EARS is a small set of requirement templates. A requirement names a system and +the response it shall perform, optionally guarded by a state, event, optional +feature, unwanted condition, or a valid combination of those clauses. + +```text +The billing service shall verify the HMAC signature. +When a payment webhook arrives, the billing service shall verify the HMAC signature. +While the payment provider is unavailable, the billing service shall queue retryable events. +Where dunning management is enabled, the billing service shall retry declined charges. +If the HMAC signature is invalid, then the billing service shall reject the webhook. +While the payment provider is unavailable, when a payment webhook arrives, the billing service shall queue retryable events. +``` + +The `strict` profile keeps to canonical EARS. Other profiles are explicit +adapters: every relaxation or extension is named in profile data and covered by +fixtures. + +## Profiles + +Profiles are closed built-ins. Each profile combines EARS dialect rules with the +host-document sections the CLI is allowed to scan. + +| Profile | Use it for | Locator | +| ---------- | -------------------------- | ------------------------------------------------------------- | +| `strict` | Canonical EARS | Every non-empty line in `.ears`, text, or stdin | +| `ears-x` | earsyntax extensions | Same inputs as `strict`, plus frame metadata and prohibitions | +| `kiro` | Kiro requirements | List items under `#### Acceptance Criteria` | +| `speckit` | Spec Kit specs | Requirement sections in `specs/**/spec.md` | +| `openspec` | OpenSpec specs and changes | `### Requirement:` and `#### Scenario:` blocks | + +Render the exact profile behavior: + +```bash +earsyntax profiles +``` + +Use `extract` when a profile does not validate the lines you expected: + +```bash +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +Example candidate: + +```json +{ + "file": ".kiro/specs/checkout/requirements.md", + "line": 18, + "col": 3, + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item", + "text": "When a payment webhook arrives, the billing service shall verify the signature." +} +``` + +## Agent And Host Setup + +`init` installs integration files only. It does not create `.earsyntax/`, create +work items, edit requirement or spec documents, run validation as a side effect, +call an LLM, approve changes, or manage any workspace lifecycle. + +```bash +earsyntax init --agent claude,codex --host kiro +``` + +It may write files like: + +```text +.claude/commands/earsyntax-author.md +.claude/commands/earsyntax-convert.md +.claude/commands/earsyntax-repair.md +.claude/commands/earsyntax-review.md +AGENTS.md +.kiro/steering/earsyntax.md +.kiro/hooks/ears-validate.yaml +``` + +`init` is idempotent. Whole-file integrations are rendered deterministically. +Shared files use managed begin and end markers, so rerunning the same command +should produce no diff. + +Supported agents: + +| Agent | Integration | +| --------- | ------------------------------------------ | +| `claude` | `.claude/commands/earsyntax-*.md` | +| `codex` | Codex-specific managed `AGENTS.md` section | +| `cursor` | `.cursor/rules/earsyntax.mdc` | +| `copilot` | `.github/prompts/earsyntax.prompt.md` | +| `gemini` | Managed `GEMINI.md` section | +| `generic` | Generic managed `AGENTS.md` section | + +Supported hosts: + +| Host | Files rendered | Validation command | +| ---------- | --------------------------------------------------------------- | -------------------------------------------------------------------- | +| `kiro` | `.kiro/steering/earsyntax.md`, `.kiro/hooks/ears-validate.yaml` | `earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro` | +| `speckit` | `.specify/extensions/earsyntax.md` | `earsyntax validate "specs/**/spec.md" --profile speckit` | +| `openspec` | Managed `AGENTS.md` validation section | `earsyntax validate "openspec/specs/**/*.md" --profile openspec` | + +For raw source-to-target conversion with `--from`, call +`earsyntax instructions author` or `earsyntax instructions convert` directly so +the agent can pass both the read-only source and the editable target file. + +## Diagnostics And Output + +Pretty output is optimized for humans: + +```text +requirements.ears:2:1 EARS-E006 error The 'If' clause is missing the required 'then' boundary. +3/4 valid across 1 file(s), 1 error(s), 0 warning(s) +``` + +JSON output is the automation contract: + +```bash +earsyntax validate requirements.ears --profile strict --json +``` + +SARIF is available for code-scanning systems: + +```bash +earsyntax validate requirements.ears --profile strict --sarif > earsyntax.sarif +``` + +Explain any diagnostic by ID: + +```bash +earsyntax explain EARS-E006 +``` + +Diagnostic IDs are stable and namespaced: + +| Prefix | Meaning | +| ----------- | -------------------------------------------------------------- | +| `EARS-E###` | Error diagnostics that can make validation fail | +| `EARS-W###` | Warning diagnostics that keep the requirement valid by default | + +## CI + +Use `validate` as the gate: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +Use JSON or SARIF when another tool consumes the findings: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --json +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif +``` + +## CLI Reference + +The facade has eight commands: + +| Command | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `validate ` | Locate, extract, parse, lint, and report findings. Accepts files, globs, or stdin with `-`. | +| `extract ` | Show requirement candidates found by the active profile. Accepts files, globs, or stdin with `-`. | +| `instructions --file ` | Return deterministic rules for an agent loop step. | +| `explain ` | Explain one diagnostic with examples and repair guidance. | +| `profiles` | List built-in profiles and their exact behavior. | +| `doctor` | Detect host and agent setup and recommend commands. | +| `init --agent --host ` | Install managed host and agent integration files. | +| `version --features` | Print package and facade capabilities. | + +Global options: + +| Option | Meaning | +| ------------------ | ------------------------------------------------------------- | +| `--profile ` | Select `strict`, `ears-x`, `kiro`, `speckit`, or `openspec`. | +| `--json` | Emit the facade JSON envelope. | +| `--sarif` | Emit SARIF from `validate`. Mutually exclusive with `--json`. | +| `--strict` | Treat surviving warnings as validation errors. | +| `--quiet` | Suppress pretty output where supported. | +| `--cwd ` | Resolve paths and globs from another working directory. | + +Instruction modes: + +| Mode | Agent job | +| --------- | -------------------------------------------------------------------------------------- | +| `author` | Write new EARS requirements into an existing host document section or target file. | +| `convert` | Convert natural-language requirement prose into EARS. | +| `repair` | Fix diagnostics returned by `validate`. | +| `review` | Summarize validation status, changed requirements, and open questions without editing. | + +`author` and `convert` may also use `--from ` to point the agent at a +read-only natural-language source file. + +## Library API + +`@earsyntax/core` exposes the deterministic parser, linter, profile data, +diagnostic registry, and findings helpers for TypeScript users. + +```ts +import { + lintEars, + lintEarsBatch, + parseEars, + resolveProfile, + summarizeProfiles, + toFindings, +} from '@earsyntax/core'; + +const result = lintEars( + 'When a payment webhook arrives, the billing service shall verify the signature.', +); + +const profile = resolveProfile('strict'); + +console.log(result.valid); +console.log(result.pattern); +console.log(profile.ok ? profile.profile.name : profile.error.message); +``` + +`@earsyntax/extract` owns host-aware extraction and the validation pipeline. +`@earsyntax/cli-contract` owns the shared findings and SARIF output contracts. + +## Packages + +| Package | Role | +| ------------------------- | ------------------------------------------------------------------------ | +| `@earsyntax/cli` | Public command facade. | +| `@earsyntax/core` | Parser, linter, diagnostics, findings, and profile data. | +| `@earsyntax/extract` | Candidate extraction from `.ears`, text, Markdown, YAML, and JSON files. | +| `@earsyntax/cli-contract` | Shared JSON, findings, exit-code, and SARIF contracts. | + +## Development + +Install dependencies with the repository package manager: + +```bash +pnpm install +``` + +Useful checks: + +```bash +pnpm build +pnpm typecheck +pnpm test +pnpm --filter @earsyntax/cli test +pnpm format:check +``` + +## Status And Limits + +Current boundaries: + +- deterministic parsing, extraction, linting, and reporting only +- no semantic contradiction checking +- no natural-language intent inference in the CLI +- no LLM calls from core, extraction, contracts, or CLI packages +- no `.earsyntax/` workspace lifecycle +- no CLI acceptance command +- no hidden host-specific behavior outside profiles + +Useful docs: + +- [docs/agentic-loop.md](docs/agentic-loop.md) +- [docs/api.md](docs/api.md) +- [docs/cli.md](docs/cli.md) +- [docs/diagnostics.md](docs/diagnostics.md) +- [docs/facade-api.md](docs/facade-api.md) +- [docs/grammar.md](docs/grammar.md) +- [docs/input-formats.md](docs/input-formats.md) + +Design references: + +- [EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md](EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md) +- [GRAMMAR-AGENT-BRIEF-FABLE.md](GRAMMAR-AGENT-BRIEF-FABLE.md) + +The packages declare Apache-2.0 in their package manifests. diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..84dcb12 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +}; diff --git a/docs/agent-rules.md b/docs/agent-rules.md new file mode 100644 index 0000000..d79140c --- /dev/null +++ b/docs/agent-rules.md @@ -0,0 +1,202 @@ +# Agent rules + +This document is the human-readable mirror of the rules the +`earsyntax instructions --file --json` +command returns. The CLI JSON is the source of truth at runtime; this page is the +same content in prose so a person can read it without running the command. The +rules are deterministic data: no clock, no file system, no network, no LLM. The +same mode, profile, and findings always yield the same rule text. + +The rules are agent-agnostic. Per-agent wrappers (a Claude Code command, a Cursor +rule, an `AGENTS.md` section, a Kiro steering file) are thin pointers that call +`earsyntax instructions` and follow whatever it returns, so the rules live in one +place and never fork. + +Every mode edits the host document in place, at the region the active profile's +locator describes. There is no workspace, no `.ears` side file, no traceability +file, and no acceptance step. Human review happens in the host workflow. + +## What each mode tells you to do + +The `instructions` response opens with mode-specific rules. + +### `author` + +Write new EARS requirements into a host file that has none yet, or add to the ones +it has. + +- Write new EARS requirements into the requirements region of the host file that + the locator describes, and nowhere else. +- If that region does not exist yet, create it following the host document + convention; add no prose or headings beyond it. + +### `convert` + +Rewrite natural-language requirements already present in the host file. + +- Rewrite the natural-language requirements already in the host file requirements + region into EARS form, in place. +- Preserve each requirement's original intent; change wording only to reach a + canonical EARS shape. + +### `repair` + +Fix the findings a `validate` run reported. + +- Change only what the reported findings justify; leave passing requirements + untouched. +- Work through the findings by id using the guidance below, then re-run validation + and repeat until no error-severity finding remains. +- Do not delete a failing requirement to make validation pass, and do not weaken a + requirement because it is harder to parse. + +### `review` + +Summarize the located requirements for a human. This mode never changes the file. + +- This review is read-only: describe the state of the located requirements and + make no change to the host file. +- Summarize how many requirements were reviewed, how they distribute across the + EARS patterns, and every finding grouped by severity. +- Report the validation status and what a human must resolve before the + requirements are ready, and leave that decision to the human. + +## Reading a `--from` source + +`author` and `convert` accept `--from `, a natural-language spec the agent +reads as input while writing EARS into the host file. When it is present, three +more rules apply: + +- Read the requirement content from the source file; it is your input to + understand, not something to modify. +- Write the resulting EARS requirements into the host file at the region the + locator describes. +- Leave the source file unchanged. + +The CLI never reads or transforms the source itself. Reading and understanding it +is the agent's job. + +## Choosing a pattern + +The writing modes (`author` and `convert`) share the EARS authoring guidance +below. Start by choosing the narrowest pattern that fits the behavior. + +- Choose the narrowest EARS pattern that fits the behaviour; do not force + everything into When. +- Use While for behaviour active during a state, Where for behaviour gated by an + optional feature, and If ..., then ... for behaviour handling an error or other + unwanted condition. +- Use the ubiquitous form for behaviour that is always active with no trigger or + state. + +| Behavior in the source | Pattern | Template | +| --------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------- | +| Always active, no trigger or state | Ubiquitous | `The shall .` | +| Triggered by an event | Event-driven | `When , the shall .` | +| Active during a state | State-driven | `While , the shall .` | +| Gated by an optional feature | Optional feature | `Where , the shall .` | +| Handling an error or unwanted condition | Unwanted behaviour | `If , then the shall .` | +| Needs more than one leading clause | Complex | order clauses as While, then Where, then When, then If, before `the shall `. | + +## One obligation per requirement + +- Write one requirement per statement, each with exactly one shall stating a + single obligation. +- When a statement carries several obligations, split it into separate + requirements; do not split a phrase that only qualifies the response. + +For example, "validate the signature, persist the event, and enqueue a job" +becomes three requirements, but "reject the webhook with a 400 status" stays one, +because the status only qualifies the single obligation. + +## Do not invent behavior + +- Write only behaviour the source states; do not add logging, retries, rate + limits, persistence, or permissions it does not require. +- When behaviour is missing, vague, or conflicting, leave it out and flag it for a + human rather than guessing a precise requirement. + +If missing behavior matters, note the gap for a human. Do not fill it with a +plausible-sounding requirement the source never stated. + +## Edit policy + +The writing modes close with one rule, and every mode carries an `editPolicy` +naming the single editable file: + +- Edit only the host file, in place, and preserve the surrounding document + structure. + +The `editPolicy` object is `{ editableFile, preserveStructure: true }`, and +`outputPolicy` is always `"edit-in-place"`. When `--from` is set, the source file +is explicitly not editable. + +## Diagnostic-to-fix guidance + +In `repair` mode the response appends one fix rule per reported diagnostic id, in +first-seen order. The full mapping the CLI draws from is below; only the ids +present in a run are emitted. Each finding may also carry its own `fix` string. + +### Structural errors (`EARS-E###`) + +| Id | Fix | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| `EARS-E001` | Use the specific canonical system name so it matches exactly one catalog entry. | +| `EARS-E002` | Use the catalog canonical system name, or confirm the system with the catalog owner. | +| `EARS-E003` | Fill the empty leading clause body, or remove the clause if it was accidental. | +| `EARS-E004` | Add the response after shall, or flag a question if the source states none. | +| `EARS-E005` | Reorder the leading clauses to While, then Where, then When, then If, before the system shall response. | +| `EARS-E006` | Add the missing then: `If , then the shall .` | +| `EARS-E007` | Add a single shall response boundary stating one obligation. | +| `EARS-E008` | Insert the system name before shall: `the shall .` | +| `EARS-E009` | Split into separate requirements, one shall each. | +| `EARS-E010` | Rewrite the line into a canonical EARS template, or move it out of the requirements region. | +| `EARS-E011` | Remove the empty group or supply the missing operand in the clause expression. | +| `EARS-E012` | Fix the malformed operator run (for example a trailing and or a leading or). | +| `EARS-E013` | Balance the parentheses in the clause expression. | +| `EARS-E014` | Match the EARS keyword casing the profile requires. | +| `EARS-E015` | Add the comma after the leading clause: `When , the shall .` | +| `EARS-E016` | Restate the prohibition as a positive obligation, or use a profile that allows shall not. | + +### Warnings (`EARS-W###`) + +| Id | Fix | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `EARS-W001` | Use the specific canonical event name. | +| `EARS-W002` | Use the canonical event name, or add the event to the catalog if it is correct. | +| `EARS-W003` | Use the specific canonical feature name. | +| `EARS-W004` | Use the canonical feature name, or add the feature to the catalog if it is correct. | +| `EARS-W005` | Use the specific canonical state name. | +| `EARS-W006` | Use the canonical state name, or add the state to the catalog if it is correct. | +| `EARS-W007` | Add a requirement that uses the cataloged term if one is missing, or note the gap; do not invent behaviour to satisfy coverage. | +| `EARS-W008` | Disambiguate the term so it matches one catalog entry, or use the canonical name. | +| `EARS-W009` | Align the unresolved term in the clause with the catalog. | +| `EARS-W010` | Add parentheses to the mixed and/or expression to make grouping explicit. | +| `EARS-W011` | Align the clause term with a catalog entry, or add the term to the catalog if it is correct. | +| `EARS-W012` | Prefer the canonical catalog name over the matched alias. | +| `EARS-W013` | Split the semicolon-joined responses into separate requirements. | +| `EARS-W014` | Rewrite the sentence into a clean EARS template. | +| `EARS-W015` | Move the trailing text into the requirement or remove it. | +| `EARS-W016` | Replace the vague term with an observable, bounded response, or flag a question if the bound is unknown. | + +Every id resolves in `earsyntax explain `, which gives the meaning, rationale, +and a corrected example. The full registry, including severity by profile, is in +[the diagnostics reference](diagnostics.md). + +## The dialect the rules assume + +Each `instructions` response also carries the active profile's `dialect`: the +grammar tolerances the agent may rely on. For the `kiro` profile, for example, +keyword case is case-insensitive, a comma after a leading clause is optional, the +literal `THE SYSTEM` is an allowed system name, and user-story wrappers are +allowed. Write to the dialect the response reports, not to a fixed assumption; a +stricter profile allows less. See [profiles](input-formats.md#profiles) for what +each profile relaxes. + +## No approval, ever + +The rules never tell an agent to approve, accept, or merge, and never reference a +workspace, work item, or manifest. `review` mode produces a summary and reports +what a human must resolve; it leaves the decision to the human. Acceptance lives +in the host workflow: pull request review, Kiro review, Spec Kit review, or +OpenSpec change review. diff --git a/docs/agentic-loop.md b/docs/agentic-loop.md new file mode 100644 index 0000000..2e1f32a --- /dev/null +++ b/docs/agentic-loop.md @@ -0,0 +1,179 @@ +# Agentic loop + +This document describes how any coding agent uses the `earsyntax` facade to write, +convert, and repair EARS requirements inside host documents. The loop is +tool-agnostic: it works with Claude Code, Cursor, Codex, Kiro, or a plain shell +script, because the durable contract is the host files plus the CLI's JSON, not +any one agent's command names. There is no `earsyntax` workspace, no work item, +and no acceptance record; the agent edits the host's own requirement files in +place. + +Three parties have separate responsibilities, and the loop keeps them separate. + +| Party | Responsibility | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CLI facade | Locate requirements in host files, return instructions, validate deterministically, report findings and next actions. Never edits, never calls an LLM. | +| Coding agent | Read the source, decide which behaviors are requirements, write and repair EARS in the host file, and flag ambiguity for a human. | +| Human | Review the resulting requirements in the host workflow (pull request, Kiro, Spec Kit, or OpenSpec review) and decide whether they are ready. | + +The CLI never interprets a source spec semantically. The agent never approves, +accepts, or merges its own work. Validation success and human approval are two +different events, and the CLI produces only the first. + +## The loop at a glance + +```text +earsyntax instructions --file -> get the rules for this step + (agent edits the host file in place) +earsyntax validate -> check the file, deterministically + (repeat instructions repair + validate until no error-severity finding remains) +``` + +Every JSON response ends with a `next` array, so an agent walks the loop by +following `next[].command` without hard-coding the sequence. Actions marked +`forAgent: true` are safe for the agent to run on its own. The response never +tells the agent to approve, accept, or merge, and never references a workspace. + +## The four modes + +Each `instructions` mode covers one kind of step against a host file. Pick the +mode that matches the task. + +| Mode | When to use it | Reads findings | +| --------- | -------------------------------------------------------------------- | -------------- | +| `author` | Write new EARS requirements into a host file's requirements region. | no | +| `convert` | Rewrite natural-language requirements already in the file into EARS. | no | +| `repair` | Fix the findings a `validate` run reported. | yes | +| `review` | Summarize the located requirements for a human. Never approves. | yes | + +`author` and `convert` also accept `--from `, naming a natural-language +spec the agent reads as input while writing EARS into `--file`. The CLI points at +the source; it never reads or transforms it. See +[the CLI reference](cli.md#--from-source). + +## The repair loop, step by step + +The most common loop is repair: something failed validation, and the agent fixes +it. The example uses a Kiro `requirements.md`, but the shape holds for any +profile. + +### 1. Validate and read the findings + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +```json +{ + "command": "validate", + "ok": false, + "findings": { + "ok": false, + "summary": { "files": 1, "requirements": 3, "valid": 2, "errors": 2, "warnings": 0 }, + "diagnostics": [ + { + "id": "EARS-E006", + "severity": "error", + "file": ".kiro/specs/checkout/requirements.md", + "line": 10, + "col": 4, + "message": "The 'If' clause is missing the required 'then' boundary." + } + ] + }, + "next": [ + { + "command": "earsyntax instructions repair --file .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Get repair rules for the reported diagnostics.", + "forAgent": true + } + ] +} +``` + +The `next` action tells the agent exactly which command to run. + +### 2. Get the repair rules + +```bash +earsyntax instructions repair --file ".kiro/specs/checkout/requirements.md" --profile kiro --json +``` + +The response embeds the same findings plus a `rules` array with one entry per +reported id, drawn from the diagnostic-to-fix guidance in +[agent-rules.md](agent-rules.md): + +```json +{ + "mode": "repair", + "rules": [ + "Change only what the reported findings justify; leave passing requirements untouched.", + "EARS-E006: Add the missing then: If , then the shall .", + "Edit only the host file, in place, and preserve the surrounding document structure." + ], + "editPolicy": { + "editableFile": ".kiro/specs/checkout/requirements.md", + "preserveStructure": true + }, + "outputPolicy": "edit-in-place" +} +``` + +### 3. Edit the host file in place + +The agent changes only what the findings justify, in the file `editPolicy` +names, preserving the surrounding document structure. It does not delete a failing +requirement to make validation pass, and does not weaken a requirement because it +is harder to parse. + +### 4. Re-validate until clean + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +```text +3/3 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +Exit code `0`. The loop repeats step 1 through step 4 until `validate` reports no +error-severity finding. Warnings do not block a clean exit, but a `review` pass +surfaces them so a human can decide whether they matter. + +## Authoring and converting + +`author` and `convert` follow the same shape without a preceding findings run: get +the rules, edit the host file, then validate. In `author` mode the agent writes +new EARS requirements into the requirements region the profile's locator +describes. In `convert` mode it rewrites natural-language requirements already in +the file into EARS in place, preserving each requirement's intent. + +```bash +# Write new requirements, reading a PRD as input. +earsyntax instructions author --file ".kiro/specs/checkout/requirements.md" --from prd.md --profile kiro --json +# agent reads prd.md, writes EARS into requirements.md, leaves prd.md unchanged +earsyntax validate ".kiro/specs/checkout/requirements.md" --profile kiro +``` + +Both modes carry the shared authoring rules: choose the narrowest EARS pattern, +write one obligation per requirement, split compounds, and do not invent behavior +the source does not state. Those rules are documented in full in +[agent-rules.md](agent-rules.md). + +## What the loop does not do + +- It does not maintain a workspace, work item, manifest, or acceptance record. +- It does not track source staleness or hash sources; the host's own version + control does that. +- It does not accept, approve, or merge. The `review` mode produces a summary for + a human and stops there. +- It does not call an LLM from the CLI. The agent calls `earsyntax`, never the + reverse. + +## Convergence + +The repair sub-loop (`instructions repair`, then `validate`) repeats until +`validate` reports no error-severity finding. The agent must not force convergence +by deleting failing requirements or weakening wording; when the intended behavior +is unclear, it leaves the requirement out and flags the gap for a human rather +than guessing. The rules for this are in [agent-rules.md](agent-rules.md). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..ef26ec0 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,309 @@ +# API reference + +`@earsyntax/core` is the deterministic parser and linter at the center of the toolkit. This page documents its four public functions, the option and catalog shapes they take, and the guarantees they hold to. It also covers one example each for `@earsyntax/extract` and `@earsyntax/cli-contract`. Every example below was run against the built packages, and the JSON is the captured output. + +For the exact type definitions, see [`packages/core/src/types.ts`](../packages/core/src/types.ts) and the [public API reference](public-api.md). To write EARS by hand, see the [authoring guide](authoring-ears.md). + +```ts +import { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from '@earsyntax/core'; +``` + +## Determinism guarantees + +The core is deterministic by contract. For a given input it always returns the same result. + +- No LLM calls, no network access, no file system access. +- No fuzzy or semantic matching. Catalog matching is exact canonical name, then exact alias. +- Diagnostics are stably sorted by span start, then span end, then code, then message, then severity. +- `lintEarsBatch` preserves input order: result `i` corresponds to input `i`, and echoes its `id`. +- `valid` is derived only from severity: `false` when any diagnostic is an `error`, otherwise `true`. + +## `lintEars` + +```ts +function lintEars(text: string, catalog?: Catalog, options?: Options): LintResult; +``` + +Lints a single requirement and returns a complete `LintResult`: `valid`, the classified `pattern`, the parsed `ast`, catalog `references`, and stably sorted `diagnostics`. + +### Without a catalog + +```ts +lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', +); +``` + +```json +{ + "valid": true, + "references": [ + { + "clause": "trigger", + "text": "a payment webhook is received", + "role": "event", + "span": { "start": 5, "end": 34 } + }, + { "clause": "system", "text": "billing service", "role": "system" } + ], + "diagnostics": [], + "pattern": "event-driven", + "ast": { + "pattern": "event-driven", + "system": { "raw": "billing service", "role": "system" }, + "responses": ["verify the HMAC signature"], + "raw": "When a payment webhook is received, the billing service shall verify the HMAC signature.", + "trigger": { + "kind": "term", + "text": "a payment webhook is received", + "term": { "raw": "a payment webhook is received", "role": "event" }, + "span": { "start": 5, "end": 34 } + } + } +} +``` + +Without a catalog, each `references` entry and each `term` carries the raw text and role, but no `matched` pointer: with nothing to match against, terms are neither resolved nor unresolved. + +### With a catalog + +```ts +const catalog = { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-WEBHOOK', name: 'a payment webhook is received' }], +}; + +lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + catalog, + { mode: 'strict' }, +); +``` + +With the catalog supplied, matched terms carry a `matched` pointer to the catalog entry. The system reference becomes: + +```json +{ + "clause": "system", + "text": "billing service", + "role": "system", + "matched": { "group": "systems", "id": "SYS-BILLING", "name": "billing service" } +} +``` + +and the trigger's `term` gains `"matched": { "group": "events", "id": "EVT-WEBHOOK", "name": "a payment webhook is received" }`. The requirement stays `valid: true` with no diagnostics. + +## `lintEarsBatch` + +```ts +function lintEarsBatch( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): LintResult[]; +``` + +Lints many requirements at once. Returns one `LintResult` per input item, in the same order, each echoing the input `id`. + +```ts +lintEarsBatch([ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + }, +]); +``` + +The results preserve order and id: + +```json +[ + { "id": "REQ-001", "valid": true, "pattern": "event-driven" }, + { "id": "REQ-002", "valid": true, "pattern": "unwanted-behaviour" } +] +``` + +Each full result also carries `references`, `diagnostics`, and `ast`, shown trimmed here. + +## `parseEars` + +```ts +function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult; +``` + +Parses a requirement without full linting. Returns the classified `pattern`, the parsed `ast`, and structural `diagnostics`. It does not return catalog `references` or lint-level findings, so it is the lighter surface when you only need the shape. + +```ts +parseEars( + 'While the payment provider is unavailable, the billing service shall queue retryable events.', +); +``` + +```json +{ + "diagnostics": [], + "pattern": "state-driven", + "ast": { + "pattern": "state-driven", + "system": { "raw": "billing service", "role": "system" }, + "responses": ["queue retryable events"], + "raw": "While the payment provider is unavailable, the billing service shall queue retryable events.", + "preconditions": { + "kind": "term", + "text": "the payment provider is unavailable", + "term": { "raw": "the payment provider is unavailable", "role": "state" }, + "span": { "start": 6, "end": 41 } + } + } +} +``` + +The `While` clause body fills `ast.preconditions`. `When` fills `trigger`, `Where` fills `feature`, and `If` fills `unwanted`. + +## `lintCatalogCoverage` + +```ts +function lintCatalogCoverage( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): Diagnostic[]; +``` + +Reports catalog entries that no requirement text references. Emits `catalog.term_unreferenced` warnings, and only when `mode` is `strict`. Returns a stably sorted list of diagnostics: empty in guided mode, or when every entry is referenced. + +```ts +lintCatalogCoverage( + [ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + }, + ], + { + systems: [ + { id: 'SYS-BILLING', name: 'billing service' }, + { id: 'SYS-LEDGER', name: 'ledger service' }, + ], + }, + { mode: 'strict' }, +); +``` + +The ledger service is never mentioned, so the result contains one diagnostic: + +```json +[ + { + "code": "catalog.term_unreferenced", + "severity": "warning", + "message": "catalog systems term \"ledger service\" (SYS-LEDGER) is not referenced by any requirement text" + } +] +``` + +## Options + +```ts +interface Options { + mode?: Mode; // 'strict' | 'guided'; default 'strict' + commaAsAnd?: boolean; // default false + vagueTerms?: string[]; // default ['appropriate', 'sufficient', 'as needed'] +} +``` + +### strict vs guided + +`mode` defaults to `strict`. In strict mode, structural shell and expression failures and an unresolved or ambiguous system are errors, so they set `valid: false`. In guided mode those same defects are downgraded to warnings where a partial AST can still be recovered, so the requirement stays `valid: true`. Every other code, the non-system catalog terms and all `lint.*` codes, is a warning in both modes. The exact mapping is in the [diagnostics reference](diagnostics.md). + +`commaAsAnd` reads unambiguous commas inside a clause body as `and`; by default a comma ends a clause. `vagueTerms` replaces the default list that triggers `lint.vague_response` in a response. + +## Catalog + +```ts +interface Catalog { + systems?: CatalogEntry[]; + actors?: CatalogEntry[]; + events?: CatalogEntry[]; + states?: CatalogEntry[]; + features?: CatalogEntry[]; + modes?: CatalogEntry[]; + conditions?: CatalogEntry[]; + dataTerms?: CatalogEntry[]; +} + +interface CatalogEntry { + id: string; + name: string; + aliases?: string[]; +} +``` + +Every group is optional. Matching is deterministic: exact canonical name, then exact alias, then ambiguous when more than one entry matches, then unresolved when none does. An absent or empty catalog means matching is skipped, and terms carry no `matched` pointer. + +## `@earsyntax/extract` + +`@earsyntax/extract` turns files people write into the `RequirementInput` shape core lints. It supports `.ears`, Markdown, YAML, and JSON, and never lints or parses EARS grammar itself. See the [input formats guide](input-formats.md) for every format. + +The pipeline is profile-driven: a profile controls which regions of a host document become candidates. Two entry points cover it: + +```ts +import { extractCandidates, runPipeline } from '@earsyntax/extract'; +import { BUILTIN_PROFILES } from '@earsyntax/core'; + +// Stage 1-2 only: locate candidates (what `earsyntax extract` prints). +const { candidates, notices } = extractCandidates({ + files: [{ path: '.kiro/specs/checkout/requirements.md', content }], + profile: BUILTIN_PROFILES.kiro, +}); + +// The whole pipeline: locate, extract, parse, lint, assemble findings. +const { findings } = runPipeline({ + files: [{ path: 'requirements.ears', content, kind: 'ears' }], + profile: BUILTIN_PROFILES.strict, + strict: false, +}); +``` + +`inferKind(path)` returns the `DocumentKind` a bare path maps to. The pipeline, profiles, and candidate shapes are documented end to end in the [input formats guide](input-formats.md) and enumerated in the [public API reference](public-api.md). + +## `@earsyntax/cli-contract` + +`@earsyntax/cli-contract` holds the report serializers so external tools can render +linting results the way the CLI does, without depending on the CLI binary. It is +pure data and serializers: no I/O, no argument parsing. It works from a `Findings` +object (from `@earsyntax/core` or `@earsyntax/extract`) to produce the JSON report, +SARIF, and the exit code. + +### Findings, SARIF, and the exit code + +The findings-based serializers take a `Findings` object. Get one from +`runPipeline` (`@earsyntax/extract`) or `toFindings`/`candidatesToFindings` +(`@earsyntax/core`): + +```ts +import { BUILTIN_PROFILES } from '@earsyntax/core'; +import { runPipeline } from '@earsyntax/extract'; +import { buildSarifLog, exitCodeForFindings, serializeFindings } from '@earsyntax/cli-contract'; + +const { findings } = runPipeline({ + files: [{ path: 'requirements.ears', content, kind: 'ears' }], + profile: BUILTIN_PROFILES.strict, + strict: false, +}); + +exitCodeForFindings(findings); // 1, because the file has an error-severity finding +const sarif = buildSarifLog(findings); // SARIF 2.1.0 log; each id becomes a rule +serializeFindings(findings); // byte-stable JSON string of the canonical findings +``` + +For the `If`-without-`then` line above, `findings.diagnostics[0]` is +`{ id: 'EARS-E006', severity: 'error', file: 'requirements.ears', line: 1, col: 1, message: "The 'If' clause is missing the required 'then' boundary." }`, +and the SARIF log carries a matching `EARS-E006` rule and result. The package also +exports `serializeSarifLog`, `canonicalizeFindings`, and the `EXIT_OK`/`EXIT_LINT_ERRORS`/`EXIT_USAGE` +constants. This is the same projection the CLI uses; `earsyntax validate --sarif` and `--json` are +thin wrappers over these serializers. diff --git a/docs/authoring-ears.md b/docs/authoring-ears.md new file mode 100644 index 0000000..31c95da --- /dev/null +++ b/docs/authoring-ears.md @@ -0,0 +1,252 @@ +# Authoring EARS by hand + +This page is a practical guide to writing EARS requirements that pass +`earsyntax validate`. It covers the six patterns, the clause-order rule, boolean +expressions inside clauses, vague terms, and the diagnostics you are most likely +to hit. Every example uses one domain, a billing service that processes payment +webhooks, and every CLI example below was validated with the built CLI. + +The examples validate `.ears` files under the default `strict` profile, which +reads every non-empty line as a candidate. If you have not run the linter yet, +start with the [quickstart](quickstart.md). For the machine-readable API, see the +[API reference](api.md). + +## The shape of a requirement + +Every EARS requirement ends the same way: a system and one observable response +joined by `shall`. + +```text +The shall . +``` + +Optional clauses go in front of that tail to say when the behavior applies. There +are four clause keywords, each mapping to one pattern. A requirement with no +clause is the fifth pattern, and a requirement with more than one clause is the +sixth. Under `strict`, keywords are matched in their canonical capitalized form; +host profiles such as `kiro` relax keyword case. The examples here use the +conventional capitalized form. + +## The six patterns + +### Ubiquitous: always-true behavior + +Use `The shall ` for behavior that always holds, with no guard. + +```text +The billing service shall verify the HMAC signature of every incoming webhook. +The billing service shall retain payment receipts for seven years. +``` + +### State-driven: behavior during a state + +Use `While , ...` when the behavior applies only while a condition holds. + +```text +While the payment provider is unavailable, the billing service shall queue retryable events. +While the account is in dunning, the billing service shall suppress new charge attempts. +``` + +### Event-driven: behavior triggered by an event + +Use `When , ...` for behavior that fires in response to an event. + +```text +When a payment webhook is received, the billing service shall verify the HMAC signature. +When a refund is requested, the billing service shall issue a refund to the original payment method. +``` + +### Optional feature: behavior gated by a feature + +Use `Where , ...` when the behavior exists only if a feature is present +or enabled. + +```text +Where dunning management is enabled, the billing service shall retry declined charges. +Where multi-currency support is enabled, the billing service shall convert amounts to the account currency. +``` + +### Unwanted behaviour: handling an error or exceptional condition + +Use `If , then ...` for errors, invalid input, threats, and other +unwanted conditions. The `then` is required: an `If` clause without it is an error +(`EARS-E006`). + +```text +If the HMAC signature is invalid, then the billing service shall reject the webhook. +If the payment is declined, then the billing service shall notify the account owner. +``` + +### Complex: more than one clause + +Use more than one clause when the behavior needs several guards. A requirement +with two or more clauses classifies as `complex`. + +```text +While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. +When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +The five clean requirements above (one per pattern) validate cleanly: + +```bash +earsyntax validate requirements.ears --profile strict +``` + +```text +5/5 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +## Clause order + +When a requirement uses more than one clause, the clauses must appear in this +order: + +```text +While -> Where -> When -> If -> the shall +``` + +A clause out of order is a structural error (`EARS-E005`). `When ... if ... then +...` is valid, because `When` precedes `If`. `When ... while ...` is not, because +`While` must come before `When`: + +```text +When a payment webhook is received, while the account is active, the billing service shall process the payment. +``` + +```text +requirements.ears:1:1 EARS-E005 error The shell clauses appear in an unsupported order. +0/1 valid across 1 file(s), 1 error(s), 0 warning(s) +``` + +The article `the` on the system is stripped: `the billing service shall ...` +yields the system `billing service`. + +## Boolean expressions in clauses + +The body of a `While`, `Where`, `When`, or `If` clause can be a boolean expression +over free-text terms, using `and`, `or`, `not`, and parentheses. + +```text +When a payment webhook is received or a refund is requested, the billing service shall write an audit log entry. +When a payment webhook is received and the account is active, the billing service shall process the payment. +If the signature is missing or the timestamp is stale, then the billing service shall discard the event. +Where (dunning management is enabled or manual retries are enabled) and the account is active, the billing service shall retry declined charges. +``` + +Precedence runs `not` tightest, then `and`, then `or`, so `a and b or c` groups as +`(a and b) or c`. Parentheses override precedence. When you mix `and` and `or` at +the same level without parentheses, the requirement still parses and stays valid, +but the linter warns (`EARS-W010`) so you can make the grouping explicit: + +```text +When a payment webhook is received and the account is active or a refund is requested, the billing service shall write an audit log entry. +``` + +```text +requirements.ears:1:1 EARS-W010 warning The clause expression mixes 'and' and 'or' without grouping; add parentheses to make precedence explicit. +1/1 valid across 1 file(s), 0 error(s), 1 warning(s) +``` + +Unbalanced parentheses (`EARS-E013`), doubled operators such as `or or` +(`EARS-E012`), and empty groups (`EARS-E011`) are errors. By default a comma ends +a clause; the library `commaAsAnd` option reads unambiguous commas inside a clause +body as `and`, but the explicit `and` keyword works either way. See the +[API reference](api.md#options) for the library options. + +## Prohibitions + +Canonical EARS states positive obligations. A `shall not` prohibition is an error +under `strict` (`EARS-E016`): + +```text +The billing service shall not store raw card numbers. +``` + +```text +requirements.ears:1:1 EARS-E016 error The 'shall not' prohibition form is not allowed by this dialect. +0/1 valid across 1 file(s), 1 error(s), 0 warning(s) +``` + +The `ears-x` profile legalizes prohibitions through its `allowProhibition` +dialect, so the same line validates under `--profile ears-x`. Restate the +prohibition as a positive obligation, or switch to a profile that allows it. + +## Catalog matching + +A catalog is an optional list of the domain terms you consider canonical: systems, +events, states, features, and more. Supplying one lets the linter check the system +and clause terms against it, exact match first, then alias, then ambiguous or +unresolved. There is no fuzzy or semantic matching. Catalog matching is a library +capability of `@earsyntax/core`, not a facade flag; the CLI validates against the +active profile's dialect and does not take a catalog. To lint with a catalog, call +`lintEars` or `lintEarsBatch` directly. See the +[API reference](api.md#catalog) for the catalog shape and matching rules. + +## Vague terms + +A response that hedges instead of stating an observable obligation gets a warning +(`EARS-W016`). The default vague terms are `appropriate`, `sufficient`, and +`as needed`: + +```text +The billing service shall retry failed charges as needed. +``` + +```text +requirements.ears:1:1 EARS-W016 warning The response contains the vague term "as needed". +1/1 valid across 1 file(s), 0 error(s), 1 warning(s) +``` + +Vague terms do not fail validation. They flag a requirement a reader cannot turn +into a test. Replace the vague term with a bounded, observable response, or raise +the gap for a human if you do not yet know the bound. The library `vagueTerms` +option replaces the default list. + +## Common diagnostics and fixes + +These are the diagnostics you meet most often while authoring. In `strict` the +structural codes are errors; the style codes are warnings. The +[diagnostics reference](diagnostics.md) documents every code and its severity, +and `earsyntax explain ` gives the rationale and a corrected example for one. + +| Id | What it means | Fix | +| ----------- | ---------------------------------------------- | ------------------------------------------------------------------- | +| `EARS-E007` | No single `shall` response boundary. | State one obligation with `shall`: `the billing service shall ...`. | +| `EARS-E008` | No system name before `shall`. | Name the system: `... the billing service shall ...`. | +| `EARS-E006` | An `If` clause has no `then`. | Add `then`: `If , then the shall .` | +| `EARS-E009` | More than one `shall` in the sentence. | Split into separate requirements, one obligation each. | +| `EARS-E005` | Clauses are out of order. | Reorder to `While -> Where -> When -> If`. | +| `EARS-W013` | Semicolon-joined responses in one requirement. | Split into separate requirements. | +| `EARS-W016` | The response contains a vague term. | Replace it with an observable, bounded response. | +| `EARS-W010` | Mixed `and`/`or` without parentheses. | Add parentheses to make the grouping explicit. | + +The four lines below each fail with a different structural error: + +```text +The billing service verifies the HMAC signature. +When a payment webhook is received, shall verify the HMAC signature. +If the HMAC signature is invalid, the billing service shall reject the webhook. +The billing service shall queue events and shall log the event. +``` + +```text +requirements.ears:1:1 EARS-E007 error The requirement does not contain exactly one 'shall' response boundary. +requirements.ears:2:1 EARS-E008 error The requirement is missing the system name before 'shall'. +requirements.ears:3:1 EARS-E006 error The 'If' clause is missing the required 'then' boundary. +requirements.ears:4:1 EARS-E009 error The requirement contains more than one shell-level 'shall'. +0/4 valid across 1 file(s), 4 error(s), 0 warning(s) +``` + +Line 1 states the behavior in the present tense with no `shall`; add the `shall` +boundary. Line 2 dropped the system; name it. Line 3 is missing `then`; add it. +Line 4 packs two obligations into one sentence; split them into two requirements. + +## Next steps + +- [Diagnostics reference](diagnostics.md): every code, its severity, and an + example trigger. +- [Grammar matrix](grammar.md): the exact rules the parser implements, with + fixtures. +- [API reference](api.md): lint the same requirements from TypeScript, with a + catalog and options. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..f3ee376 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,641 @@ +# CLI reference + +`earsyntax` is the command-line facade for humans, scripts, and coding agents. It +locates EARS-shaped requirements inside host documents (Kiro, Spec Kit, OpenSpec, +plain `.ears`/text), parses and lints them deterministically, and reports stable +diagnostics as pretty text, JSON, or SARIF. It never derives requirements from +prose and never calls an LLM; that reasoning is the coding agent's job. + +Run it through the package binary, no global install required: + +```bash +npx @earsyntax/cli [options] +``` + +The surface is eight commands, and it is closed: + +| Command | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------------------ | +| `validate ` | Locate, extract, parse, lint, and report findings. The only command that returns exit `1`. | +| `extract ` | Print the requirement candidates the active profile locates, with positions. | +| `instructions --file ` | Return the deterministic rules an agent follows for one loop step. | +| `explain ` | Explain one diagnostic id, with rationale and examples. | +| `profiles` | List the built-in profiles and exactly what each one does. | +| `doctor` | Detect hosts and agents in the repo and recommend commands. | +| `init --agent --host ` | Render managed agent-wrapper and host-integration files. | +| `version --features` | Report the installed version and the capability map. | + +## Global options + +| Flag | Applies to | Meaning | +| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `--profile ` | validate, extract, instructions | Select a built-in profile: `strict` (default), `ears-x`, `kiro`, `speckit`, `openspec`. | +| `--json` | all | Emit the JSON envelope instead of pretty text. | +| `--sarif` | validate only | Emit SARIF 2.1.0. Rejected on any other command with exit `2`. | +| `--strict` | validate | Upgrade warnings to errors at the findings layer. | +| `--quiet` | all | Suppress non-essential pretty output; JSON is unaffected. | +| `--cwd ` | all | Directory used to resolve relative paths and detect the repo root. Defaults to the process working directory. | + +`--profile` defaults to `strict`. `--json` and `--sarif` are mutually exclusive: +passing both is a usage error (exit `2`). There is no `--work`, `--source`, +`--out`, `--catalog`, `--mode`, or `--comma-as-and`; those were workspace or +library flags and are not part of the facade. + +## Exit codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | Success, or a validate run with no error-severity finding. | +| `1` | A validate run produced at least one error-severity finding. Only `validate` returns `1`. | +| `2` | Usage or environment failure: bad flag, unknown profile, unknown diagnostic id, missing or unreadable path, `--sarif` on a non-validate command, `--from` on a non-author/convert mode. | + +Scripts branch on these: `0` means the requirements are clean, `1` means they +failed validation, `2` means the command was called wrong or the environment is +broken. + +## The JSON envelope + +Every `--json` response is an envelope with base fields, then command-specific +keys, then `next`: + +```ts +interface Envelope { + version: string; // installed @earsyntax/cli version + command: string; // "validate", "instructions repair", ... + ok: boolean; // no error-severity finding and no usage error + root?: string; // absolute repo root, when the command detects one + // ...command-specific keys... + diagnostics?: FacadeDiagnostic[]; // usage and environment notices, never lint findings + next: NextAction[]; // always present; may be empty +} + +interface FacadeDiagnostic { + code: string; // "cli.unknown_profile", "cli.flag_not_allowed", ... + severity: 'error' | 'warning'; + message: string; + path?: string; + line?: number; +} + +interface NextAction { + command: string; // a runnable earsyntax command + reason: string; + forAgent?: boolean; // true when an agent can run it without a human +} +``` + +`diagnostics` in the envelope carries usage and environment problems only. Lint +results live in a command's `findings` key and never appear in `diagnostics`. +The `findings` object is the frozen Findings model documented in +[`docs/contracts/findings.md`](contracts/findings.md). + +## `validate ` + +Locate, extract, parse, and lint EARS in the given files (or stdin `-`) under the +active profile. Exit `0` when there are no error-severity findings, else `1`. + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +Pretty output points at each finding with `path:line:col id severity message`, +then a summary line: + +```text +.kiro/specs/checkout/requirements.md:10:4 EARS-E006 error The 'If' clause is missing the required 'then' boundary. +.kiro/specs/checkout/requirements.md:10:4 EARS-E008 error The requirement is missing the system name before 'shall'. +2/3 valid across 1 file(s), 2 error(s), 0 warning(s) +``` + +The same run with `--json`: + +```json +{ + "version": "0.0.1-alpha.0", + "command": "validate", + "ok": false, + "findings": { + "ok": false, + "summary": { "files": 1, "requirements": 3, "valid": 2, "errors": 2, "warnings": 0 }, + "diagnostics": [ + { + "id": "EARS-E006", + "severity": "error", + "file": ".kiro/specs/checkout/requirements.md", + "line": 10, + "col": 4, + "message": "The 'If' clause is missing the required 'then' boundary." + }, + { + "id": "EARS-E008", + "severity": "error", + "file": ".kiro/specs/checkout/requirements.md", + "line": 10, + "col": 4, + "message": "The requirement is missing the system name before 'shall'." + } + ] + }, + "next": [ + { + "command": "earsyntax instructions repair --file .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Get repair rules for the reported diagnostics.", + "forAgent": true + } + ] +} +``` + +Read from stdin with `-`. Stdin is treated as plain text (every non-empty line is +a candidate), so it validates under any profile: + +```bash +printf 'When a payment webhook arrives, the billing service shall verify the signature.\n' \ + | earsyntax validate - --profile strict +``` + +```text +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +`--strict` upgrades every warning to an error at the findings layer, so a file +with only warnings exits `1`. `--sarif` replaces stdout with a SARIF 2.1.0 log +(see below); the exit code stays findings-driven. + +### Profiles and Markdown blindness + +`strict` and `ears-x` locate over `.ears` and plain-text files only: they read +every non-empty line. They do not know Markdown structure, so validating a `.md` +file under `strict` finds zero candidates and reports a clean run: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile strict --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "validate", + "ok": true, + "findings": { + "ok": true, + "summary": { "files": 1, "requirements": 0, "valid": 0, "errors": 0, "warnings": 0 }, + "diagnostics": [] + }, + "next": [] +} +``` + +This is intended behavior, not a bug: EARS requirements in Markdown live inside +host structure (acceptance-criteria lists, requirement sections), and locating +them is what the host profiles (`kiro`, `speckit`, `openspec`) exist for. Use a +host profile to validate a host document; use `strict` for `.ears`, text, and +stdin. See [profiles](#profiles) and [input formats](input-formats.md). + +### SARIF output + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif +``` + +`--sarif` emits a SARIF 2.1.0 log (not the envelope). Each diagnostic id becomes +a `rule` in `tool.driver.rules`, and each finding becomes a `result` with a +`physicalLocation`. Abridged: + +```json +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "earsyntax", + "rules": [ + { + "id": "EARS-E006", + "shortDescription": { "text": "Malformed If/then unwanted-behaviour form" }, + "fullDescription": { "text": "An If clause is missing its required then boundary." }, + "helpUri": "docs/diagnostics.md#ears-e006", + "defaultConfiguration": { "level": "error" } + } + ] + } + }, + "results": [ + { + "ruleId": "EARS-E006", + "ruleIndex": 0, + "level": "error", + "message": { "text": "The 'If' clause is missing the required 'then' boundary." }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": ".kiro/specs/checkout/requirements.md" }, + "region": { "startLine": 10, "startColumn": 4 } + } + } + ] + } + ] + } + ] +} +``` + +## `extract ` + +Print the requirement candidates the active profile's locator finds, with source +positions and the matching locator rule. This is the debugging surface for +profiles: it does not lint, so it never returns exit `1`. + +```bash +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "extract", + "ok": true, + "summary": { "files": 1, "candidates": 3 }, + "candidates": [ + { + "file": ".kiro/specs/checkout/requirements.md", + "line": 9, + "col": 4, + "text": "WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + } + ] +} +``` + +Each `Candidate` is `{ file, line, col?, text, profile, locatorRuleId, requirementId? }`. +`col` and `requirementId` are omitted when unknown. `locatorRuleId` traces the +candidate back to the profile rule that selected it, so `extract` explains why a +line was or was not picked up. + +## `instructions --file [--from ]` + +Return the deterministic rules a coding agent follows for one loop step against a +host file. Read-only: the CLI returns rules and (for `repair`/`review`) findings; +it never edits, never converts content, and never calls an LLM. The human mirror +of this rule text is [agent-rules.md](agent-rules.md). + +```bash +earsyntax instructions repair --file ".kiro/specs/checkout/requirements.md" --profile kiro --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "instructions repair", + "ok": true, + "mode": "repair", + "file": ".kiro/specs/checkout/requirements.md", + "profile": "kiro", + "locator": { + "documentKinds": ["markdown"], + "summary": "Bullet and numbered items under #### Acceptance Criteria headings in requirements.md." + }, + "dialect": { + "keywordCase": "case-insensitive", + "commaAfterLeadingClause": "optional", + "allowLiteralSystemName": ["THE SYSTEM"], + "allowStoryWrapper": true, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "rules": [ + "Change only what the reported findings justify; leave passing requirements untouched.", + "EARS-E006: Add the missing then: If , then the shall .", + "EARS-E008: Insert the system name before shall: the shall .", + "Edit only the host file, in place, and preserve the surrounding document structure." + ], + "editPolicy": { + "editableFile": ".kiro/specs/checkout/requirements.md", + "preserveStructure": true + }, + "outputPolicy": "edit-in-place", + "findings": { + "ok": false, + "summary": { "files": 1, "requirements": 3, "valid": 2, "errors": 2, "warnings": 0 }, + "diagnostics": [] + }, + "next": [ + { + "command": "earsyntax validate .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Validate the host file after editing and repeat until no error-severity finding remains.", + "forAgent": true + } + ] +} +``` + +The payload keys are `mode`, `file`, `profile`, optional `sourceFile`, `locator`, +`dialect`, `rules`, `editPolicy`, `outputPolicy` (always `"edit-in-place"`), +optional `findings`, and `next`. The four modes differ: + +| Mode | `findings` | `rules` focus | +| --------- | ---------- | ----------------------------------------------------------------------------- | +| `author` | no | Write new EARS requirements into the host file's requirements region. | +| `convert` | no | Rewrite natural-language requirements already in the file into EARS in place. | +| `repair` | yes | Change only what the findings justify, keyed to their ids. | +| `review` | yes | Produce a human review summary; never approve, accept, or merge. | + +### `--from ` + +`--from ` is valid only with `author` and `convert`; using it with +`repair` or `review` is a usage error (exit `2`): + +```bash +earsyntax instructions repair --file x.md --from y.md --profile kiro +``` + +```text +error instructions.from_not_allowed: The --from source is only valid with author and convert, not repair. +``` + +It names a natural-language spec the agent reads as input while writing EARS into +`--file`. The CLI never reads or transforms `` itself; it points the agent +at it. When present, the response adds `sourceFile` and `sourcePolicy: "read-only"`, +and `rules` gains entries directing the agent to read from the source, write into +`--file`, and leave the source unchanged. + +## `explain ` + +Full write-up of one diagnostic. Resolves current ids and deprecated dotted +aliases. No profile needed; exit `0` on a known id, `2` on an unknown one. + +```bash +earsyntax explain EARS-E006 +``` + +```text +EARS-E006 error Malformed If/then unwanted-behaviour form + +Meaning + An If clause is missing its required then boundary. + +Rationale + Canonical EARS requires 'If , then the shall .' Without the then boundary the unwanted-behaviour form is incomplete. + +Bad + If the signature is invalid, the system shall reject the webhook. + +Good + If the signature is invalid, then the system shall reject the webhook. + +Profiles + Error by default under every built-in profile. --strict has no further effect on an error; only an explicit profile severity override can change its effective severity. +``` + +With `--json` the same content is structured as `{ id, requestedId, severity, +title, meaning, rationale, badExample, goodExample, profileNotes }`. When the +requested id was a deprecated alias, the response also carries `alias: true` and +a `deprecationNote`, while `id` holds the resolved current id: + +```bash +earsyntax explain ears.invalid_if_then_form --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "explain", + "ok": true, + "id": "EARS-E006", + "requestedId": "ears.invalid_if_then_form", + "alias": true, + "deprecationNote": "ears.invalid_if_then_form is a deprecated alias for EARS-E006.", + "severity": "error", + "title": "Malformed If/then unwanted-behaviour form", + "meaning": "An If clause is missing its required then boundary.", + "next": [] +} +``` + +An unknown id fails with exit `2` and suggests near matches: + +```text +error explain.unknown_id: Unknown diagnostic id "NOPE". Did you mean EARS-E001, EARS-E002, EARS-E003? +``` + +## `profiles` + +List the built-in profiles, rendered from profile data so the descriptions +cannot drift. Exit `0`. + +```bash +earsyntax profiles --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "profiles", + "ok": true, + "profiles": [ + { + "name": "strict", + "locates": "every non-empty line in ears, text files.", + "relaxes": [], + "adds": [], + "severityOverrides": {} + }, + { + "name": "ears-x", + "locates": "every non-empty line in ears, text files.", + "relaxes": [], + "adds": ["frame metadata", "prohibition (shall not)", "id format (^REQ-\\d+$)"], + "severityOverrides": {} + }, + { + "name": "kiro", + "locates": "list items under /^acceptance criteria$/ in markdown files.", + "relaxes": [ + "keyword case", + "leading comma", + "literal system name (THE SYSTEM)", + "user-story wrappers" + ], + "adds": [], + "severityOverrides": { "EARS-W011": "off", "EARS-W014": "off" } + }, + { + "name": "speckit", + "locates": "sections matching /^(functional )?requirements$/ in markdown files.", + "relaxes": [], + "adds": [], + "severityOverrides": {} + }, + { + "name": "openspec", + "locates": "### Requirement: blocks and #### Scenario: blocks in markdown files.", + "relaxes": [], + "adds": [], + "severityOverrides": {} + } + ], + "next": [] +} +``` + +Each `ProfileSummary` is `{ name, locates, relaxes, adds, severityOverrides }`, +one per built-in profile, in the order `strict`, `ears-x`, `kiro`, `speckit`, +`openspec`. + +## `doctor` + +Detect host frameworks and agent integrations in the repo at `--cwd`, and +recommend exact commands. Works with no setup. Exit `0`. + +```bash +earsyntax doctor +``` + +```text +Repo: /repo + +Hosts: + kiro .kiro/specs/ (profile kiro) + +Recommended commands: + earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro + earsyntax init --agent claude --host kiro +``` + +With `--json`, detection lands in `detected.hosts` and `detected.agents`, and +every recommendation is a runnable command in `next`: + +```json +{ + "version": "0.0.1-alpha.0", + "command": "doctor", + "ok": true, + "root": "/repo", + "detected": { + "hosts": [{ "host": "kiro", "evidence": ".kiro/specs/", "profile": "kiro" }], + "agents": [] + }, + "next": [ + { + "command": "earsyntax validate \".kiro/specs/**/requirements.md\" --profile kiro", + "reason": "Validate Kiro requirements with the Kiro profile.", + "forAgent": true + }, + { + "command": "earsyntax init --agent claude --host kiro", + "reason": "Render integration files for the detected hosts and agents.", + "forAgent": true + } + ] +} +``` + +Detection markers include `.kiro/specs/`, `.kiro/steering/`, `.kiro/hooks/`, +`specs/**/spec.md`, `.specify/`, `openspec/`, `.claude/`, `AGENTS.md`, `.cursor/`, +`.github/prompts/`, and `GEMINI.md`. + +## `init --agent --host ` + +Render managed agent-wrapper and host-integration files. It does not create a +workspace, does not edit requirement documents, does not validate as a side +effect, and does not call an LLM. It is idempotent: running the same command +twice produces no diff. + +```bash +earsyntax init --agent claude --host kiro --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "init", + "ok": true, + "root": "/repo", + "agents": ["claude"], + "hosts": ["kiro"], + "written": [".claude/commands/earsyntax-author.md", ".claude/commands/earsyntax-repair.md"], + "updated": [], + "skipped": [], + "warnings": [], + "next": [ + { + "command": "earsyntax validate \".kiro/specs/**/requirements.md\" --profile kiro", + "reason": "Validate kiro requirements with the kiro profile." + } + ] +} +``` + +`--agent` accepts a comma list of `claude`, `codex`, `cursor`, `copilot`, +`gemini`, `generic`. `--host` accepts a comma list of `kiro`, `speckit`, +`openspec`. The response reports `written` (newly created files), `updated` +(files whose managed section changed), `skipped` (files already up to date, which +is how a second run reports a no-op), and `warnings` (non-fatal notices). Files +that may already exist use managed begin/end markers, so only the managed section +is touched. + +## `version --features` + +Report the installed version and capability map. Never resolves a repo, so `root` +is absent. Exit `0`. + +```bash +earsyntax version --features --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "version", + "ok": true, + "features": { + "facade": 1, + "commands": [ + "validate", + "extract", + "instructions", + "explain", + "profiles", + "doctor", + "init", + "version" + ], + "profiles": ["strict", "ears-x", "kiro", "speckit", "openspec"], + "instructions": ["author", "convert", "repair", "review"], + "hosts": ["kiro", "speckit", "openspec"], + "agents": ["claude", "codex", "cursor", "copilot", "gemini", "generic"], + "inputFormats": ["ears", "text", "markdown", "yaml", "json"], + "outputFormats": ["pretty", "json", "sarif"], + "sarif": true + }, + "next": [] +} +``` + +`features.facade` is the integer facade contract version; it increments only on a +breaking change to these shapes. An agent reads `features` to discover the closed +surface without guessing. + +## The repair loop + +The commands compose into one loop that keeps the CLI, the agent, and the human +separate. `validate` reports findings, `instructions repair` returns the rules to +fix them, the agent edits the host file, and `validate` runs again until clean. +See [the agentic loop](agentic-loop.md) for the full walk-through. + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --json +# agent reads the findings, then: +earsyntax instructions repair --file .kiro/specs/checkout/requirements.md --profile kiro --json +# agent edits the host file in place, then re-validates until exit 0 +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +The agent never approves, accepts, or merges. Human review stays in the host +workflow: pull request review, Kiro review, Spec Kit review, or OpenSpec change +review. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..2031a8f --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,264 @@ +# Go Reference Compatibility + +This document records how `@earsyntax/core` relates to the Go reference +implementation `ears-lint-go` (module `github.com/labeth/ears-lint-go`). It +covers the parity policy, the intentional deviations between the two +implementations, the provenance of every parity fixture, and the open questions +a reader should keep in mind when using these fixtures as golden tests. + +The parity fixtures live under `fixtures/ears-lint-go-parity/`. Each one is a +golden snapshot of the Go reference output for a specific input, catalog, and +options triple, translated into the frozen fixture schema (`fixtures/schema.md`) +and into the frozen TypeScript contract (`packages/core/src/types.ts`). + +## How the fixtures were produced + +Every fixture was generated by running the real Go library, not by reading test +source by eye. A throwaway Go program linked `github.com/labeth/ears-lint-go` +through a local `replace` directive and called `LintEars` with the exact +catalog and options each Go test uses, then serialized the `LintResult`. The Go +test suite passes (`go test ./...` reports `ok`), so the captured outputs match +the reference behavior that suite asserts. + +The captured results were then translated into fixtures under two rules: + +- Diagnostics are recorded as the exact `(code, severity)` multiset the Go + reference produced, matching the schema's multiset comparison rule. Two + representative fixtures also pin a diagnostic span (see the fixture list). +- AST assertions use the TypeScript contract field names, which differ from the + Go struct in the ways described under Intentional deviations below. + +## Parity policy + +`@earsyntax/core` matches `ears-lint-go` where the Go behavior aligns with the +project goals stated in the orchestration brief: + +- AST-level shell pattern classification (ubiquitous, state-driven, + event-driven, optional-feature, unwanted-behaviour, complex). +- Boolean clause expression parsing with precedence `not > and > or`, grouping, + and the same `kind` values on expression nodes. +- Deterministic catalog matching: exact canonical name, then exact alias, then + ambiguous, then unresolved. No fuzzy matching. +- The strict and guided mode concept, including the rule that guided mode + downgrades structural parse failures to warnings. +- Stable diagnostic sorting and the rule that `valid` is `false` only when some + diagnostic has severity `error`. +- Response splitting on semicolons. + +Parity is not bug-for-bug where the manager has chosen a different contract. The +deviations below are intentional and the fixtures target the TypeScript contract +for those cases. + +## Intentional deviations + +| Area | Go reference behavior | earsyntax behavior | Reason | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unwanted-behaviour clause field | `EarsAST` has no `unwanted` field. The `If ... then` clause body is stored in `ast.Trigger` (`shell_parser.go`, `assignClause`, `ClauseIf` case). | `EarsAst` has a dedicated `unwanted` field for the `If` clause body; `trigger` is reserved for `When`. | Unwanted behaviour must be first-class in the TypeScript contract even though the Go struct folds it into the trigger slot. | +| Clause expression node shape | `ClauseExpr` is one struct with a `Kind` string plus optional `Term`, `Text`, `Items`, and `Item` fields. | `ClauseExpr` is a discriminated union (`TermExpr | AndExpr | OrExpr | NotExpr | GroupExpr | FreeTextExpr`) on `kind`. | TypeScript expresses the node families as a union for type safety. The `kind` string values are identical, so parse structure is preserved. | +| Batch input type | `LintEarsBatch(items [][2]string, ...)` takes `[id, text]` pairs. | `lintEarsBatch(items: RequirementInput[], ...)` takes objects with `id`, `text`, and optional `source`. | The TypeScript batch surface carries source locations from extractors. Parity fixtures exercise the single-requirement path (`lintEars`), so this difference is not fixture-tested here. | +| Pattern name strings | `PatternOptionalFeature = "optional-feature"`, `PatternUnwantedBehavior = "unwanted-behaviour"`. | Same string values. | The orchestration brief anticipated Go might use `optional`. The actual Go reference already uses `optional-feature` and `unwanted-behaviour`, so there is no divergence. Recorded here to close that open question. | +| Span offset units | Spans are byte offsets into the UTF-8 input (Go string indices). | Spans are UTF-16 code unit offsets (JavaScript string indices). | The two units are identical for ASCII input and differ only when the text contains multi-byte characters. The parity corpus is entirely ASCII, so every pinned span in these fixtures is already correct under both interpretations. Any future parity fixture with non-ASCII text and an asserted span must recompute the expected span as a UTF-16 index or drop the span assertion. | +| Catalog coverage API | Go exposes `LintCatalogCoverage` and `LintCatalogCoverageFromResults`, which emit `catalog.term_unreferenced` warnings for catalog entries no requirement references (strict mode only). | The frozen public API is `lintEars`, `lintEarsBatch`, and `parseEars`. No coverage entry point is defined, though `catalog.term_unreferenced` exists in the diagnostic union. | Coverage is a batch-level lint that does not fit the per-requirement fixture schema, and no TypeScript entry point emits it yet. The two `coverage_test.go` cases are therefore not translated into fixtures. | +| System-term expression diagnostic | `resolveTerm` (`catalog_match.go`) always appends an expression-level diagnostic (`expr.ambiguous_term` or `expr.unknown_term`) alongside the role-specific `catalog.*` diagnostic, including for the system term, which is not part of a clause expression. | The system term produces only `catalog.system_ambiguous` or `catalog.system_unresolved`. No `expr.*` diagnostic is emitted for the system role. | The `expr.*` codes describe clause-expression terms. The system name is not a clause expression, so mirroring an expression diagnostic onto it is incidental in Go. Fixtures `A301`, `LIB-system-unresolved`, and `LIB-determinism` drop the system `expr.*` warning. | +| Duplicate diagnostic collapse | Go can emit exact-duplicate diagnostics: two `ears.missing_shall` errors for a no-shall sentence (one from the `countShall` prescan, one from the system-clause scan), both with no span. | Diagnostics that are exact `(code, span)` duplicates collapse to a single occurrence. Duplicates with distinct spans are preserved. | Two findings that point at the same code and the same location (or both at no location) are the same finding reported twice. Fixtures `I405`, `I406`, and `N701` collapse the doubled `ears.missing_shall`. Duplicate `lint.alias_used` warnings survive because the clause-term alias carries a span and the system-term alias does not, so the pairs are distinct (`V201`, `V202`, `V204`, `A303`). | +| Specific structural code over blanket no-match | When a parse fails for a specific structural reason, Go still also appends `ears.no_match` on top of the specific code. | When the failure is specific, the shell parser emits the specific code(s) without an accompanying `ears.no_match`. `ears.no_match` is emitted alone only when the text is genuinely unstructured. | `ears.no_match` means the text matched no EARS shell at all. When a more precise cause is known (missing shall, unbalanced parentheses, missing system), that code carries the signal and the blanket code is redundant. Fixtures `I405`, `I406`, `N701` (missing shall) drop `ears.no_match`. `LIB-guided-suspicious`, whose input has no EARS shape at all, keeps `ears.no_match` alone (plus the guided-mode `lint.suspicious_text_shape`). | +| Empty parenthesized group | For an empty group `()`, Go emits both `expr.invalid_operator_sequence` and `expr.unbalanced_parentheses`. | A single `expr.empty_subexpression` is emitted. | An empty group has one defect: it contains no subexpression. Reporting an operator-sequence error and a balance error for it is misleading, since the parentheses are balanced and no operator is involved. No parity fixture contains an empty group; recorded for the expression-parser and grammar agents. | +| Unparsed tail after an operator error | When an operator sequence error truncates expression parsing, Go reports `expr.invalid_operator_sequence` and also `lint.unparsed_tail` for the leftover tokens. | Only `expr.invalid_operator_sequence` is reported. For this input class the truncated remainder produces no `lint.unparsed_tail` at either level: the expression parser suppresses its own follow-on tail, and the sentence-level recovery in `lint.ts` emits none either. | The unparsed tail is a restatement of the operator error already reported, not an independent finding, so a single root-cause diagnostic is clearer. Fixture `I506` keeps only `expr.invalid_operator_sequence` (empirically confirmed against the core test suite). This matches the grammar fixture `INV-010`. | +| Infix `not` inside a term | Go tokenizes `not` anywhere in a clause as a `not` operator, so `the retry queue is not full` would split into a negation over a partial term. | Only a leading `not` is a negation operator. An infix `not` folds into the surrounding term text, so `the retry queue is not full` is a single term. | Requirement authors write `not` inside natural-language predicates. Treating every `not` as an operator would fragment ordinary terms. A leading `not` (for example `not service mode is active`) remains a negation. Parity fixtures `V109` and `K901` use leading `not`, which stays an operator; no fixture uses an infix `not`. | +| Whole-text parenthesis imbalance recovery | For an input whose parentheses do not balance across the whole text, Go reports `expr.unbalanced_parentheses` and then, because the broken parse never reaches the `the shall` clause, also `ears.missing_system` and `ears.no_match`. | Only `expr.unbalanced_parentheses` is reported (severity per mode). The downstream `ears.missing_system` and `ears.no_match` are suppressed. | When a whole-text paren imbalance is what prevents shell parsing, the missing-system finding is an artifact of the failed recovery, not a real defect in the requirement, and is misleading to surface. This keeps `I502` consistent with the structurally identical grammar fixtures `INV-009` and `VAL-038`, which expect `expr.unbalanced_parentheses` only. | +| `When ... If ... then` clause order | Go rejects a `When` clause followed by an `If ... then` clause with `ears.invalid_clause_order` (`validateClauseCardinality` counts `When` plus `If` as multiple triggers), and its single trigger slot loses the `When` body because `assignClause` overwrites `trigger` with the `If` body. | The order is valid and classifies as `complex`. The `When` body populates `trigger` and the `If` body populates `unwanted`; both survive. | The accepted clause order is `While* -> Where* -> When* -> If*`, so a `When` clause preceding an `If` clause is in order. The dedicated `unwanted` field (see the first row) means the two clauses no longer compete for one slot. Fixture `I410` is `valid: true`, `complex`, with both `trigger` (span 5..32) and `unwanted` (span 37..64), and no `ears.invalid_clause_order`. | +| Empty-but-present catalog | Go treats each catalog group as a slice; an empty catalog is a real catalog with zero entries, so every term resolves to unresolved and emits `catalog.*_unresolved` diagnostics. | An empty catalog (present but with no entries, or all groups empty) behaves as no-catalog mode: no term resolution runs, so no `catalog.*` unresolved or ambiguous diagnostics are produced. | Catalog matching is opt-in. Supplying an empty catalog signals no domain terms to check against, which is the same as supplying none. Treating it as strict-unresolved would flag every term in a spec that has not yet defined a catalog. No parity fixture exercises this (all carry a populated catalog); the note is recorded for the integration and catalog agents. | +| Group and negation span extent | A group node and a `not` node carry the span of their inner operand only; the parentheses and the leading `not` keyword fall outside the node span (`GroupExpr.span == inner.span`, `NotExpr.span == inner.span`). | A non-empty `GroupExpr` span covers its parentheses (opening paren start to closing paren end), and a `NotExpr` span covers the `not` keyword through the end of its operand. | A node's span should point at the exact source substring the node represents, including its delimiters, so a tool can highlight the whole group or negation. Parity fixtures `K901` and `V102` were updated to the paren-inclusive spans (K901 `preconditions` 6..127 and `trigger` 134..224; V102 `preconditions` 6..91). Enclosing `and`/`or` spans shift by the same paren offsets through span merging. | +| Canonical name precedence over aliases | `findMatches` collects canonical-name matches and alias matches into one candidate set, so a term that names one entry canonically and another entry by alias is reported ambiguous. | Canonical-name matches take precedence: alias matches are considered only when no entry matched by canonical name, so a canonical hit wins over a competing alias and the term resolves to the canonical entry. Alias-only collisions (no canonical match) are still ambiguous. | The frozen `TermMatch` contract (`packages/core/src/types.ts`) documents matching as canonical, then alias, then ambiguous, then unresolved. No parity fixture mixes a canonical and an alias match: the ambiguous fixtures `A301`, `A303`, `K901`, and `LIB-ambiguous-event` are all canonical/canonical or alias/alias, so none changed. | +| Resolved term role | `resolveTerm` overwrites `term.Role` with the role of the group a single match came from, so a term resolved through a non-requested group reports that group's role. | `TermMatch.role` keeps the requested role (the role the term was expected to play); the matched group is still recorded in `matched.group`. | The `TermMatch.role` field documents "the role this term was expected to play." Overwriting it with the matched group's role loses that information and contradicts the field's contract. No parity fixture asserts a cross-group single-match role. | + +## Strict dialect tightenings (host-native grammar) + +The host-native grammar work introduced a resolved dialect (see +`docs/contracts/profile.md`, "dialect", and `packages/core/src/options.ts`, +`ResolvedDialect`). `lintEars` and `parseEars` default to the strict dialect, +which formalizes Mavin's ruleset more tightly than the Go reference does. The Go +reference matches keywords case-insensitively, has no notion of a required +leading comma, no prohibition concept, and no frame-metadata or story-wrapper +tolerance, so its behavior corresponds to a relaxed dialect rather than to +strict. + +Because the parity corpus pins Go behavior, the fixture harness +(`packages/core/test/fixtures.test.ts`) runs every `fixtures/ears-lint-go-parity/` +fixture under a Go-matching dialect (`keywordCase: case-insensitive`, +`commaAfterLeadingClause: optional`, `allowProhibition: true`) unless the fixture +sets its own `dialect`. The `fixtures/valid/` and `fixtures/invalid/` corpora +assert the new strict defaults. Every parity fixture keeps its recorded +`(code, severity)` multiset under the loose dialect; no parity fixture was +deleted or altered for this work. + +The strict tightenings, each new relative to the Go reference and each gated by a +dialect knob, are: + +| Ruling | Strict behavior | Go reference | Relaxing dialect knob | Fixtures | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| ES-D-001 leading comma | A leading `While`/`Where`/`When` clause must be comma-delimited from the main clause. A missing comma is `ears.missing_leading_comma` (the tail is still recovered). | No comma-specific diagnostic; a comma-less leading clause fails to parse into a tail. | `commaAfterLeadingClause: optional` accepts it and recovers silently. | `invalid/missing-leading-comma`, `valid/kiro-optional-leading-comma` | +| ES-D-002 `then` discriminator | `then` is valid only inside an `If ... then` requirement. A `then` elsewhere is `ears.invalid_if_then_form`. | No check for a stray `then` outside the If form. | none (structural rule; applies under every dialect) | `invalid/then-outside-if`, `valid/unwanted-behaviour-basic` | +| ES-D-004 prohibition | `shall not` is `ears.prohibition_not_allowed`. | `shall not` parses as a `not ...` response with no diagnostic. | `allowProhibition: true` accepts it and sets `EarsAst.prohibition`. | `invalid/prohibition-strict`, `valid/prohibition-earsx` | +| ES-D-005 system name | Only `the ` (or a permitted literal) names the system; a pronoun leaves the system empty (`ears.missing_system`). | Same `the`-required tail, but no literal-system-name allowance. | `allowLiteralSystemName` accepts listed literals (for example `THE SYSTEM`). | `invalid/pronoun-system`, `valid/literal-system-name` | +| ES-D-007 keyword casing | Clause keywords are capitalized sentence-initially and lowercase mid-sentence; `shall` and `then` are lowercase. A violation is `ears.keyword_case`. | Keywords match case-insensitively with no casing diagnostic. | `keywordCase: case-insensitive` suppresses the check. | `invalid/keyword-case-lowercase-when`, `valid/case-insensitive-when-lower` | +| Frame metadata | A leading `REQ-###` id or a trailing `[source: path:line]` tag is not part of the sentence; under strict it flows through as ordinary text (typically `ears.no_match`). | No frame-metadata concept. | `allowFrameMetadata: true` strips the id prefix and source tag before parsing. | `invalid/frame-metadata-strict`, `valid/frame-metadata-earsx` | + +ES-D-003 (at most one `When`; multiple triggers raise `ears.invalid_clause_order`) +and ES-D-008 (a second shell-level `shall` outside an `and`-joined response raises +`ears.multiple_shall`) are enforced identically under every dialect and match the +Go reference, so they are not dialect tightenings. ES-D-008 relies on the +whole-text `shall` count documented above under "Behaviors where the fixtures +follow the Go reference verbatim"; the ruling is layered on top of that count and +does not change fixture `I404`. The story-wrapper tolerance (`allowStoryWrapper`) +is surfaced as the exported `isStoryWrapperLine` predicate for the extraction +pipeline rather than as a parser branch, so it changes no lint behavior. + +## Behaviors where the fixtures follow the Go reference verbatim + +These are places where the Go reference does something worth calling out but the +fixtures still track it exactly, so a TypeScript implementation must reproduce +the behavior to pass them. + +- Alias matches are counted per resolved term. An aliased system plus an aliased + clause term produces two `lint.alias_used` warnings, and three aliased terms + produce three. Fixtures `V201` (two) and `V202` (three) pin those counts. The + clause-term alias warning carries a span and the system-term alias warning + carries none, so the duplicate-collapse rule (see the deviations table) leaves + both in place because the `(code, span)` pairs are distinct. +- `A303` is marked `eitherValid` in the Go corpus test. The actual run is + `valid: true` because the only errors would come from an ambiguous system, and + here the ambiguity is on a state term (warning) with an aliased, resolved + system. The fixture pins `valid: true`. +- Guided mode returns `valid: true` for text that does not parse at all, because + every structural diagnostic is downgraded to a warning. Fixture + `LIB-guided-suspicious` snapshots this: `ears.no_match` and + `lint.suspicious_text_shape`, both warnings, and `valid: true`. +- The `shall` count that drives `ears.missing_shall` (zero) and + `ears.multiple_shall` (more than one) is taken over the whole requirement text, + not just the shell-level `the shall` boundary. A `shall` inside a + clause body or response prose therefore counts toward the total, so + `ears.multiple_shall` can fire on a secondary `shall`. This matches the Go + reference `countShall`, which runs `\bshall\b` over the entire normalized input + (`shell_parser.go`). earsyntax keeps this behavior deliberately for parity + rather than restricting the count to the shell boundary; a requirement that + needs a literal `shall` in prose should rephrase. Fixture `I404` + (`ears.multiple_shall`) depends on the whole-text count. + +## Parity fixtures + +All fixtures use `mode: strict` unless noted. Corpus fixtures additionally use +`commaAsAnd: true` (the option the Go corpus test passes). `LIB-comma-as-and` +also uses `commaAsAnd: true`. `LIB-guided-suspicious` uses `mode: guided`. + +Corpus fixtures use the eight-group `fullCatalog()` from `corpus_test.go`. +Library fixtures use the smaller `testCatalog()` from `library_test.go`, except +`LIB-ambiguous-event`, which uses that catalog plus one `conditions` entry that +collides with an event entry (from `TestAmbiguousEventDiagnosticCode`). + +| Fixture | Provenance (Go test) | Pattern | valid | Diagnostic codes | +| ------------------------- | ----------------------------------- | ------------------ | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `V001` | `TestProvidedCorpus/V001` | ubiquitous | true | (none) | +| `V002` | `TestProvidedCorpus/V002` | state-driven | true | (none, span-pinned AST) | +| `V003` | `TestProvidedCorpus/V003` | event-driven | true | (none) | +| `V004` | `TestProvidedCorpus/V004` | optional-feature | true | (none, span-pinned AST) | +| `V005` | `TestProvidedCorpus/V005` | unwanted-behaviour | true | (none, span-pinned AST via `unwanted`) | +| `V006` | `TestProvidedCorpus/V006` | complex | true | (none) | +| `V102` | `TestProvidedCorpus/V102` | complex | true | (none, span-pinned AST) | +| `V105` | `TestProvidedCorpus/V105` | optional-feature | true | (none) | +| `V108` | `TestProvidedCorpus/V108` | complex | true | (none) | +| `V109` | `TestProvidedCorpus/V109` | complex | true | (none) | +| `V110` | `TestProvidedCorpus/V110` | event-driven | true | `lint.alias_used` | +| `V201` | `TestProvidedCorpus/V201` | event-driven | true | `lint.alias_used` x2 | +| `V202` | `TestProvidedCorpus/V202` | complex | true | `lint.alias_used` x3 | +| `V204` | `TestProvidedCorpus/V204` | optional-feature | true | `lint.alias_used` x2 | +| `A301` | `TestProvidedCorpus/A301` | event-driven | false | `catalog.event_ambiguous`, `expr.ambiguous_term`, `catalog.system_ambiguous` (system `expr.*` dropped) | +| `A303` | `TestProvidedCorpus/A303` | complex | true | `catalog.state_ambiguous`, `expr.ambiguous_term`, `lint.alias_used` x2 | +| `I402` | `TestProvidedCorpus/I402` | unwanted-behaviour | false | `ears.invalid_if_then_form` | +| `I404` | `TestProvidedCorpus/I404` | state-driven | false | `ears.multiple_shall` | +| `I405` | `TestProvidedCorpus/I405` | (none) | false | `ears.missing_shall` (doubled `missing_shall` and blanket `no_match` dropped) | +| `I406` | `TestProvidedCorpus/I406` | (none) | false | `ears.missing_shall` (doubled `missing_shall` and blanket `no_match` dropped) | +| `I410` | `TestProvidedCorpus/I410` | complex | true | (none; `When ... If ... then` is valid, `ears.invalid_clause_order` not ported, span-pinned `trigger` + `unwanted`) | +| `I501` | `TestProvidedCorpus/I501` | complex | false | `expr.invalid_operator_sequence` (span-pinned) | +| `I502` | `TestProvidedCorpus/I502` | (none) | false | `expr.unbalanced_parentheses` (span-pinned); Go also emitted `ears.missing_system` and `ears.no_match`, both dropped (see paren-recovery deviation) | +| `I506` | `TestProvidedCorpus/I506` | state-driven | false | `expr.invalid_operator_sequence` (Go also emitted `lint.unparsed_tail`, suppressed as a restatement of the operator error) | +| `N701` | `TestProvidedCorpus/N701` | (none) | false | `ears.missing_shall` (doubled `missing_shall` and blanket `no_match` dropped) | +| `S801` | `TestProvidedCorpus/S801` | complex | true | (none) | +| `S803` | `TestProvidedCorpus/S803` | unwanted-behaviour | true | (none) | +| `K901` | `TestProvidedCorpus/K901` | complex | true | `catalog.state_ambiguous`, `expr.ambiguous_term` (span-pinned AST) | +| `LIB-ubiquitous` | `TestUbiquitousPattern` | ubiquitous | true | (none) | +| `LIB-state-driven` | `TestStateDrivenPattern` | state-driven | true | (none) | +| `LIB-event-driven` | `TestEventDrivenPattern` | event-driven | true | (none, span-pinned AST) | +| `LIB-optional-feature` | `TestOptionalFeaturePattern` | optional-feature | true | (none) | +| `LIB-complex-expr` | `TestComplexPatternWithExpressions` | complex | true | (none) | +| `LIB-ifthen-missing-then` | `TestIfThenValidation` | unwanted-behaviour | false | `ears.invalid_if_then_form` | +| `LIB-ifthen-valid` | `TestValidIfThenPattern` | unwanted-behaviour | true | (none) | +| `LIB-system-unresolved` | `TestSystemUnresolvedInStrictMode` | ubiquitous | false | `catalog.system_unresolved` (system `expr.unknown_term` dropped) | +| `LIB-multiple-trigger` | `TestMultipleTriggerClausesInvalid` | complex | false | `ears.invalid_clause_order` | +| `LIB-guided-suspicious` | `TestGuidedSuspiciousShape` | (none) | true | `ears.no_match`, `lint.suspicious_text_shape` (unstructured text: `no_match` alone; `missing_shall`/`missing_system` dropped) | +| `LIB-comma-as-and` | `TestCommaAsAndExpression` | state-driven | true | (none) | +| `LIB-alias-used` | `TestAliasUseWarning` | ubiquitous | true | `lint.alias_used` | +| `LIB-determinism` | `TestDiagnosticsDeterministic` | event-driven | false | `expr.invalid_operator_sequence`, `catalog.system_unresolved`, `lint.vague_response` (system `expr.unknown_term` dropped) | +| `LIB-ambiguous-event` | `TestAmbiguousEventDiagnosticCode` | event-driven | true | `catalog.event_ambiguous`, `expr.ambiguous_term` | + +Batch tests (`TestBatchMode`, `TestLintCatalogCoverage_*`) are not represented as +fixtures. `TestBatchMode` only asserts that IDs are preserved in order, which the +schema cannot express for a single-requirement fixture, and the coverage tests +use the Go-only coverage API described under Intentional deviations. + +## Diagnostic code coverage + +The parity corpus exercises 15 of the 29 codes in the frozen `DiagnosticCode` +union. Codes not exercised by these fixtures: + +- `ears.empty_clause` +- `ears.empty_response` +- `ears.missing_system` +- `expr.empty_subexpression` +- `expr.operator_precedence_warning` +- `expr.mixed_unresolved_terms` +- `expr.unknown_term` +- `lint.multiple_responses` +- `lint.unparsed_tail` +- `catalog.state_unresolved` +- `catalog.event_unresolved` +- `catalog.feature_unresolved` +- `catalog.feature_ambiguous` +- `catalog.term_unreferenced` + +Most of these gaps come from the Go corpus itself, which does not include inputs +that trigger them. Three are a consequence of the intentional deviations rather +than the corpus: + +- `expr.unknown_term`: the only inputs that produced it were unresolved system + terms, and the system-term expression diagnostic is now dropped by design. A + clause term that is unresolved would still produce it, but no corpus input + exercises that. +- `ears.missing_system`: the only input that produced it (`I502`) was a + whole-text paren imbalance, where the missing-system finding is now suppressed + as a recovery artifact. A requirement that genuinely omits the system, with + balanced parentheses, would still produce it. +- `lint.unparsed_tail`: the only parity input that produced it (`I506`) was an + operator sequence error, where the follow-on tail is now suppressed as a + restatement of that error. This is a gap in the parity corpus only, not a + registry gap: the grammar corpus (`fixtures/valid/`) has a dedicated + unparsed-tail warning fixture, so the code stays exercised project-wide. Well- + formed text with a genuinely unparsed tail still produces it. The grammar agent's own `fixtures/valid` and `fixtures/invalid` + corpus is the place to cover them; they are listed here so the gap is explicit + rather than silent. + +## Known unknowns + +- Whether spans other than the two pinned diagnostic spans (`I501`, `I502`) and + the six span-pinned AST fixtures will line up byte-for-byte. Spans were not + asserted broadly because the schema makes span assertion opt-in per + diagnostic; the offsets that are pinned come directly from the Go run and + assume identical 0-based character offsets. Multi-byte input is not present in + this corpus, so byte and rune offsets coincide here. +- Whether the catalog coverage feature will ever be added to the TypeScript + surface. If it is, the two `coverage_test.go` cases should become fixtures + under a coverage-specific schema extension. +- `commaAsAnd` interacts with parsing in the corpus fixtures (the Go corpus runs + with it on). Fixtures that depend on it, such as `LIB-comma-as-and`, encode + the option explicitly, but a TypeScript implementation whose comma handling + differs in edge cases could diverge on the complex corpus entries. diff --git a/docs/contracts/findings.md b/docs/contracts/findings.md new file mode 100644 index 0000000..1d2f889 --- /dev/null +++ b/docs/contracts/findings.md @@ -0,0 +1,180 @@ +# Findings model v1 + +The Findings model is the single canonical result that every findings-bearing +command returns. `validate` returns it directly. `extract`, `instructions`, +`explain`, `profiles`, `doctor`, `init`, and `version` embed or reference it +per `docs/refactor/host-native-facade.md`. SARIF is a projection of this model, +never a second pipeline. + +This contract is frozen. Fields are append-only within contract version 1: new +optional fields may be added, existing fields are never renamed, retyped, or +removed without a contract version bump. + +## Types + +```ts +interface Findings { + ok: boolean; + summary: { + files: number; + requirements: number; + valid: number; + errors: number; + warnings: number; + }; + diagnostics: Diagnostic[]; +} + +interface Diagnostic { + id: string; + severity: 'error' | 'warning'; + file: string; + line: number; + col?: number; + message: string; + fix?: string; + requirementId?: string; +} +``` + +## Field semantics + +### `Findings` + +| Field | Type | Meaning | +| ---------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `ok` | `boolean` | `true` when zero diagnostics have severity `error` after all severity resolution (profile overrides and `--strict`). See "The `ok` rule". | +| `summary.files` | `number` | Count of source files the pipeline located and read. Stdin counts as one file. | +| `summary.requirements` | `number` | Count of requirement candidates the extractor produced across all files. | +| `summary.valid` | `number` | Count of requirements carrying no error-severity diagnostic. `valid <= requirements`. | +| `summary.errors` | `number` | Total count of diagnostics with effective severity `error`. Equal to `diagnostics.filter(d => d.severity === 'error').length`. | +| `summary.warnings` | `number` | Total count of diagnostics with effective severity `warning`. | +| `diagnostics` | `Diagnostic[]` | Every finding across every file, in stable order (see "Ordering"). Always present; may be empty. | + +`summary.errors` and `summary.warnings` count effective severities, the same +values written to each `Diagnostic.severity`. There is no `info` severity in +the Findings model. `summary` has no `infos` field. + +### `Diagnostic` + +| Field | Type | Meaning | +| --------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | The registry ID: `EARS-E###` or `EARS-W###`. The prefix reflects the registry's default severity, not necessarily the effective severity on this diagnostic (see below). | +| `severity` | `'error' \| 'warning'` | The EFFECTIVE severity after profile severity overrides and `--strict`. This is what `ok` and the summary counts derive from. | +| `file` | `string` | Path to the source file, relative to `--cwd` (POSIX separators), or `-` for stdin. Never absolute unless the caller passed an absolute path. | +| `line` | `number` | 1-based line in `file`, mapped back to the original host document position through every pipeline stage. Required. | +| `col` | `number` (optional) | 1-based column, when the finding maps to a specific column. Omitted when only line resolution is available. | +| `message` | `string` | One factual sentence describing the finding. Third-person, neutral. No fix instructions here; use `fix`. | +| `fix` | `string` (optional) | One suggested remediation sentence, when the diagnostic has a deterministic repair hint. Omitted otherwise. Advisory only; the core never edits files. | +| `requirementId` | `string` (optional) | The requirement's own ID (for example a `REQ-001` frame ID under `ears-x`), when the extractor found one. Distinct from the diagnostic `id`. | + +## The `ok` rule + +`ok` is `true` if and only if no diagnostic has effective severity `error`: + +```ts +findings.ok === (findings.summary.errors === 0); +``` + +Effective severity is computed in this order, and `severity` on each emitted +`Diagnostic` already reflects the result: + +1. Start from the registry default severity for the diagnostic's ID + (`EARS-E###` defaults to `error`, `EARS-W###` defaults to `warning`). +2. Apply the active profile's `severity` override for that ID, if any + (`error`, `warning`, or `off`). `off` drops the diagnostic entirely; it does + not appear in `diagnostics` and is not counted. +3. Apply `--strict`: every remaining `warning` is upgraded to `error`. + +`--strict` upgrades warnings to errors AT THE FINDINGS LAYER. It does not +re-run the parser or linter and does not change which diagnostics are produced; +it only reclassifies the severity of already-produced warnings. A profile +`severity` override of `off` wins over `--strict` (an `off` diagnostic is never +produced, so there is nothing for `--strict` to upgrade). + +Consequences: + +- An `EARS-W###` diagnostic can carry `severity: 'error'` after `--strict` or a + profile override. The ID prefix is the default classification, not a runtime + guarantee. +- An `EARS-E###` diagnostic always carries `severity: 'error'` unless a profile + override downgrades it to `warning` or `off`. +- Reading the ID prefix tells you the default; reading `severity` tells you the + effective classification for this run. + +## SARIF is a projection + +The SARIF emitter consumes a `Findings` value and maps it to SARIF 2.1.0. It +does not lint, parse, or re-derive anything: + +- One SARIF `result` per `Diagnostic`. +- `result.ruleId` = `Diagnostic.id`. +- `result.level` = `error` for `severity: 'error'`, `warning` for + `severity: 'warning'`. There is no `note` level because the Findings model + has no `info` severity. +- `result.message.text` = `Diagnostic.message`. +- `result.locations[0].physicalLocation`: `artifactLocation.uri` = + `Diagnostic.file`; `region.startLine` = `Diagnostic.line`; + `region.startColumn` = `Diagnostic.col` when present. +- Rule metadata (`shortDescription`, `helpUri`) comes from the diagnostic + registry, one `reportingDescriptor` per registry ID, sorted by ID. + +Because SARIF is downstream of `Findings`, `--strict` and profile overrides are +already baked into `severity` before projection: SARIF levels match the +effective severities with no additional logic. + +## Ordering + +`diagnostics` is stably sorted so identical input always serializes identically: + +1. `file`, by code-unit order (input file order is preserved when the caller + passes files; globs are expanded in sorted order upstream). +2. `line`, ascending. +3. `col`, ascending; diagnostics without `col` sort after those with `col` on + the same line. +4. `id`, by code-unit order. +5. `message`, by code-unit order. + +## JSON emission and field order + +When a command serializes a `Findings` value (for example `validate --json`), +keys are constructed in this fixed order so output is byte-stable: + +`Findings`: + +1. `ok` +2. `summary` +3. `diagnostics` + +`summary` (always all five keys, always present): + +1. `files` +2. `requirements` +3. `valid` +4. `errors` +5. `warnings` + +Each `Diagnostic`, optional keys included only when present, always in this +position: + +1. `id` +2. `severity` +3. `file` +4. `line` +5. `col` (optional) +6. `message` +7. `fix` (optional) +8. `requirementId` (optional) + +Serialization is 2-space indented. The command envelope that carries the +Findings (base fields `version`, `command`, `ok`, plus `findings`) is specified +in `docs/refactor/host-native-facade.md`; this document fixes only the +`Findings` object itself. + +## Relationship to the current code + +The CLI emits this model today. `validate` builds a `Findings` object and +serializes it through `canonicalizeFindings` and `serializeFindings` from +`@earsyntax/cli-contract` (`packages/cli/src/commands/validate.ts`). SARIF output +is a projection of the same `Findings` object, produced by `buildSarifLog` in the +same package. diff --git a/docs/contracts/profile.md b/docs/contracts/profile.md new file mode 100644 index 0000000..611e28f --- /dev/null +++ b/docs/contracts/profile.md @@ -0,0 +1,202 @@ +# Profile schema v1 + +A profile is data, not code. It is a validated JSON/TS object that tells the +pipeline which host document regions to locate, which EARS dialect to accept, +and how to classify each diagnostic. Parser and linter logic never branch on a +profile name; they read these fields. Adding a host means adding a data file, +not editing parser conditionals. + +This contract is frozen. The schema is closed: unknown keys are a validation +error, never silently ignored and never a silent extension point. New fields +are added by amending this contract with a version note, not by tolerating +extra keys. + +## Type + +```ts +interface Profile { + name: 'strict' | 'ears-x' | 'kiro' | 'speckit' | 'openspec'; + notation: 'ears'; + dialect: { + keywordCase: 'strict' | 'case-insensitive'; + allowLiteralSystemName: string[]; + commaAfterLeadingClause: 'required' | 'optional'; + allowStoryWrapper: boolean; + allowFrameMetadata: boolean; + allowProhibition: boolean; + }; + locator: { + documentKinds: string[]; + include: LocatorRule[]; + exclude: LocatorRule[]; + codeFences: 'ignore' | 'include'; + }; + severity: Record; + idFormat: { + required: boolean; + pattern?: string; + }; +} + +interface LocatorRule { + id: string; + kind: 'every-line' | 'heading-section' | 'list-item' | 'block'; + headingPattern?: string; + underHeading?: string; + listMarker?: 'bullet' | 'ordered' | 'any'; + blockPrefix?: string; + note?: string; +} +``` + +`LocatorRule` is the unit the extractor reports as the matching rule for each +candidate (`extract` returns `locatorRuleId`). The `id` field and the four +`kind` values are the frozen part, because `extract` output and profile +fixtures depend on them. The optional fields are the finalized minimal set the +built-in markdown profiles need; each applies to specific kinds: + +| Field | Applies to | Meaning | +| ---------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `headingPattern` | `heading-section` | Regex selecting the heading whose section body lines this rule targets (include) or removes (exclude). | +| `underHeading` | `list-item` | Regex selecting the ancestor heading a candidate list must sit under. | +| `listMarker` | `list-item` | Which list markers qualify: `bullet`, `ordered`, or `any`. Defaults to `any` when omitted. | +| `blockPrefix` | `block` | Literal heading line that opens a candidate block; the block body runs until the next heading of equal or higher level. | +| `note` | any | Human note documenting intent in the data file. Rendered nowhere. | + +`headingPattern` and `underHeading` are JavaScript regular-expression source +strings matched case-insensitively against a heading's trimmed text. +`blockPrefix` is a literal string matched against a trimmed line, not a regex. +`every-line` rules (strict, ears-x) use none of these fields: every non-empty +line of a `documentKinds` file is a candidate. + +## Field semantics + +### Top level + +| Field | Meaning | +| ---------- | ----------------------------------------------------------------------------------------------------------- | +| `name` | The profile identity. One of the five closed names. There are no user-defined profile names in alpha. | +| `notation` | Always `'ears'`. Reserved so a future notation cannot be added by silently repurposing an existing profile. | +| `dialect` | Grammar tolerances the parser/linter apply. See below. | +| `locator` | Which regions of a host document become requirement candidates. See below. | +| `severity` | Per-ID severity overrides keyed by registry ID (`EARS-E###` / `EARS-W###`). | +| `idFormat` | Whether requirements must carry an ID and its shape. | + +### `dialect` + +| Field | Meaning | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `keywordCase` | `strict`: EARS keywords must match canonical casing (`When`, `While`, `Where`, `If`, `shall`). `case-insensitive`: any casing, including all-caps (`WHEN`, `THE SYSTEM SHALL`). | +| `allowLiteralSystemName` | Literal system phrases accepted in place of `the ` (for example `["THE SYSTEM"]`). Empty means only the canonical `the ` form is valid. | +| `commaAfterLeadingClause` | `required`: a leading `When`/`While`/`Where`/`If` clause must be followed by a comma before the main clause. `optional`: the comma may be absent. | +| `allowStoryWrapper` | When `true`, user-story frame lines (for example `As a user, I want ...`) are treated as non-requirement frame content and skipped, not parsed as EARS. | +| `allowFrameMetadata` | When `true`, `REQ-###` frame IDs and `[source: path:line]` tags are accepted as metadata prefixes on a requirement line. | +| `allowProhibition` | When `true`, `shall not` is accepted as a prohibition kind. When `false`, `shall not` is rejected (canonical Mavin EARS has no prohibition template). | + +### `locator` + +| Field | Meaning | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `documentKinds` | The file kinds this profile locates over, for example `['ears','text']` or `['markdown']`. Files of other kinds produce no candidates. | +| `include` | Ordered `LocatorRule`s that select candidate regions. A region matched by any include rule is a candidate unless an exclude rule removes it. | +| `exclude` | Ordered `LocatorRule`s that remove regions from the candidate set (for example design or background prose sections). | +| `codeFences` | `ignore`: fenced code blocks are never candidates (prevents false positives on code samples). `include`: fenced content is eligible. | + +For `.ears` and plain-text documents the include set is the trivial +`every-line` rule: each non-empty line is a candidate. Markdown profiles use +`heading-section`, `list-item`, and `block` rules to target requirement +regions and to skip narrative prose that merely opens with an EARS keyword. + +### `severity` + +A partial map from registry ID to `error` / `warning` / `off`. Absent IDs keep +their registry default. `off` drops the diagnostic before it reaches the +Findings model (see `docs/contracts/findings.md`, "The `ok` rule"). This is the +only mechanism that changes a diagnostic's default classification per profile; +severity is never hard-coded in the linter. + +### `idFormat` + +| Field | Meaning | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `required` | When `true`, a requirement without an ID is a finding. When `false`, IDs are optional. | +| `pattern` | Optional regular expression a present ID must match (for example `^REQ-\d+$`). Applied whether or not `required` is `true`. | + +**RESERVED, not yet enforced.** `validateProfile` accepts `idFormat` and +type-checks its shape (rule 5 below), but no pipeline stage reads it yet. +Locating, extracting, parsing, and linting are unaffected by `required` or +`pattern`: a requirement without an id, or with an id that does not match +`pattern`, produces no diagnostic today. Enforcing `idFormat` requires a +future registry diagnostic code; until that code exists, this field only +documents intent. + +## Validation rules + +1. Unknown top-level keys, or unknown keys inside `dialect`, `locator`, or + `idFormat`, are a validation error. +2. `name` must be one of the five closed values. `notation` must be `'ears'`. +3. Enumerated fields (`keywordCase`, `commaAfterLeadingClause`, `codeFences`, + severity values, `LocatorRule.kind`) must be one of their listed values. +4. `severity` keys must be resolvable registry IDs (current `EARS-E###` / + `EARS-W###` IDs; deprecated aliases are not accepted as override keys). +5. Regex fields (`LocatorRule.headingPattern`, `LocatorRule.underHeading`, and + `idFormat.pattern`) must be strings that compile as JavaScript regular + expressions. `LocatorRule.blockPrefix` is a literal string, not a regex. +6. A malformed profile is an environment failure: exit `2` (see the facade + contract), never a lint result. + +## Built-in profiles (intended settings) + +These are the intended settings at a summary level. Exact `severity` maps and +the concrete `LocatorRule` sets are finalized by the profile schema agent +(plan Agent 03) and the host profile agents (plan Agents 09-12), with fixtures +on both sides of every line, and by the profile detail in +`EARSYNTAX-CLI-FACADE-ALPHA-0.md`. `strict` is the default when no `--profile` +is passed. + +| Field | `strict` | `ears-x` | `kiro` | `speckit` | `openspec` | +| ------------------------- | ----------------- | ----------------- | ---------------- | -------------- | -------------- | +| `keywordCase` | strict | strict | case-insensitive | strict | strict | +| `allowLiteralSystemName` | `[]` | `[]` | `["THE SYSTEM"]` | `[]` | `[]` | +| `commaAfterLeadingClause` | required | required | optional | required | required | +| `allowStoryWrapper` | false | false | true | false | false | +| `allowFrameMetadata` | false | true | false | false | false | +| `allowProhibition` | false | true | false | false | false | +| `locator.documentKinds` | `['ears','text']` | `['ears','text']` | `['markdown']` | `['markdown']` | `['markdown']` | +| `locator.codeFences` | ignore | ignore | ignore | ignore | ignore | +| `idFormat.required` | false | false | false | false | false | +| `idFormat.pattern` | (none) | `^REQ-\d+$` | (none) | (none) | (none) | + +Locator targets, per profile: + +- `strict`, `ears-x`: the trivial `every-line` rule over `.ears` and plain + text. No Markdown extraction. +- `kiro`: bullet and numbered list items under `#### Acceptance Criteria` + headings in `requirements.md`; user-story wrapper lines skipped as frame + content; guards against prose false positives. +- `speckit`: requirements sections in `specs/**/spec.md`; design and background + prose excluded; narrative that opens with an EARS keyword but parses to + nothing must not become a candidate. +- `openspec`: `### Requirement:` bodies and `#### Scenario:` blocks inside + `openspec/specs/**` and `openspec/changes/**`; delta-aware path conventions; + non-requirement prose skipped. + +Relationship between profiles: + +- `ears-x` is a strict superset. Every `strict`-valid requirement is + `ears-x`-valid unchanged; `ears-x` only adds tolerances (frame metadata, + `[source:]` tags, prohibition). +- `kiro` relaxes casing, literal system name, and the leading comma, and skips + story wrappers. Its severity map is tuned so Kiro house style validates clean + under `kiro` while the same document fails under `strict`. +- `speckit` and `openspec` are near-strict dialects that differ from `strict` + mainly in their Markdown locator, not their grammar. + +## Non-goals + +- Profiles do not carry workspace, manifest, acceptance, or lifecycle + configuration. No `.earsyntax/` concept appears in a profile. +- Profiles never trigger an LLM call. Locating, extracting, parsing, and + linting under any profile are pure deterministic code. +- Profile descriptions shown by the `profiles` command are rendered from this + data, not hand-written, so they cannot drift from the settings above. diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..aec39e5 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,118 @@ +# Diagnostics reference + +Every finding `@earsyntax/core` reports is a `Diagnostic`: + +```ts +interface Diagnostic { + code: DiagnosticCode; // one of the codes registered below + severity: 'error' | 'warning' | 'info'; + message: string; // one factual sentence + span?: Span; // half-open [start, end) offsets, when known +} +``` + +The parser and catalog matcher discover raw findings (a code and, when known, a span). The diagnostics module assigns each finding its severity and message, sorts the set into a stable order, and derives the result's `valid` flag. + +## Severity by mode + +Severity depends on the finding's code and the active mode. + +| Class | `strict` | `guided` | +| ---------------------------------------- | --------- | --------- | +| Structural shell and expression failures | `error` | `warning` | +| Unresolved or ambiguous `system` term | `error` | `warning` | +| All other codes | `warning` | `warning` | + +`strict` treats structural defects and unknown or ambiguous systems as errors. `guided` downgrades those to warnings, on the basis that the parser can usually still recover a partial AST. Every other code (non-system catalog terms, expression term warnings, and the `lint.*` codes) is always a warning, regardless of mode. + +`valid` is `false` when any diagnostic has severity `error`, and `true` otherwise. `warning` and `info` never affect validity. The toolkit emits no `info` diagnostics in v1; the level is reserved for future use. + +## Public ids and deprecated aliases + +Every diagnostic carries a stable public id: `EARS-E###` for defaults in the error band, `EARS-W###` for defaults in the warning band. The registry that owns these ids, their metadata, and the alias mapping is `packages/core/src/registry.ts`; the frozen migration table lives in `docs/refactor/host-native-facade.md` and `fixtures/diagnostics/migration-table.json`. + +The dotted codes below (`ears.no_match`, `expr.unknown_term`, and so on) are the raw codes the parser and catalog matcher still emit internally, and they remain resolvable forever as deprecated aliases: `earsyntax explain ears.missing_shall` resolves to `EARS-E007`. New tools should key on the `EARS-*` id. The mapping is append-only: ids are never renumbered, reused, or deleted. + +The `E`/`W` band is the default severity. A profile severity override or `--strict` can change the effective severity a diagnostic carries in a findings result without changing its id (see `docs/contracts/findings.md`). The `strict`/`guided` columns below describe the legacy core mode model; the `strict` column is the default band the id inherits. + +## Code registry + +Codes are grouped by prefix. The `ID` column is the primary public identifier; the `Deprecated alias` column is the old dotted code. "strict"/"guided" columns show the legacy per-mode severity. + +### `ears.*` shell structure + +These report defects in the outer EARS sentence shape. All are mode-dependent. `EARS-E014`, `EARS-E015`, and `EARS-E016` were introduced by the host-native grammar work and have no legacy code they migrate from. A relaxing dialect suppresses them: the `kiro` profile relaxes keyword case (`EARS-E014`) and the leading comma (`EARS-E015`), and the `ears-x` profile legalizes prohibition (`EARS-E016`). + +| ID | Deprecated alias | Meaning | strict | guided | Example trigger | +| ----------- | ------------------------------ | ----------------------------------------------------------- | ------- | --------- | ------------------------------------------------------------------- | +| `EARS-E010` | `ears.no_match` | The text does not match any supported EARS shell pattern. | `error` | `warning` | `quick brown fox` | +| `EARS-E005` | `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | `error` | `warning` | `When the timer fires, while idle, the system shall reset.` | +| `EARS-E008` | `ears.missing_system` | The system name before `shall` is absent or empty. | `error` | `warning` | `When the timer fires, shall reset.` | +| `EARS-E007` | `ears.missing_shall` | The requirement has no single `shall` response boundary. | `error` | `warning` | `The system resets the timer.` | +| `EARS-E009` | `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | `error` | `warning` | `The system shall reset and shall log the event.` | +| `EARS-E006` | `ears.invalid_if_then_form` | An `If` clause is missing its required `then` boundary. | `error` | `warning` | `If the signature is invalid, the system shall reject the webhook.` | +| `EARS-E003` | `ears.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | `error` | `warning` | `When , the system shall reset.` | +| `EARS-E004` | `ears.empty_response` | The response after `shall` is empty. | `error` | `warning` | `The system shall .` | +| `EARS-E014` | `ears.keyword_case` | A keyword violates strict canonical casing. | `error` | `warning` | `when the timer fires, the system Shall reset.` | +| `EARS-E015` | `ears.missing_leading_comma` | A leading clause is not comma-delimited where required. | `error` | `warning` | `When the timer fires the system shall reset.` | +| `EARS-E016` | `ears.prohibition_not_allowed` | `shall not` is used where the dialect forbids prohibition. | `error` | `warning` | `The system shall not log the payment token.` | + +### `expr.*` clause expressions + +These report defects inside the boolean-like body of a clause. The three structural failures are mode-dependent; the term and precedence codes are always warnings. + +| ID | Deprecated alias | Meaning | strict | guided | Example trigger | +| ----------- | ---------------------------------- | ------------------------------------------------------ | --------- | --------- | ---------------------------------------------------- | +| `EARS-E013` | `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | `error` | `warning` | `When (A and B, the system shall reset.` | +| `EARS-E012` | `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | `error` | `warning` | `While A or or B, the system shall reset.` | +| `EARS-E011` | `expr.empty_subexpression` | A grouped expression or operator operand is empty. | `error` | `warning` | `While A and (), the system shall reset.` | +| `EARS-W010` | `expr.operator_precedence_warning` | A mixed `and`/`or` expression may need parentheses. | `warning` | `warning` | `While A and B or C, the system shall reset.` | +| `EARS-W011` | `expr.unknown_term` | A clause term matches no catalog entry for its role. | `warning` | `warning` | A `When` term absent from the catalog. | +| `EARS-W008` | `expr.ambiguous_term` | A clause term matches more than one catalog entry. | `warning` | `warning` | A term whose name collides across catalog groups. | +| `EARS-W009` | `expr.mixed_unresolved_terms` | One clause mixes resolved and unresolved terms. | `warning` | `warning` | `While A and B` where `A` resolves and `B` does not. | + +### `catalog.*` term matching + +The `system` role is mode-dependent (an unknown or ambiguous system is a hard error in strict mode). The `state`, `event`, and `feature` roles, and the coverage check, are always warnings. + +| ID | Deprecated alias | Meaning | strict | guided | Example trigger | +| ----------- | ---------------------------- | ------------------------------------------------------------- | --------- | --------- | -------------------------------------------------------------- | +| `EARS-E002` | `catalog.system_unresolved` | The system name matches no known system. | `error` | `warning` | `The invoicing engine shall ...` when only `BFF` is cataloged. | +| `EARS-E001` | `catalog.system_ambiguous` | The system name matches more than one known system. | `error` | `warning` | A system name that two catalog entries share. | +| `EARS-W006` | `catalog.state_unresolved` | A state term matches no known state. | `warning` | `warning` | `While the queue is draining, ...` with no such state. | +| `EARS-W005` | `catalog.state_ambiguous` | A state term matches more than one known state. | `warning` | `warning` | A state name two entries share. | +| `EARS-W002` | `catalog.event_unresolved` | An event term matches no known event. | `warning` | `warning` | `When a refund is requested, ...` with no such event. | +| `EARS-W001` | `catalog.event_ambiguous` | An event term matches more than one known event. | `warning` | `warning` | An event name two entries share. | +| `EARS-W004` | `catalog.feature_unresolved` | A feature term matches no known feature. | `warning` | `warning` | `Where retries are enabled, ...` with no such feature. | +| `EARS-W003` | `catalog.feature_ambiguous` | A feature term matches more than one known feature. | `warning` | `warning` | A feature name two entries share. | +| `EARS-W007` | `catalog.term_unreferenced` | A cataloged term is never referenced by any requirement text. | `warning` | `warning` | A cataloged event no requirement mentions (coverage check). | + +### `lint.*` style advice + +Stylistic findings that never affect validity. All are always warnings. + +| ID | Deprecated alias | Meaning | strict | guided | Example trigger | +| ----------- | ---------------------------- | --------------------------------------------------------- | --------- | --------- | -------------------------------------------------------- | +| `EARS-W013` | `lint.multiple_responses` | The response holds several semicolon-joined responses. | `warning` | `warning` | `The system shall log the event; notify the operator.` | +| `EARS-W016` | `lint.vague_response` | The response contains a configured vague term. | `warning` | `warning` | `The system shall respond as needed.` | +| `EARS-W015` | `lint.unparsed_tail` | Text remains after the parsed requirement. | `warning` | `warning` | Trailing tokens the expression parser could not consume. | +| `EARS-W012` | `lint.alias_used` | A catalog alias matched; the canonical name is preferred. | `warning` | `warning` | `db` matching a `Postgres` entry via an alias. | +| `EARS-W014` | `lint.suspicious_text_shape` | The sentence shape is likely accidental or malformed. | `warning` | `warning` | Legacy guided mode only; strict reports `EARS-E010`. | + +## Ordering + +`sortDiagnostics` returns a stably sorted copy without mutating its input. The order is: + +1. Span start, ascending. Diagnostics without a span sort last. +2. Span end, ascending. +3. Code, by code-unit order. +4. Message, by code-unit order. +5. Severity, by code-unit order. + +The order is fully determined by the diagnostics themselves, so repeated runs on the same requirement always produce the same sequence. This refines the `ears-lint-go` reference, which orders only by span start before its code, message, and severity fallbacks; adding span end as a secondary key removes the last dependence on insertion order. + +## Deduplication + +A parser can report the same finding more than once for one input (for example `ears.missing_shall` from a pre-check and again at parse time, with the same span). `dedupeDiagnostics` removes exact duplicates, keyed by code, span start and end, severity, and message. Because the message carries any interpolated term or clause, two findings that share a code and span but describe different terms stay distinct. + +The helper keeps the first occurrence and preserves relative order, so its output is deterministic for a given input. It composes with `sortDiagnostics` in either order; the sorted, deduplicated set is identical either way. diff --git a/docs/facade-api.md b/docs/facade-api.md new file mode 100644 index 0000000..7236b3d --- /dev/null +++ b/docs/facade-api.md @@ -0,0 +1,29 @@ +# Facade JSON API + +The JSON contract for every `earsyntax` command is documented alongside its usage +in the [CLI reference](cli.md), so there is one page per command with its flags, +exit codes, pretty output, and `--json` envelope together. This page is a pointer +to avoid a second copy that could drift. + +Start here: + +- [The JSON envelope](cli.md#the-json-envelope): the base shape every `--json` + response shares, and the difference between facade `diagnostics` and lint + `findings`. +- [Exit codes](cli.md#exit-codes): `0`, `1`, `2` and what each means. +- Per-command payloads: [`validate`](cli.md#validate-paths-), + [`extract`](cli.md#extract-paths-), + [`instructions`](cli.md#instructions-authorconvertrepairreview---file-path---from-source), + [`explain`](cli.md#explain-diagnostic-id), [`profiles`](cli.md#profiles), + [`doctor`](cli.md#doctor), [`init`](cli.md#init---agent-agents---host-hosts), + and [`version`](cli.md#version---features). + +Normative contracts that the CLI projects: + +- [`docs/contracts/findings.md`](contracts/findings.md): the frozen Findings + model returned in a `validate` response and embedded in `instructions +repair`/`review`. +- [`docs/refactor/host-native-facade.md`](refactor/host-native-facade.md): the + frozen command surface, global flags, and envelope definition. +- [`docs/diagnostics.md`](diagnostics.md): the diagnostic id registry the + findings reference. diff --git a/docs/grammar.md b/docs/grammar.md new file mode 100644 index 0000000..4c99652 --- /dev/null +++ b/docs/grammar.md @@ -0,0 +1,185 @@ +# EARS Grammar Matrix + +This document is the implementation grammar for `@earsyntax/core`. It converts the canonical EARS references into a precise set of rules the shell parser and expression parser implement. It is derived from the specification, not from any existing implementation. Where a rule can fail, the table names the exact [diagnostic code](../packages/core/src/types.ts) the parser emits. + +The fixtures under `fixtures/valid/` and `fixtures/invalid/` are the executable form of this document. Every rule here has at least one fixture; every fixture asserts a rule here. + +Sources: + +- Canonical EARS patterns: https://alistairmavin.com/ears/ +- AST and diagnostic shapes: `packages/core/src/types.ts` +- Fixture format: `fixtures/schema.md` + +## Shell patterns + +A requirement is a single sentence built from zero or more optional shell clauses followed by a mandatory system-and-response tail. The parser classifies the sentence into one of six patterns. + +| Pattern | Template | Keyword | AST clause field | Example | +| -------------------- | ------------------------------------------------- | ----------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `ubiquitous` | `The shall ` | none | none | The billing service shall verify the HMAC signature. | +| `state-driven` | `While , the shall ` | `While` | `preconditions` | While the payment provider is unavailable, the billing service shall queue retryable events. | +| `event-driven` | `When , the shall ` | `When` | `trigger` | When a payment webhook is received, the billing service shall verify the HMAC signature. | +| `optional-feature` | `Where , the shall ` | `Where` | `feature` | Where dunning management is enabled, the billing service shall retry declined charges. | +| `unwanted-behaviour` | `If , then the shall ` | `If`/`then` | `unwanted` | If the HMAC signature is invalid, then the billing service shall reject the webhook. | +| `complex` | any supported combination of more than one clause | mixed | multiple | While the payment provider is available, when a payment webhook is received, the billing service shall verify the HMAC signature. | + +Classification rule: a requirement with exactly one shell clause takes that clause's pattern. A requirement with more than one shell clause is `complex`. A requirement with no shell clause is `ubiquitous`. + +The keyword-to-field mapping is one to one: `While` fills `preconditions`, `When` fills `trigger`, `Where` fills `feature`, and `If` fills `unwanted`. In a `complex` requirement each present clause fills its own field. + +## Clause order + +The only accepted order of shell clauses is: + +```text +While* -> Where* -> When* -> If* -> the shall +``` + +A clause that appears out of this order is a structural error. + +| Rule | Result | Diagnostic | +| ------------------------------------------------ | ------- | --------------------------- | +| Clauses appear in the accepted order | parses | none | +| A `While` clause follows a `When` clause | invalid | `ears.invalid_clause_order` | +| A `Where` clause follows a `When` or `If` clause | invalid | `ears.invalid_clause_order` | +| Any clause follows an `If` clause | invalid | `ears.invalid_clause_order` | + +`When` followed by `If` is a valid order and produces a `complex` requirement (fixture `valid/complex-when-if.json`). `When` followed by `While` is not (fixture `invalid/invalid-clause-order.json`). + +## System and response tail + +Every requirement must end with a system and a `shall` response. The subject article `the` is stripped from the system: `the billing service shall ...` yields `system.raw = "billing service"`. + +| Rule | Result | Diagnostic | +| ------------------------------------------------------------- | ------- | --------------------- | +| Exactly one shell-level `shall` separates system and response | parses | none | +| No `shall` present | invalid | `ears.missing_shall` | +| More than one shell-level `shall` | invalid | `ears.multiple_shall` | +| System segment before `shall` is empty | invalid | `ears.missing_system` | +| Response segment after `shall` is empty | invalid | `ears.empty_response` | +| Sentence matches no shell shape at all | invalid | `ears.no_match` | + +The `If ... then ...` form is special: the `then` boundary is required. An `If` clause with no `then` before the system is `ears.invalid_if_then_form` (fixture `invalid/if-without-then.json`), not `ears.invalid_clause_order`. + +### Responses + +The response is the text after `shall`. Responses are split on semicolons into `ast.responses` in source order. A response containing more than one semicolon-separated phrase also raises a warning. + +| Rule | Result | Diagnostic | Severity | +| ----------------------------------------- | ------ | ------------------------- | -------- | +| Single response phrase | parses | none | - | +| Two or more semicolon-separated phrases | parses | `lint.multiple_responses` | warning | +| Response contains a configured vague term | parses | `lint.vague_response` | warning | +| Text remains after the parsed requirement | parses | `lint.unparsed_tail` | warning | + +Default vague terms are `appropriate`, `sufficient`, and `as needed`, configurable through `Options.vagueTerms`. + +## Clause expression grammar + +Inside `While`, `Where`, `When`, and `If` clauses the body is a boolean-like expression over free-text terms. + +```text +expr := or +or := and ( "or" and )* +and := not ( "and" not )* +not := "not" not | atom +atom := "(" expr ")" | term +term := one or more words that are not an operator or parenthesis +``` + +Precedence, from tightest to loosest: + +```text +not > and > or +``` + +So `a and b or c` parses as `(a and b) or c`, and `not a and b` parses as `(not a) and b`. Parentheses override precedence: `(a or b) and c`. + +The AST node kinds are `term`, `and`, `or`, `not`, `group`, and `free-text`, defined in `packages/core/src/types.ts`. A clause body that cannot be parsed as an expression is kept verbatim as `free-text`. + +| Rule | Result | Diagnostic | Severity | +| ------------------------------------------------------ | ------- | ---------------------------------- | -------- | +| Well-formed expression | parses | none | - | +| Mixed `and` and `or` at the same level, no parentheses | parses | `expr.operator_precedence_warning` | warning | +| Unbalanced parentheses | invalid | `expr.unbalanced_parentheses` | error | +| Dangling or doubled operator (`a and`, `a or or b`) | invalid | `expr.invalid_operator_sequence` | error | +| Empty group or empty operand (`()`, `a and ()`) | invalid | `expr.empty_subexpression` | error | + +The precedence warning is not an error: the expression still parses (`(a and b) or c`), the requirement stays valid, and the warning only suggests adding parentheses (fixture `valid/expr-precedence-warning.json`). + +## Comma handling + +A comma normally ends a shell clause. The `commaAsAnd` option changes how commas inside a clause body are read. + +| Option | Behavior | Fixture | +| ----------------------------- | -------------------------------------------------------------------------------- | --------------------------------------- | +| `commaAsAnd: false` (default) | A comma ends the current shell clause. Commas are not conjunctions. | `valid/expr-and-keyword-comma-off.json` | +| `commaAsAnd: true` | Commas inside a clause body may be read as `and` where the split is unambiguous. | `valid/expr-comma-as-and.json` | + +The explicit `and` keyword works under both settings; it does not depend on `commaAsAnd`. + +## Case-insensitivity + +Shell keywords and the `shall` boundary are matched case-insensitively. The parser accepts `while`, `WHILE`, and `While` equally, and likewise for `when`, `where`, `if`, `then`, `the`, and `shall`. Term text keeps its original casing. + +| Input keyword casing | Result | Fixture | +| ---------------------------------------- | ------ | ------------------------------------------- | +| lowercase (`when ...`) | parses | `valid/case-insensitive-when-lower.json` | +| uppercase (`WHEN ... THE ... SHALL ...`) | parses | `valid/case-insensitive-when-upper.json` | +| uppercase `IF ... THEN ...` | parses | `valid/case-insensitive-if-then-upper.json` | +| mixed (`While ... SHALL ...`) | parses | `valid/case-insensitive-while-mixed.json` | + +Parsing never requires an uppercase house style. A formatter may still choose one. + +## Catalog matching + +When a `Catalog` is supplied, the system term and clause terms are matched against it deterministically: exact canonical name, then exact alias, then ambiguous (more than one entry matches), then unresolved (no entry matches). No fuzzy or semantic matching is performed. Full matching behavior is owned by the catalog agent; the codes below are the ones this corpus pins. + +| Rule | Result | Diagnostic | Severity | +| --------------------------------------------- | ------- | --------------------------- | -------- | +| System matched by canonical name | parses | none | - | +| System matched by alias | parses | `lint.alias_used` | warning | +| System matches no catalog entry (strict mode) | invalid | `catalog.system_unresolved` | error | +| System matches more than one catalog entry | invalid | `catalog.system_ambiguous` | error | + +Per the severity rules, an unresolved or ambiguous system is an error in `strict` mode. The `guided` mode may downgrade recoverable structural failures to warnings. + +## Edge-case matrix + +Every row is backed by a fixture in `fixtures/invalid/` (errors) or `fixtures/valid/` (warnings). Fixtures assert the multiset of `(code, severity)` pairs exactly, so a parser that emits an extra or missing diagnostic fails the fixture. + +| Edge case | Example | Diagnostic | Fixture | +| ----------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------- | +| No `shall` | The billing service verifies the HMAC signature. | `ears.missing_shall` | `invalid/missing-shall.json` | +| Doubled `shall` | ... the billing service shall shall queue retryable events. | `ears.multiple_shall` | `invalid/multiple-shall.json` | +| Missing system | When a payment webhook is received, shall verify the HMAC signature. | `ears.missing_system` | `invalid/missing-system.json` | +| Empty response | The billing service shall . | `ears.empty_response` | `invalid/empty-response.json` | +| Empty clause | When , the billing service shall verify the HMAC signature. | `ears.empty_clause` | `invalid/empty-clause.json` | +| `If` without `then` | If the HMAC signature is invalid, the billing service shall reject the webhook. | `ears.invalid_if_then_form` | `invalid/if-without-then.json` | +| Wrong clause order | When a payment webhook is received, while the payment provider is available, ... | `ears.invalid_clause_order` | `invalid/invalid-clause-order.json` | +| Not an EARS sentence | Payment webhooks are important for billing accuracy. | `ears.no_match` | `invalid/no-match.json` | +| Unbalanced parentheses | While (the payment provider is available and the retry queue is not full, ... | `expr.unbalanced_parentheses` | `invalid/unbalanced-parentheses.json` | +| Doubled operator | When a payment webhook is received or or a refund is requested, ... | `expr.invalid_operator_sequence` | `invalid/invalid-operator-sequence.json` | +| Dangling operator before clause end | While the payment provider is available and, when a payment webhook is received, ... | `expr.invalid_operator_sequence` | `invalid/invalid-operator-sequence-trailing-and.json` | +| Empty subexpression | When a payment webhook is received and (), ... | `expr.empty_subexpression` | `invalid/empty-subexpression.json` | +| Unresolved system (strict) | The reconciliation engine shall verify the HMAC signature. (catalog present) | `catalog.system_unresolved` | `invalid/catalog-system-unresolved.json` | +| Ambiguous system | The BFF shall verify the HMAC signature. (two entries alias `BFF`) | `catalog.system_ambiguous` | `invalid/catalog-system-ambiguous.json` | +| Alias used (warning) | The BFF shall verify the HMAC signature. (one entry aliases `BFF`) | `lint.alias_used` | `valid/lint-alias-used.json` | +| Vague response (warning) | The billing service shall retry failed charges as needed. | `lint.vague_response` | `valid/lint-vague-response.json` | +| Multiple responses (warning) | The billing service shall verify the HMAC signature; reject invalid webhooks. | `lint.multiple_responses` | `valid/lint-multiple-responses.json` | +| Unparsed tail (warning) | The billing service shall verify the HMAC signature. Additional operator notes follow here. | `lint.unparsed_tail` | `valid/lint-unparsed-tail.json` | +| Precedence warning | When a payment webhook is received and a refund is requested or a chargeback is received, ... | `expr.operator_precedence_warning` | `valid/expr-precedence-warning.json` | + +## Validity rule + +`LintResult.valid` is derived only from severity: it is `false` when any diagnostic has severity `error`, and `true` otherwise. Warnings and info diagnostics never change validity. This is why the warning fixtures in the matrix above live under `fixtures/valid/` with `valid: true`. + +## Guided mode + +`Options.mode` defaults to `strict`. In `guided` mode the linter recovers a partial AST where it can and downgrades the recoverable structural and expression errors to `warning`, so the requirement stays `valid: true`. The downgraded codes are: + +- all `ears.*` structural codes, +- `expr.unbalanced_parentheses`, `expr.invalid_operator_sequence`, `expr.empty_subexpression`, +- `catalog.system_unresolved`, `catalog.system_ambiguous`. + +Every other code keeps its `strict`-mode severity. The `fixtures/valid/guided-*.json` set pairs a representative structural or expression fixture with `mode: "guided"` and asserts the same code at `warning` with `valid: true`. diff --git a/docs/input-formats.md b/docs/input-formats.md new file mode 100644 index 0000000..82bee37 --- /dev/null +++ b/docs/input-formats.md @@ -0,0 +1,232 @@ +# Input Formats + +`@earsyntax/extract` turns the files people actually write into requirement +candidates that `@earsyntax/core` lints. It supports four formats: `.ears`, +Markdown, YAML, and JSON. The host-native pipeline (`extractCandidates`, +`runPipeline`) is the only extraction surface this package exposes; a profile +decides which regions of a document become candidates, and every stage is a +pure function of the content string. Only the caller (the CLI) reads files. + +```ts +import { extractCandidates } from '@earsyntax/extract'; +import { BUILTIN_PROFILES } from '@earsyntax/core'; + +const md = + '- REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; +const { candidates, notices } = extractCandidates({ + files: [{ path: '.kiro/specs/checkout/requirements.md', content: md }], + profile: BUILTIN_PROFILES.kiro, +}); +// candidates[0] === { +// file: '.kiro/specs/checkout/requirements.md', +// line: 1, +// col: 3, +// text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', +// profile: 'kiro', +// locatorRuleId: 'kiro.acceptance-criteria', +// requirementId: 'REQ-001', +// } +``` + +This package locates and extracts requirements only. It never lints them or +parses EARS grammar; `runPipeline` hands the extracted text to +`@earsyntax/core` for that. + +## `.ears` + +One requirement per non-empty line. A line may start with an `ID:` prefix. +Lines whose first non-whitespace character is `#` are comments, and blank lines +are skipped. Line numbers are preserved across skipped lines. + +```text +# Billing webhook requirements +REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-002: If the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +Under the `strict` and `ears-x` profiles this is the every-line locator rule: +every non-empty, non-comment line becomes a candidate, with `col` at the line +start. + +### Metadata prefix + +Agent-generated `.ears` files may carry a source reference in the prefix. Three +forms are accepted: + +```text +REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +Under a profile whose dialect sets `allowFrameMetadata` (`ears-x`), the `ID:` +and `[source: ...]` segment stay in the candidate's `text` and `col` stays at +the line start; only `requirementId` is lifted. The linter strips the frame +prefix at parse time, so the reported position stays at the physical line and +column where the text sits. Under a profile that does not allow frame metadata +(for example `strict`), a leading `REQ-001:` is left untouched in the text and +the linter reports it. + +## Markdown + +The Markdown locator reads requirements from headings, list items, and +`blockPrefix` blocks, as the active profile's locator rules declare; see +[Host-native pipeline](#host-native-pipeline) below. Prose paragraphs and +fenced code blocks (both ` ``` ` and `~~~`) are skipped unless the profile sets +`locator.codeFences` to `include`. + +### Bullet and numbered lists + +Bullets (`-`, `*`, `+`) and numbered items (`1.`, `1)`) each become one +candidate, honoring the profile's `listMarker` and `underHeading` filters. A +markdown bold requirement label (`- **FR-001**: `) is host +formatting, not part of the EARS sentence: the locator strips both the list +marker and the `**FR-001**:` label, sets `requirementId` to `FR-001`, and puts +`col` at the first character of the sentence. + +```md +- **FR-001**: When a payment webhook is received, the billing service shall verify the HMAC signature. +- If the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +The first item extracts with `requirementId: 'FR-001'`; the second has no id. + +## YAML + +A top-level `requirements` sequence, each entry with a `text` field and an +optional `id`. + +```yaml +requirements: + - id: REQ-001 + text: When a payment webhook is received, the billing service shall verify the HMAC signature. + - text: The billing service shall retain receipts for seven years. +``` + +The first entry extracts with `requirementId: 'REQ-001'`; the second is kept +even though it has no id. Source lines are a best-effort lookup: the extractor +locates each entry's id or text in the raw document. Malformed YAML, a missing +`requirements` key, or an entry without a non-empty string `text` are reported +as a `PipelineNotice` (`extract.malformed_yaml`) rather than thrown; malformed +YAML stops extraction for that file, while a single bad entry is skipped and +the others are still returned. + +## JSON + +An object with a `requirements` array, each entry with a `text` field and an +optional `id`. + +```json +{ + "requirements": [ + { + "id": "REQ-001", + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature." + }, + { + "text": "The billing service shall retain receipts for seven years." + } + ] +} +``` + +As with YAML, missing ids are tolerated, source lines are located by searching +the raw document, and malformed JSON or the wrong top-level shape is reported +as an `extract.malformed_json` notice rather than thrown. + +## Host-native pipeline + +Two entry points cover the pipeline: + +```ts +import { extractCandidates, runPipeline } from '@earsyntax/extract'; +import { BUILTIN_PROFILES } from '@earsyntax/core'; + +// Locate candidates only (what `extract` prints). +const { candidates, notices } = extractCandidates({ + files: [{ path: '.kiro/specs/checkout/requirements.md', content }], + profile: BUILTIN_PROFILES.kiro, +}); + +// The whole pipeline: locate, extract, parse, lint, assemble findings. +const { findings, notices: pipelineNotices } = runPipeline({ + files: [{ path: '-', content, kind: 'text' }], + profile: BUILTIN_PROFILES.strict, + strict: false, +}); +``` + +A `PipelineFile` is `{ path, content, kind? }`. When `kind` is omitted it is +inferred from the path extension (`.ears`, `.md`/`.markdown`, `.yaml`/`.yml`, +`.json`, anything else `text`). Pass `path: '-'` for stdin content. + +### Candidates and positions + +Each `Candidate` is +`{ file, line, col?, text, profile, locatorRuleId, requirementId? }`. `line` and +`col` are 1-based and point at the original host document: `col` is the column of +the requirement text's first character, past any list marker or stripped bold +label. `locatorRuleId` is the id of the profile `LocatorRule` that selected the +candidate, so `extract` output can be traced back to the profile. +`requirementId` is the requirement's own id when the locator found one. + +### Document-kind gating + +For the text-family kinds (`ears`, `text`, `markdown`) a file produces +candidates only when its kind is listed in the profile's +`locator.documentKinds`. Validating a Markdown file under `strict` (which locates +over `ears`/`text`) yields no candidates rather than a parse error. YAML and +JSON are structured requirement lists: no built-in profile declares them in +`documentKinds`, so they are extracted profile-agnostically and their candidates +carry a synthetic `locatorRuleId` of `structured.yaml` or `structured.json`. The +active dialect still applies when their text is linted. + +### Never throws + +The pipeline never throws on malformed input. A bad YAML/JSON document or an +unsupported kind produces zero candidates plus a `PipelineNotice` +(`{ code, severity, message, file?, line? }`). Notices are the environment +channel, distinct from lint findings; the CLI surfaces them as facade-level +diagnostics, never inside the frozen Findings model. + +## Profiles + +A profile decides which regions of a document become requirement candidates and +which grammar tolerances apply when they are linted. The `earsyntax validate`, +`extract`, and `instructions` commands take `--profile `; it defaults to +`strict`. Run `earsyntax profiles --json` for the exact, data-rendered summary; +the table below is the same information in prose. + +| Profile | Locates | Document kinds | Notable relaxations and additions | +| ---------- | --------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `strict` | Every non-empty line. | `ears`, `text` | None. Canonical EARS only. | +| `ears-x` | Every non-empty line. | `ears`, `text` | Adds frame metadata, `shall not` prohibition, and a `^REQ-\d+$` id format. | +| `kiro` | List items under an `Acceptance Criteria` heading. | `markdown` | Relaxes keyword case, the leading comma, the literal `THE SYSTEM`, and user-story wrappers. Turns off `EARS-W011` and `EARS-W014`. | +| `speckit` | Body of sections matching `^(functional )?requirements$`. | `markdown` | None beyond the locator. | +| `openspec` | `### Requirement:` blocks and `#### Scenario:` blocks. | `markdown` | None beyond the locator. | + +### Markdown blindness under strict + +`strict` and `ears-x` declare `documentKinds` of `ears` and `text` only. A +Markdown file's kind is inferred as `markdown`, which is not in that list, so +validating a `.md` file under `strict` produces zero candidates and a clean run, +not a parse error: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile strict --json +# findings.summary.requirements === 0, ok === true +``` + +This is intended. EARS requirements inside Markdown live in host structure +(acceptance-criteria lists, requirement sections), and locating them is exactly +what the host profiles (`kiro`, `speckit`, `openspec`) do. Use `strict` for +`.ears`, plain text, and stdin; use a host profile for a host document. + +### Structured formats are profile-agnostic + +YAML and JSON are structured requirement lists, so no built-in profile declares +them in `documentKinds`. They are extracted regardless of the active profile, +and their candidates carry a synthetic `locatorRuleId` of `structured.yaml` or +`structured.json`. The active profile's dialect still applies when the extracted +text is linted. For example, a YAML file validated under `kiro` still yields its +requirement candidates and lints them with the `kiro` dialect. diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..31da669 --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,202 @@ +# Public API reference + +`@earsyntax/core` and `@earsyntax/extract` expose the deterministic parser, +linter, diagnostic registry, profile runtime, findings model, and host-native +pipeline for TypeScript users. This page enumerates the exported surface. For +worked examples of the four lint functions and the report serializers, see the +[API reference](api.md); for the extraction formats and the profile locators, see +the [input formats guide](input-formats.md). + +Every function here is deterministic: no LLM calls, no network, no file system +access, no fuzzy matching. Diagnostics are stably sorted; batch and pipeline +order is preserved. + +## `@earsyntax/core` + +### Linting + +```ts +import { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from '@earsyntax/core'; + +function lintEars(text: string, catalog?: Catalog, options?: Options): LintResult; +function lintEarsBatch( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): LintResult[]; +function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult; +function lintCatalogCoverage( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): Diagnostic[]; +``` + +`lintEars` returns a full `LintResult` (`valid`, `pattern`, `ast`, `references`, +sorted `diagnostics`). `lintEarsBatch` returns one result per input in order, each +echoing its `id`. `parseEars` returns the lighter `ParseResult` (`pattern`, `ast`, +structural `diagnostics`) with no catalog references. `lintCatalogCoverage` +reports catalog entries no requirement references, as `catalog.term_unreferenced` +warnings in strict mode. `isStoryWrapperLine(line)` reports whether a line is a +user-story wrapper the host profiles skip. + +### Dialect resolution + +```ts +import { STRICT_DIALECT, resolveDialect } from '@earsyntax/core'; +import type { ResolvedDialect } from '@earsyntax/core'; + +const STRICT_DIALECT: ResolvedDialect; // canonical EARS tolerances +function resolveDialect(options?: Options): ResolvedDialect; // merge a partial dialect over strict +``` + +`STRICT_DIALECT` is the default `lintEars`/`parseEars` apply. `resolveDialect` +merges a partial dialect over it, so the pipeline and profile layers share one +defaulting step. + +### Diagnostic registry + +```ts +import { + DIAGNOSTIC_REGISTRY, + resolveDiagnosticId, + getDiagnosticEntry, + idForCode, +} from '@earsyntax/core'; +import type { DiagnosticRegistryEntry, RegistrySeverity } from '@earsyntax/core'; + +const DIAGNOSTIC_REGISTRY: readonly DiagnosticRegistryEntry[]; +function resolveDiagnosticId(idOrAlias: string): string | undefined; // current id, or undefined +function getDiagnosticEntry(idOrAlias: string): DiagnosticRegistryEntry | undefined; +function idForCode(code: DiagnosticCode): string; // dotted code -> EARS-* id +``` + +The registry is the single source of truth for the id, alias, and default-severity +mapping. `resolveDiagnosticId` resolves current ids and deprecated dotted aliases +to the current `EARS-*` id; `idForCode` maps a legacy `DiagnosticCode` to its id. +This is the same table `earsyntax explain` and the findings layer read. + +### Profiles + +```ts +import { + validateProfile, + resolveProfile, + diffProfile, + summarizeProfiles, + BUILTIN_PROFILES, + BUILTIN_PROFILE_NAMES, + KNOWN_DIAGNOSTIC_IDS, + isKnownDiagnosticId, +} from '@earsyntax/core'; + +function validateProfile(input: unknown): ProfileValidationResult; // schema-check an untrusted profile +function resolveProfile(name: string): ResolveProfileResult; // built-in profile by name +function diffProfile(profile: Profile): ProfileDiff; // what one profile relaxes/adds +function summarizeProfiles(): ProfileDiff[]; // the `profiles` command data +``` + +`BUILTIN_PROFILES` is the record of built-in `Profile` objects (`strict`, +`ears-x`, `kiro`, `speckit`, `openspec`); `BUILTIN_PROFILE_NAMES` is their names +in order. `KNOWN_DIAGNOSTIC_IDS` and `isKnownDiagnosticId` gate a profile's +`severityOverrides` keys against the registry. Exported profile types: +`Profile`, `ProfileName`, `ProfileDialect`, `ProfileLocator`, `ProfileIdFormat`, +`LocatorRule`, `LocatorRuleKind`, `ListMarker`, `KeywordCase`, +`CommaAfterLeadingClause`, `CodeFences`, `SeverityLevel`, `ProfileValidationError`, +`ProfileValidationErrorCode`, `ProfileValidationResult`, `ResolveProfileResult`, +`UnknownProfileError`, `ProfileDiff`. + +### Findings + +```ts +import { toFindings, defaultSeverityForId } from '@earsyntax/core'; + +function toFindings(input: FindingsInput, options?: ToFindingsOptions): Findings; +function defaultSeverityForId(id: string): FindingsSeverity; +``` + +`toFindings` assembles the frozen Findings model (the shape `earsyntax validate +--json` returns) from lint results, applying `--strict` and profile severity +overrides. Exported findings types: `Findings`, `FindingsDiagnostic`, +`FindingsInput`, `FindingsInputFile`, `FindingsInputItem`, `FindingsSeverity`, +`FindingsSummary`, `SeverityOverride`, `SeverityOverrides`, `ToFindingsOptions`. +The model is specified in [`docs/contracts/findings.md`](contracts/findings.md). + +### Pipeline findings assembly + +```ts +import { candidatesToFindings } from '@earsyntax/core'; +import type { + Candidate, + CandidateFile, + PipelineNotice, + LintCandidatesOptions, +} from '@earsyntax/core'; + +function candidatesToFindings( + files: readonly CandidateFile[], + profile: Profile, + options?: LintCandidatesOptions, +): Findings; +``` + +`candidatesToFindings` is the lint-and-assemble stage: it takes located candidates +and a profile and returns Findings. The locate and extract stages live in +`@earsyntax/extract`; `runPipeline` composes all of them. + +## `@earsyntax/extract` + +The host-native pipeline is the only extraction surface this package exposes. +Every stage is pure over the content strings the caller supplies; only the +caller (the CLI) reads files. The formats are documented in the +[input formats guide](input-formats.md). + +### Host-native pipeline + +```ts +import { extractCandidates, runPipeline, inferKind } from '@earsyntax/extract'; +import type { + DocumentKind, + PipelineFile, + ExtractCandidatesInput, + ExtractCandidatesResult, + RunPipelineInput, + RunPipelineResult, +} from '@earsyntax/extract'; + +function extractCandidates(input: ExtractCandidatesInput): ExtractCandidatesResult; // locate only +function runPipeline(input: RunPipelineInput): RunPipelineResult; // locate + lint + findings +function inferKind(path: string): DocumentKind; // path -> document kind +``` + +`extractCandidates` locates the requirement candidates a profile selects (what +`earsyntax extract` prints). `runPipeline` runs the whole chain (locate, extract, +parse, lint, assemble findings) and returns `{ findings, notices }` (what +`earsyntax validate` returns). `Candidate` and `PipelineNotice` are re-exported +from core so callers need no separate import. + +## Core types + +All shapes are defined and documented in +[`packages/core/src/types.ts`](../packages/core/src/types.ts) and re-exported from +`@earsyntax/core`. The load-bearing ones: + +| Type | Role | +| ------------------ | --------------------------------------------------------------------------- | +| `Mode` | `'strict' \| 'guided'` linting strictness. | +| `Pattern` | The classified EARS shell pattern. | +| `Options` | `mode`, `commaAsAnd`, `vagueTerms`. | +| `RequirementInput` | `{ id?, text, source? }` batch input item. | +| `SourceLocation` | `{ file?, line?, column? }` origin of a requirement. | +| `LintResult` | Full lint output: `valid`, `pattern`, `ast`, `references`, `diagnostics`. | +| `ParseResult` | Parse-only output: `pattern`, `ast`, `diagnostics`. | +| `EarsAst` | Parsed AST with optional `preconditions`, `trigger`, `feature`, `unwanted`. | +| `ClauseExpr` | Discriminated union: `term`, `and`, `or`, `not`, `group`, `free-text`. | +| `TermMatch` | Result of matching one term against the catalog. | +| `ReferenceMatch` | A catalog reference found in a requirement, with clause and span. | +| `Diagnostic` | `{ code, severity, message, span? }` with `code` typed as `DiagnosticCode`. | +| `DiagnosticCode` | The frozen, append-only registry of dotted diagnostic codes. | +| `Catalog` | Grouped catalog of known domain terms. | +| `CatalogEntry` | `{ id, name, aliases? }`. | +| `CatalogRef` | `{ group, id, name }` pointer to a matched entry. | +| `Span` | `{ start, end }` half-open source offsets. | diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..6a88054 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,209 @@ +# Quickstart + +This page takes you from zero to a clean `earsyntax validate` two ways: a +guest-mode check that needs no setup, and the host-native loop that validates and +repairs requirements inside a Kiro spec. Every command and its output below was +captured from a real run. + +You need Node 22 or newer. No global install is required; `npx` fetches the CLI on +first use. + +## Guest mode: validate a requirement with zero setup + +You do not need a project, a config file, or an install to validate EARS. Pipe a +line into `validate -` and the CLI reads stdin as plain text: + +```bash +printf 'The billing service shall verify the HMAC signature of every incoming webhook.\n' \ + | npx @earsyntax/cli validate - --profile strict +``` + +```text +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +The exit code is `0`. Now feed it a broken requirement, an `If` clause with no +`then`: + +```bash +printf 'If the HMAC signature is invalid, the billing service shall reject the webhook.\n' \ + | npx @earsyntax/cli validate - --profile strict +``` + +```text +-:1:1 EARS-E006 error The 'If' clause is missing the required 'then' boundary. +0/1 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +The line format is `path:line:col id severity message`; here `-` is stdin. The +exit code is `1`, the signal CI branches on: `0` means every requirement is clean, +`1` means at least one error diagnostic remains. Run `earsyntax explain EARS-E006` +for the rationale and a corrected example. + +The same guest check works on a file: + +```bash +npx @earsyntax/cli validate requirements.ears --profile strict +``` + +## Host-native loop: validate and repair a Kiro spec + +The guest check validates one line. The host-native loop is the fuller path: point +`earsyntax` at the specification files a team already keeps, let it locate the EARS +requirements inside them, and repair what fails. The example below uses a Kiro +`requirements.md`, but the shape is the same for Spec Kit and OpenSpec with their +own profiles. + +### 1. Detect what is in the repo + +`doctor` reads the working directory and reports the hosts and agents it finds, +with the exact commands to run next. It never writes. + +```bash +earsyntax doctor +``` + +```text +Repo: /repo + +Hosts: + kiro .kiro/specs/ (profile kiro) + +Recommended commands: + earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro + earsyntax init --agent claude --host kiro +``` + +### 2. Install integration files + +`init` renders thin wrapper files that point coding agents and host hooks back at +the CLI. It does not create a workspace or edit any spec. + +```bash +earsyntax init --agent claude --host kiro +``` + +It writes files such as `.claude/commands/earsyntax-repair.md` and +`.kiro/steering/earsyntax.md`. Running it again produces no diff; see +[init in the CLI reference](cli.md#init---agent-agents---host-hosts) for the full +`written`/`updated`/`skipped` report. + +### 3. See what the profile locates + +Before validating, `extract` shows exactly which lines the `kiro` profile treats +as requirements. This is the debugging surface when a file does not validate the +way you expect. + +```bash +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +```json +{ + "version": "0.0.1-alpha.0", + "command": "extract", + "ok": true, + "summary": { "files": 1, "candidates": 3 }, + "candidates": [ + { + "file": ".kiro/specs/checkout/requirements.md", + "line": 9, + "col": 4, + "text": "WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + } + ] +} +``` + +The `kiro` profile located the acceptance-criteria list items and ignored the +heading, the user story, and the surrounding prose. + +### 4. Validate + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +For a spec whose second criterion is written `IF the signature is invalid THE +SYSTEM SHALL reject the webhook` (no `then`), validation reports: + +```text +.kiro/specs/checkout/requirements.md:10:4 EARS-E006 error The 'If' clause is missing the required 'then' boundary. +.kiro/specs/checkout/requirements.md:10:4 EARS-E008 error The requirement is missing the system name before 'shall'. +2/3 valid across 1 file(s), 2 error(s), 0 warning(s) +``` + +Exit code `1`. The `kiro` profile relaxes keyword case and the literal `THE +SYSTEM`, so those are accepted; the missing `then` is not, because it breaks the +unwanted-behaviour form. + +### 5. Get the repair rules + +`instructions repair` returns the deterministic rules for fixing exactly the +reported findings, keyed by id. It reads the file and returns rules; it never +edits. + +```bash +earsyntax instructions repair --file ".kiro/specs/checkout/requirements.md" --profile kiro --json +``` + +The `rules` array includes one entry per reported id: + +```json +{ + "mode": "repair", + "rules": [ + "Change only what the reported findings justify; leave passing requirements untouched.", + "EARS-E006: Add the missing then: If , then the shall .", + "EARS-E008: Insert the system name before shall: the shall .", + "Edit only the host file, in place, and preserve the surrounding document structure." + ], + "next": [ + { + "command": "earsyntax validate .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Validate the host file after editing and repeat until no error-severity finding remains.", + "forAgent": true + } + ] +} +``` + +### 6. Edit the host file and re-validate + +Apply the fix in the spec. Adding `, then` to the second criterion resolves both +findings at once, because the corrected line parses as a well-formed +unwanted-behaviour requirement: + +```diff +-2. IF the signature is invalid THE SYSTEM SHALL reject the webhook ++2. IF the signature is invalid, THEN THE SYSTEM SHALL reject the webhook +``` + +Validate again: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +```text +3/3 valid across 1 file(s), 0 error(s), 0 warning(s) +``` + +Exit code `0`. That is the loop: `validate`, read `instructions repair`, edit the +host file, re-validate until clean. A coding agent runs the same steps by +following each response's `next` action. The CLI never approves or merges; human +review stays in the host workflow (pull request, Kiro, Spec Kit, or OpenSpec +review). + +## Next steps + +- [Author EARS by hand](authoring-ears.md): the six patterns, clause order, and + the diagnostics you are most likely to hit. +- [The agentic loop](agentic-loop.md): the full validate-and-repair loop and how + the CLI, agent, and human stay separate. +- [CLI reference](cli.md): every command, its flags, exit codes, and JSON + envelope. +- [Profiles and input formats](input-formats.md): what each profile locates in a + host document. diff --git a/docs/refactor/host-native-facade.md b/docs/refactor/host-native-facade.md new file mode 100644 index 0000000..e85ddf4 --- /dev/null +++ b/docs/refactor/host-native-facade.md @@ -0,0 +1,535 @@ +# Host-native facade (frozen) + +This is the frozen command surface for the host-native `earsyntax` CLI. It is +the target the implementation agents build against. Where it conflicts with +`EARSYNTAX-CLI-FACADE-ALPHA-0.md`, this document wins: the `.earsyntax/` +workspace, work items, manifests, acceptance, and the `new`/`list`/`status`/ +`show`/`accept`/`check` commands are removed, not preserved as a second ring. + +`earsyntax` owns deterministic EARS extraction, validation, explanation, SARIF +output, and agent instructions inside host documents (Kiro, Spec Kit, OpenSpec, +and plain `.ears`/text). It never owns a specification lifecycle. The core +never calls an LLM; Claude calls `earsyntax`, never the reverse. + +## Command surface + +```bash +earsyntax validate +earsyntax extract +earsyntax instructions --file [--from ] +earsyntax explain +earsyntax profiles +earsyntax doctor +earsyntax init --agent --host +earsyntax version --features +``` + +Eight commands. The surface is closed. No command outside this list may be +added. + +## Global flags + +| Flag | Applies to | Meaning | +| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `--profile ` | validate, extract, instructions | Select a built-in profile: `strict` (default), `ears-x`, `kiro`, `speckit`, `openspec`. | +| `--json` | all | Emit the JSON contract instead of pretty text. | +| `--sarif` | validate only | Emit SARIF 2.1.0 (projection of the findings model). Rejected on any other command with exit `2`. | +| `--strict` | validate (and repair/review instructions that embed findings) | Upgrade warnings to errors at the findings layer (see `docs/contracts/findings.md`). | +| `--quiet` | all | Suppress pretty non-essential output; JSON output is unaffected. | +| `--cwd ` | all | Directory to resolve relative paths and detect the repo root from. Defaults to the process working directory. | + +`--profile` defaults to `strict`. `--sarif` and `--json` are mutually +exclusive; passing both is a usage error (exit `2`). There is no `--work`, +`--source` (as a validate flag), `--out`, `--by`, `--artifact`, `--config`, or +`--mode`: all were workspace or lifecycle flags and are removed. + +## Exit codes (frozen) + +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | Success, or a findings run with no error-severity findings. | +| `1` | A findings run produced at least one error-severity finding. `validate` is the command that returns `1`. | +| `2` | Usage or environment failure: bad flag, unknown profile, unknown diagnostic id, missing path, unreadable file, `--sarif` on a non-validate command, `--from` on a non-author/convert mode. | + +Exit code `3` is removed. It existed only for workspace refusals (overwrite +protection over managed state, the acceptance gate, stale-source refusal). With +no managed workspace there is nothing to refuse in that sense, so the codes are +`0`, `1`, `2` only. `init` writes managed files idempotently and reports +conflicts in its JSON (`skipped`, `warnings`), not via exit `3`. + +## Response envelope + +Every `--json` response is an envelope with base fields, then command-specific +keys, then `next`: + +```ts +interface Envelope { + version: string; // installed @earsyntax/cli package version + command: string; // "validate", "instructions author", ... + ok: boolean; // no error-severity finding and no usage error + root?: string; // absolute repo root, when the command detects one + // ...command-specific keys... + diagnostics?: FacadeDiagnostic[]; // environment/usage notices, NOT lint findings + next: NextAction[]; // always present; may be empty +} + +interface FacadeDiagnostic { + code: string; // "cli.unknown_profile", "validate.missing_file", ... + severity: 'error' | 'warning'; + message: string; + path?: string; + line?: number; +} + +interface NextAction { + command: string; // a runnable earsyntax command + reason: string; + forAgent?: boolean; +} +``` + +`diagnostics` here is the facade-level channel for usage and environment +problems, distinct from the lint findings that live in a command's `findings` +key. Lint results are never placed in `diagnostics`. Base key order: +`version`, `command`, `ok`, `root?`, command keys, `diagnostics?`, `next`. + +`FacadeDiagnostic.severity` is `error` or `warning` only; the `info` level from +the old contract is dropped. + +## Per-command JSON contracts + +### `validate ` + +Locate, extract, parse, and lint EARS in the given files (or stdin `-`) under +the active profile. Returns the frozen Findings model in `findings`. Exit `0` +when `findings.ok`, else `1`. + +```json +{ + "version": "0.0.0", + "command": "validate", + "ok": false, + "findings": { + "ok": false, + "summary": { "files": 1, "requirements": 3, "valid": 2, "errors": 1, "warnings": 0 }, + "diagnostics": [ + { + "id": "EARS-E006", + "severity": "error", + "file": ".kiro/specs/checkout/requirements.md", + "line": 12, + "message": "An If clause is missing its required then boundary.", + "fix": "Write: If , then the shall .", + "requirementId": "REQ-003" + } + ] + }, + "next": [ + { + "command": "earsyntax instructions repair --file .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Get repair rules for the reported diagnostics.", + "forAgent": true + } + ] +} +``` + +The `findings` object is exactly `docs/contracts/findings.md`. `--sarif` +replaces the entire stdout with a SARIF 2.1.0 log (not the envelope); exit code +is still findings-driven. + +### `extract ` + +Print the requirement candidates the active profile's locator finds, with +source positions and the matching locator rule. Debugging surface for profiles. +Does not lint, so it carries no findings and returns exit `0` unless there is an +environment error. + +```json +{ + "version": "0.0.0", + "command": "extract", + "ok": true, + "summary": { "files": 1, "candidates": 2 }, + "candidates": [ + { + "file": ".kiro/specs/checkout/requirements.md", + "line": 10, + "col": 3, + "text": "WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item", + "requirementId": "REQ-001" + } + ], + "next": [] +} +``` + +`Candidate`: `{ file, line, col?, text, profile, locatorRuleId, requirementId? }`. +`col` and `requirementId` are omitted when unknown. Field order is fixed as +listed. + +### `instructions --file [--from ]` + +Return the deterministic rules an agent follows for one loop step against a host +file. Read-only: the CLI returns rules and (for repair/review) findings; it +never edits, never converts content semantically, never calls an LLM. + +```json +{ + "version": "0.0.0", + "command": "instructions repair", + "ok": true, + "mode": "repair", + "file": ".kiro/specs/checkout/requirements.md", + "profile": "kiro", + "locator": { + "documentKinds": ["markdown"], + "summary": "Bullet and numbered items under #### Acceptance Criteria headings in requirements.md." + }, + "dialect": { + "keywordCase": "case-insensitive", + "commaAfterLeadingClause": "optional", + "allowLiteralSystemName": ["THE SYSTEM"], + "allowStoryWrapper": true, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "rules": [ + "Change only what the diagnostics justify; leave passing requirements untouched.", + "Edit only the host file; preserve surrounding document structure." + ], + "editPolicy": { + "editableFile": ".kiro/specs/checkout/requirements.md", + "preserveStructure": true + }, + "outputPolicy": "edit-in-place", + "findings": { + "ok": false, + "summary": { "files": 1, "requirements": 3, "valid": 2, "errors": 1, "warnings": 0 }, + "diagnostics": [ + { + "id": "EARS-E006", + "severity": "error", + "file": ".kiro/specs/checkout/requirements.md", + "line": 12, + "message": "An If clause is missing its required then boundary." + } + ] + }, + "next": [ + { + "command": "earsyntax validate .kiro/specs/checkout/requirements.md --profile kiro --json", + "reason": "Re-validate after applying the repairs and repeat until clean.", + "forAgent": true + } + ] +} +``` + +Payload keys: `mode`, `file`, `profile`, optional `sourceFile` (see `--from`), +`locator`, `dialect`, `rules`, `editPolicy`, `outputPolicy` (always +`"edit-in-place"`), optional `findings`, `next`. + +Per-mode content: + +| Mode | `findings` present | `rules` focus | +| --------- | ------------------ | ---------------------------------------------------------------------------------- | +| `author` | no | Write new EARS requirements into the host file's requirements region only. | +| `convert` | no | Rewrite natural-language requirements already in the host file into EARS in place. | +| `repair` | yes | Change only what the diagnostics justify, keyed to their ids. | +| `review` | yes | Produce a human review summary; never approve, accept, or merge. | + +The instruction body never tells an agent to approve, accept, or merge, and +never references a workspace, work item, or manifest. + +#### `--from ` semantics + +`--from ` is valid only with `instructions author` and +`instructions convert`; using it with `repair` or `review` is a usage error +(exit `2`). It names a natural-language spec file the agent reads as input while +writing EARS requirements into the host file at `--file`, following the +profile's locator for where requirements belong. + +The CLI only returns rules describing this flow. It never reads, parses, or +transforms `` content semantically and never calls an LLM. Reading and +understanding `` is the agent's job; `earsyntax` just points at it. + +When `--from` is present, the response adds and changes: + +- `sourceFile: string` is set to the `` path (optional field, absent + otherwise). +- `sourcePolicy: "read-only"` is set, stating the agent must not modify + ``. +- `rules` gains entries directing the agent to read requirement content from + `sourceFile`, write EARS into `--file` per the locator, and leave `sourceFile` + unchanged. +- `editPolicy.editableFile` remains `--file`; `sourceFile` is explicitly not + editable. + +### `explain ` + +Full write-up of one diagnostic. Resolves current ids and deprecated aliases. +No profile needed; exit `0` on a known id, `2` on an unknown one. + +```json +{ + "version": "0.0.0", + "command": "explain", + "ok": true, + "id": "EARS-E006", + "requestedId": "ears.invalid_if_then_form", + "alias": true, + "deprecationNote": "ears.invalid_if_then_form is a deprecated alias for EARS-E006.", + "severity": "error", + "title": "Malformed If/then unwanted-behaviour form", + "meaning": "An If clause is missing its required then boundary.", + "rationale": "Canonical EARS requires 'If , then the shall .'", + "badExample": "If the signature is invalid, the system shall reject the webhook.", + "goodExample": "If the signature is invalid, then the system shall reject the webhook.", + "profileNotes": "Errors under strict, ears-x, speckit, openspec. Under kiro, ...", + "next": [] +} +``` + +`requestedId` echoes what the user typed. `alias` is `true` and +`deprecationNote` is present only when the requested id was a deprecated alias; +`id` always holds the resolved current id. `severity` is the registry default +severity for the id. + +### `profiles` + +List the built-in profiles, rendered from profile data so descriptions cannot +drift. Exit `0`. + +```json +{ + "version": "0.0.0", + "command": "profiles", + "ok": true, + "profiles": [ + { + "name": "kiro", + "locates": "Acceptance-criteria list items in Kiro requirements.md.", + "relaxes": ["keyword case", "leading comma", "literal THE SYSTEM", "user-story wrappers"], + "adds": [], + "severityOverrides": { "EARS-W011": "off" } + } + ], + "next": [] +} +``` + +`ProfileSummary`: `{ name, locates, relaxes, adds, severityOverrides }`, one per +built-in profile, in the order `strict`, `ears-x`, `kiro`, `speckit`, +`openspec`. + +### `doctor` + +Detect host frameworks and agent integrations in the repo at `--cwd`, and +recommend exact commands. Works with no workspace. Exit `0`. + +```json +{ + "version": "0.0.0", + "command": "doctor", + "ok": true, + "root": "/repo", + "detected": { + "hosts": [{ "host": "kiro", "evidence": ".kiro/specs/", "profile": "kiro" }], + "agents": [{ "agent": "claude", "evidence": ".claude/" }] + }, + "next": [ + { + "command": "earsyntax validate \".kiro/specs/**/requirements.md\" --profile kiro", + "reason": "Validate Kiro requirements with the Kiro profile.", + "forAgent": true + }, + { + "command": "earsyntax init --agent claude --host kiro", + "reason": "Render Kiro and Claude integration files.", + "forAgent": true + } + ] +} +``` + +Detection markers include `.kiro/specs/`, `.kiro/steering/`, `.kiro/hooks/`, +`specs/**/spec.md`, `.specify/`, `openspec/`, `.claude/`, `AGENTS.md`, +`.cursor/`, `.github/prompts/`, `GEMINI.md`. Recommendations are runnable +`earsyntax` commands in `next`. + +### `init --agent --host ` + +Render managed agent-wrapper and host-integration files. Idempotent: running the +same command twice produces no diff. It does not create `.earsyntax/`, work +items, or edit requirement/spec documents, does not validate as a side effect, +and does not call an LLM. + +The `--json` output is exactly (verbatim from the plan, lines 741-760): + +```json +{ + "version": "0.0.0", + "command": "init", + "ok": true, + "root": "/repo", + "agents": ["claude"], + "hosts": ["kiro"], + "written": [".claude/commands/earsyntax-repair.md"], + "updated": [], + "skipped": [], + "warnings": [], + "next": [ + { + "command": "earsyntax validate \".kiro/specs/**/requirements.md\" --profile kiro", + "reason": "Validate Kiro requirements with the Kiro profile." + } + ] +} +``` + +`--agent` accepts a comma list of `claude`, `codex`, `cursor`, `copilot`, +`gemini`, `generic`. `--host` accepts a comma list of `kiro`, `speckit`, +`openspec`. `--tools` is a deprecated alias for `--agent`: it works, emits a +warning into `warnings`, and does not appear in help. `written` lists newly +created files, `updated` files whose managed section changed, `skipped` files +already up to date (this is how a second run reports a no-op), `warnings` +non-fatal notices. + +### `version --features` + +Report the installed version and capability map. Never resolves a repo, so +`root` is absent. Exit `0`. + +```json +{ + "version": "0.0.0", + "command": "version", + "ok": true, + "features": { + "facade": 1, + "commands": [ + "validate", + "extract", + "instructions", + "explain", + "profiles", + "doctor", + "init", + "version" + ], + "profiles": ["strict", "ears-x", "kiro", "speckit", "openspec"], + "instructions": ["author", "convert", "repair", "review"], + "hosts": ["kiro", "speckit", "openspec"], + "agents": ["claude", "codex", "cursor", "copilot", "gemini", "generic"], + "inputFormats": ["ears", "text", "markdown", "yaml", "json"], + "outputFormats": ["pretty", "json", "sarif"], + "sarif": true + }, + "next": [] +} +``` + +`features.workItems` is gone. `features.sarif` is now `true`, and `profiles`, +`hosts`, `agents`, and `commands` are reported so agents can discover the closed +surface without guessing. + +## Diagnostic id migration table + +Every one of the 29 codes in `packages/core/src/types.ts` `DiagnosticCode` +(lines 191-228) is assigned a stable `EARS-E###` or `EARS-W###` id. Errors get +`E001+`, warnings get `W001+`, assigned alphabetically by old code within each +severity, using the current strict-mode severity from `docs/diagnostics.md` +(structural `ears.*`, the three structural `expr.*`, and the two +`catalog.system_*` are errors; the other 16 codes are warnings). + +Old ids remain resolvable forever as deprecated aliases in `explain`. The +mapping is append-only: never renumber, never reuse a retired number, never +delete an id. A new diagnostic takes the next free number in its severity band. + +### Errors: 13 codes (`EARS-E001`-`EARS-E013`) + +| New id | Old code | +| ----------- | -------------------------------- | +| `EARS-E001` | `catalog.system_ambiguous` | +| `EARS-E002` | `catalog.system_unresolved` | +| `EARS-E003` | `ears.empty_clause` | +| `EARS-E004` | `ears.empty_response` | +| `EARS-E005` | `ears.invalid_clause_order` | +| `EARS-E006` | `ears.invalid_if_then_form` | +| `EARS-E007` | `ears.missing_shall` | +| `EARS-E008` | `ears.missing_system` | +| `EARS-E009` | `ears.multiple_shall` | +| `EARS-E010` | `ears.no_match` | +| `EARS-E011` | `expr.empty_subexpression` | +| `EARS-E012` | `expr.invalid_operator_sequence` | +| `EARS-E013` | `expr.unbalanced_parentheses` | + +### Introduced by the host-native grammar: 3 error codes (`EARS-E014`-`EARS-E016`) + +These are not part of the original 29-code migration. The host-native grammar +work added them; each has no legacy code it replaces, but its dotted form +registers as a deprecated alias like every other code. All three default to +`error` and resolve as errors in strict mode. + +| New id | Dotted code | Introduced by | +| ----------- | ------------------------------ | ------------------- | +| `EARS-E014` | `ears.keyword_case` | host-native grammar | +| `EARS-E015` | `ears.missing_leading_comma` | host-native grammar | +| `EARS-E016` | `ears.prohibition_not_allowed` | host-native grammar | + +The `kiro` profile relaxes keyword case (`EARS-E014`) and the leading comma +(`EARS-E015`) through its dialect; the `ears-x` profile legalizes prohibition +(`EARS-E016`) through `allowProhibition`. + +### Warnings: 16 codes (`EARS-W001`-`EARS-W016`) + +| New id | Old code | +| ----------- | ---------------------------------- | +| `EARS-W001` | `catalog.event_ambiguous` | +| `EARS-W002` | `catalog.event_unresolved` | +| `EARS-W003` | `catalog.feature_ambiguous` | +| `EARS-W004` | `catalog.feature_unresolved` | +| `EARS-W005` | `catalog.state_ambiguous` | +| `EARS-W006` | `catalog.state_unresolved` | +| `EARS-W007` | `catalog.term_unreferenced` | +| `EARS-W008` | `expr.ambiguous_term` | +| `EARS-W009` | `expr.mixed_unresolved_terms` | +| `EARS-W010` | `expr.operator_precedence_warning` | +| `EARS-W011` | `expr.unknown_term` | +| `EARS-W012` | `lint.alias_used` | +| `EARS-W013` | `lint.multiple_responses` | +| `EARS-W014` | `lint.suspicious_text_shape` | +| `EARS-W015` | `lint.unparsed_tail` | +| `EARS-W016` | `lint.vague_response` | + +13 errors + 16 warnings = 29 ids in the original migration, one per old code, no +gaps. The host-native grammar work then appended `EARS-E014`-`EARS-E016` (see +above), bringing the registry to 32 ids. + +Notes: + +- The `E`/`W` band is the DEFAULT severity. A profile `severity` override or + `--strict` can change the effective severity a given diagnostic carries in a + Findings result without changing its id (see `docs/contracts/findings.md`). +- The old `guided` mode (which downgraded strict errors to warnings) is gone; + its behavior is now expressed as profile severity overrides, not a global + mode flag. The migration band still reflects strict-mode severity. +- Aliases resolve in `explain` only. A profile's `severity` map keys on current + `EARS-*` ids, not aliases. + +## Removed commands and banned verbs + +Removed from the product surface entirely (not kept, not hidden, not a second +ring): `new`, `list`, `status`, `show`, `accept`, `check`. They do not appear in +help, README, docs, or `next` actions. The `.earsyntax/` workspace and every +concept tied to it (work items, manifests, acceptance records, staleness +hashing) is removed; see `docs/refactor/inventory.md` for the full list. + +No lifecycle or orchestration verb may ever be added. Permanently banned: +`plan`, `tasks`, `design`, `implement`, and any other specification-lifecycle +verb. Existing SDD tools own the lifecycle; `earsyntax` owns deterministic EARS +work inside their documents. Narrowness is the product position, not a +temporary limitation. diff --git a/docs/refactor/inventory.md b/docs/refactor/inventory.md new file mode 100644 index 0000000..8731a3b --- /dev/null +++ b/docs/refactor/inventory.md @@ -0,0 +1,379 @@ +# Current CLI inventory + +This is a frozen snapshot of the `earsyntax` CLI as it exists on branch +`feat/earsyntax-toolkit` before the host-native refactor. Later agents cite +line numbers here instead of re-reading the source. It records every command, +flag, output shape, and workspace file, with call sites. + +It is a description of what exists now, not a target. The target surface is +`docs/refactor/host-native-facade.md`. The final section lists what the +host-native refactor removes. + +## Package layout + +| Package | Role | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `@earsyntax/core` | Parser, linter, diagnostics, catalog matching. Pure, no I/O, no LLM. (`packages/core/src/`) | +| `@earsyntax/extract` | Turns `.ears`, Markdown, YAML, JSON files into `RequirementInput`, and locates candidates in host documents. (`packages/extract/src/`) | +| `@earsyntax/cli-contract` | Report projections: Findings serialization, SARIF, exit codes. Pure serializers, no I/O, no argv. (`packages/cli-contract/src/`) | +| `@earsyntax/cli` | Command dispatcher and the workspace-backed command handlers. (`packages/cli/src/`) | + +Note: `@earsyntax/cli-contract` contains a `Findings` serializer, a `SarifLog` +builder, and an exit-code helper, all built from the Findings model. `validate` +consumes these projections directly. See the findings contract for how these +converge. + +## The dispatcher + +`packages/cli/src/cli.ts`. + +- `run(argv, options)` (`cli.ts:63`) is the entry point. It returns an exit + code and never calls `process.exit`. +- Bare invocation or `--help` / `-h` prints usage text (`cli.ts:71-74`). + Bare invocation returns exit `2`; `--help` returns `0`. +- `--version` / `-v` dispatches to the `version` command (`cli.ts:75-77`). +- `dispatch()` (`cli.ts:82`) parses args, resolves globals, resolves `cwd`, + builds the emitter, and routes to a handler in the `COMMANDS` map + (`cli.ts:37-48`). +- Unknown commands throw `cli.unknown_command` (exit `2`) (`cli.ts:96-100`). +- Caught `CliError`s are rendered as a base response and the carried exit code + is returned; anything else becomes `cli.internal_error` (exit `2`) + (`cli.ts:105-118`). +- `commandLabel()` (`cli.ts:51`) makes `instructions` report as + `instructions `. +- `usageText()` (`cli.ts:121`) lists all ten commands and the global options. + +`COMMANDS` map (`cli.ts:37-48`): `init`, `doctor`, `version`, `new`, `list`, +`status`, `instructions`, `validate`, `accept`, `show`. Ten commands. + +## Argument parsing + +`packages/cli/src/args.ts`. + +- `parseArgs(argv)` (`args.ts:40`) produces `{ positionals, booleans, values }`. + Understands `--flag`, `--flag value`, `--flag=value`, `--no-flag`, and `--` + (everything after is positional). +- Value-taking flags are declared in `VALUE_FLAGS` (`args.ts:16-30`): `cwd`, + `config`, `tools`, `mode`, `source`, `prompt`, `out`, `by`, `work`, + `catalog`, `format`, `artifact`, `status`. Every other `--flag` is boolean. +- `resolveGlobals(args)` (`args.ts:106`) extracts globals: `json` + (`--json`), `color` (`!--no-color`), `interactive` (`!--no-interactive`), + `cwd`, `config`. + +There is no `--profile`, `--strict`, `--sarif`, or `--quiet` today. + +## Global options (current) + +| Flag | Type | Meaning | Site | +| ------------------ | ------- | ------------------------------------ | ------------- | +| `--json` | boolean | Emit JSON instead of pretty text. | `args.ts:108` | +| `--no-color` | boolean | Disable ANSI color in pretty output. | `args.ts:109` | +| `--no-interactive` | boolean | Non-interactive mode. | `args.ts:110` | +| `--cwd ` | value | Working directory to resolve from. | `args.ts:111` | +| `--config ` | value | Explicit config path override. | `args.ts:112` | + +## Base response shape + +`packages/cli/src/response.ts`, `packages/cli/src/facade-types.ts`. + +`buildResponse(init, extra)` (`response.ts:27`) emits keys in this fixed order: + +1. `version` (from `CLI_VERSION`, `version.ts:34`) +2. `command` +3. `ok` +4. `root` (only when defined) +5. command-specific convenience keys (spread from `extra`) +6. `diagnostics` (only when non-empty) +7. `next` (always, defaults to `[]`) + +`FacadeResponse` (`facade-types.ts:36-46`), `FacadeDiagnostic` +(`facade-types.ts:19-25`), `NextAction` (`facade-types.ts:28-33`). + +`FacadeDiagnostic`: `{ code, severity: 'error'|'warning'|'info', message, +path?, line? }`. This is the facade-level diagnostic (config, path, work-item +problems), distinct from the core `Diagnostic` embedded inside a validate +result. `serialize()` (`response.ts:43`) is `JSON.stringify(response, null, 2)`, +no trailing newline added by serialize; `emit()` (`response.ts:55`) appends the +newline. + +## Commands + +### `init` + +`packages/cli/src/commands/init.ts`. Handler `initCommand` (`init.ts:61`). + +- Positional: none. +- Flags: `--tools ` (`init.ts:64`), `--force` (`init.ts:63`). +- `resolveTools()` (`init.ts:23`) parses the comma list; validates against + `KNOWN_TOOLS` = `{claude, codex, cursor}` (`tool-wrappers.ts:98`); throws + `init.bad_tools` (exit `2`) on unknown. +- Refuses if `.earsyntax/` exists without `--force`: `init.exists` (exit `3`) + (`init.ts:67-72`). +- Refuses to overwrite a wrapper file (except `AGENTS.md`) without `--force`: + `init.wrapper_exists` (exit `3`) (`init.ts:81-88`). +- Writes: `.earsyntax/config.json` (`init.ts:100`), `.earsyntax/work/.gitkeep` + (`init.ts:101`), plus tool wrapper files (`init.ts:104-113`). `AGENTS.md` is + merged via marker section, not clobbered (`mergeAgentsFile`, `init.ts:46`). +- Output keys: `written: string[]`, `tools: string[]` (`init.ts:128`). +- `next`: single action pointing at `earsyntax new --source ... --json` + (`init.ts:119-126`). +- Exit `0` on success. + +### `doctor` + +`packages/cli/src/commands/doctor.ts`. Handler `doctorCommand` (`doctor.ts:27`). + +- Requires a workspace: `requireRoot()` (`doctor.ts:28`) throws + `project.not_initialized` (exit `2`) if no `.earsyntax/`. +- Loads config: `loadConfig()` (`doctor.ts:30`); `config.unreadable` / + `config.invalid` (exit `2`). +- Runs a fixed checklist (`doctor.ts:32-99`): `config`, `workDir`, `version`, + per-work-item `source:` and `stale:`, per-tool + `tool:`. +- Output keys: `checks: { name, ok, severity, message }[]`, plus non-passing + checks mirrored into base `diagnostics` (`doctor.ts:101-114`). +- `ok` is true unless a check has severity `error` (`doctor.ts:109`). +- Always returns exit `0` (`doctor.ts:120`); `hasError` only affects `ok`. + +### `version` + +`packages/cli/src/commands/version.ts`. Handler `versionCommand` +(`version.ts:12`). + +- Flags: `--features` (pretty output detail only; JSON always carries features). +- Never resolves a project; `root` absent. +- Output key: `features` = `FEATURES` (`version.ts:14`). +- `FEATURES` (`version.ts:50-59`): `{ facade: 1, workItems: true, +instructions: ['author','convert','repair','review'], inputFormats: +['.ears','markdown','yaml','json'], outputFormats: ['pretty','json'], +sarif: false }`. +- `CLI_VERSION` read from package.json at runtime (`version.ts:13-34`), falls + back to `0.1.0`. +- Exit `0`. + +### `new` + +`packages/cli/src/commands/new.ts`. Handler `newCommand` (`new.ts:77`). + +- Positional: `` (validated by `SLUG_RE`, `new.ts:20`); bad slug throws + `new.bad_slug` (exit `2`). +- Flags: `--mode convert|author` (`new.ts:40`), `--source `, + `--prompt `, `--out ` (`new.ts:122`), `--snapshot-source` + (`new.ts:114`), `--force` (`new.ts:90`). +- Mode resolution (`resolveMode`, `new.ts:39`): convert requires `--source` + (`new.missing_source`), author requires `--prompt` (`new.missing_prompt`), + neither present throws `new.missing_mode`; bad `--mode` throws `new.bad_mode`. + All exit `2`. +- Refuses existing work item without `--force`: `new.exists` (exit `3`) + (`new.ts:93-98`). +- Reads and hashes the source for convert mode (`new.ts:104-120`); + `new.source_unreadable` (exit `2`). +- Writes the work item: manifest, empty `requirements.ears`, `questions.md`, + `traceability.json` (`new.ts:159-170`). +- Output keys: `work`, `written: string[]` (`new.ts:187`). +- `next` from `nextForStatus(slug, 'scaffolded', source)` (`new.ts:185`). +- Exit `0`. + +### `list` + +`packages/cli/src/commands/list.ts`. Handler `listCommand` (`list.ts:14`). + +- Requires a workspace (`requireRoot`, `list.ts:15`). +- Flags: `--status ` filters by reported status (`list.ts:17`). +- Output key: `items: WorkSummary[]`, sorted by id (`list.ts:19-25`). +- Exit `0`. + +### `status` + +`packages/cli/src/commands/status.ts`. Handler `statusCommand` (`status.ts:35`). + +- Requires a workspace (`requireRoot`, `status.ts:36`). +- Positional: `[]`. With no slug, resolves the single work item if there + is exactly one; otherwise `status.no_work` or `status.ambiguous` (exit `2`) + (`resolveSlug`, `status.ts:15-33`). +- Unknown item: `work.unknown` (exit `2`) via `requireManifest`. +- Output key: `work` with computed staleness (`status.ts:42-52`). +- `next` from `nextForStatus` (`status.ts:59`). +- Exit `0`. + +### `instructions` + +`packages/cli/src/commands/instructions.ts`. Handler `instructionsCommand` +(`instructions.ts:76`). + +- Positional: `` = `author|convert|repair|review` (validated by + `requireInstructionMode`, `mode.ts:13`); bad mode throws + `instructions.unknown_mode` (exit `2`). +- Flag: `--work ` REQUIRED (`instructions.ts:78-81`); missing throws + `instructions.missing_work` (exit `2`). +- Requires a workspace (`requireRoot`, `instructions.ts:83`) and a manifest. +- Convert mode adds `source` block with excerpts (`instructions.ts:92-101`); + repair mode adds `diagnostics` read from the work item's last + `validation.json` (`readRepairDiagnostics`, `instructions.ts:51`). +- Output keys: `mode`, `work` (WorkSummary), `rules` (from `rulesFor`, + `rules.ts:79`), `format: { line, allowedPatterns, metadataPrefixes }`, + optional `source`, optional `diagnostics` (`instructions.ts:105-116`). +- `next`: review mode points at `show ... --artifact questions` and + `accept` (blocking); other modes point at `validate` (`instructions.ts:118-148`). +- Exit `0`. + +Rule bodies live in `packages/cli/src/rules.ts`: `ALLOWED_PATTERNS` +(`rules.ts:13`, five templates), `METADATA_PREFIXES` (`rules.ts:22`), +`AUTHOR_RULES`, `CONVERT_RULES`, `REPAIR_RULES`, `REVIEW_RULES` +(`rules.ts:28-76`), `exampleLine()` (`rules.ts:93`). + +### `validate` + +`packages/cli/src/commands/validate.ts`. Handler `validateCommand` +(`validate.ts:115`). + +- Positionals: ``; none throws `validate.no_files` (exit `2`) + (`validate.ts:117-119`). +- Flags: `--work ` (workspace recording, `validate.ts:121`), `--catalog +` (`validate.ts:129`), `--mode strict|guided` (`buildOptions`, + `validate.ts:70`), `--comma-as-and` (`validate.ts:77`). +- With `--work`, requires a workspace (`requireRoot`); without, uses + `findRoot(cwd) ?? cwd` so it runs outside a workspace too (`validate.ts:122`). +- `expandFiles()` (`validate.ts:31`): literal paths must exist + (`validate.missing_file`, exit `2`); globs expand via `globSync`, sorted. +- Extraction, parsing, and linting via `runPipeline` (`validate.ts:151`); + unreadable file with errors throws `validate.unreadable` (exit `2`). +- Facade-level duplicate-ID check emits `facade.duplicate_id` error diagnostics + (`validate.ts:166-183`). +- Output keys: `summary { files, requirements, valid, errors, warnings }`, + `results: ValidationResult[]`, optional `work` + `stale` when validating a + work item (`validate.ts:201-225`). +- `ValidationResult` (`facade-types.ts:110-119`): `{ id?, file, line?, valid, +pattern?, ast?, references, diagnostics }`. `diagnostics` are core + `Diagnostic` objects, `references` are `ReferenceMatch[]`. +- Work-item integration (`updateWorkItem`, `validate.ts:248`) writes + `manifest.json`, `validation.json`, `validation.md` and computes staleness. +- `ok` is `errors === 0`; exit `0` when ok, `1` otherwise (`validate.ts:244`). + This is the ONLY command that returns exit `1`. +- `next` on failure: `earsyntax instructions repair ... --json` + (`validate.ts:227-235`). + +### `accept` + +`packages/cli/src/commands/accept.ts`. Handler `acceptCommand` (`accept.ts:16`). + +- Requires a workspace (`requireRoot`, `accept.ts:17`). +- Positional: ``; missing throws `accept.missing_slug` (exit `2`). +- Flag: `--by ` (`accept.ts:52`). +- Refusals, all exit `3`: `accept.not_valid` (status not valid), + `accept.stale` (source drifted), `accept.output_missing`, + `accept.output_changed` (`accept.ts:26-50`). +- Writes the `accepted` block to the manifest and sets status `accepted` + (`accept.ts:52-61`). +- Output key: `work` including `accepted` (`accept.ts:63-70`). +- Exit `0`. + +### `show` + +`packages/cli/src/commands/show.ts`. Handler `showCommand` (`show.ts:20`). + +- Requires a workspace (`requireRoot`, `show.ts:21`). +- Positional: ``; missing throws `show.missing_slug` (exit `2`). +- Flag: `--artifact requirements|questions|traceability|validation|manifest` + (`show.ts:38`); unknown throws `show.unknown_artifact` (exit `2`). +- With `--artifact`: output key `artifact { path, exists, content }` + (`show.ts:56`). Without: output key `work` with all resolved paths + (`show.ts:62-73`). +- Exit `0`. + +## Exit codes (current) + +`docs/facade-api.md:77-105`, `errors.ts`. + +| Code | Meaning | Site | +| ---- | ---------------------------------------------------------------------- | ------------------------------ | +| `0` | Completed, no error-severity diagnostic. | all handlers | +| `1` | Error diagnostics present. Only `validate`. | `validate.ts:244` | +| `2` | Usage, config, missing file, unparseable input, work-item resolution. | `usageError`, `errors.ts:31` | +| `3` | Refused write, stale source, overwrite protection, human confirmation. | `refusalError`, `errors.ts:40` | + +## Workspace files (`.earsyntax/`) + +Written and read by the workspace commands. `packages/cli/src/workspace.ts`, +`packages/cli/src/project.ts`. + +| Path | Written by | Read by | Shape | +| ------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| `.earsyntax/config.json` | `init` (`init.ts:100`) | `loadConfig` (`project.ts:60`), every workspace command | `{ version, workDir, tools[] }` (`project.ts:14-20`) | +| `.earsyntax/work/.gitkeep` | `init` (`init.ts:101`) | none | empty | +| `.earsyntax/work//manifest.json` | `new`, `validate`, `accept` (`workspace.ts:66`) | `readManifest` (`workspace.ts:43`) | `WorkManifest` (`facade-types.ts:59-89`) | +| `.earsyntax/work//requirements.ears` | `new` (empty), agent-authored | `validate`, `show` | EARS text | +| `.earsyntax/work//questions.md` | `new` (template) | `show` | Markdown | +| `.earsyntax/work//traceability.json` | `new` (template) | `show` | `{ requirements[], questions[] }` | +| `.earsyntax/work//validation.json` | `validate` (`validate.ts:273`) | `instructions repair` (`instructions.ts:51`), `show` | `{ summary, results }` | +| `.earsyntax/work//validation.md` | `validate` (`validate.ts:274`) | none | Markdown | + +`WorkManifest` fields (`facade-types.ts:59-89`): `schemaVersion`, `id`, `mode`, +`status`, `source?{path,hash,kind,snapshotPath?}`, `prompt?`, +`output{path,hash?}`, `artifacts{questions,traceability,validationJson, +validationMarkdown}`, `createdAt`, `updatedAt`, +`accepted?{at,by?,sourceHash?,outputHash}`. + +`WorkStatus` (`facade-types.ts:52-53`): `missing | scaffolded | drafted | +invalid | valid | accepted | stale`. + +Staleness is computed from content hashes (`workspace.ts:124-143`), never +stored. Hashing: `hashContent` in `packages/cli/src/hash.ts` (`sha256:` + +64 hex). + +## Diagnostic registry (current, 32 codes) + +`packages/core/src/types.ts:191-234` defines the `DiagnosticCode` union. +`packages/core/src/catalog.ts`'s `DIAGNOSTIC_REGISTRY` maps each to a title and +meaning. `docs/diagnostics.md` documents severity by mode. + +Groups: `ears.*` (8), `expr.*` (7), `catalog.*` (9), `lint.*` (5), plus 3 +host-native grammar codes with no legacy migration. Total 32. The full list and +its new-ID migration is in `docs/refactor/host-native-facade.md`. + +Strict-mode severities (`docs/diagnostics.md:20-89`): the 8 `ears.*`, the 3 +structural `expr.*` (`unbalanced_parentheses`, `invalid_operator_sequence`, +`empty_subexpression`), and the 2 `catalog.system_*` are `error`. The other 16 +codes are `warning` in every mode. Total 13 error, 16 warning. + +## Removed by the host-native refactor + +The host-native plan (`EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md` +lines 37-51) removes these from the product surface entirely. They are not kept +as a second ring; the older dual-ring iteration +(`EARSYNTAX-CLI-FACADE-ALPHA-0.md`) is superseded here. + +Removed commands: + +- `new` (`packages/cli/src/commands/new.ts`) +- `list` (`packages/cli/src/commands/list.ts`) +- `status` (`packages/cli/src/commands/status.ts`) +- `show` (`packages/cli/src/commands/show.ts`) +- `accept` (`packages/cli/src/commands/accept.ts`) +- `check` (planned in the dual-ring doc; never built; not to be built) + +Removed workspace, in its entirety: + +- The `.earsyntax/` directory and everything under it: `config.json`, + `work//` and all its artifacts (manifest, requirements.ears, + questions.md, traceability.json, validation.json, validation.md). +- All concepts tied to it: work items, manifests, `WorkStatus` / + `WorkManifest` / `WorkSummary`, acceptance records, source/output content + hashing for staleness, the `stale` computation, project-root discovery by + walking up to `.earsyntax/`. + +Removed flags (tied to the workspace): `--work`, `--source` (as a validate +flag), `--out`, `--snapshot-source`, `--by`, `--artifact`, `--status`, +`--config`, `--mode` (replaced by profiles), `--comma-as-and` (folded into +profile dialect), `--tools` (deprecated alias of `--agent`, hidden from help), +`--no-interactive`. + +Exit code `3` is removed with the workspace: with no refusals to make (no +overwrite protection over managed state, no acceptance gate), the only codes +are `0`, `1`, `2`. + +Modules that become dead or are rewritten: `workspace.ts`, `hash.ts`, +`next-actions.ts` (work-item lifecycle), the `WorkManifest` / `WorkStatus` / +`WorkSummary` types in `facade-types.ts`, the work-item branch of +`validate.ts`, and `project.ts` root discovery (replaced by `--cwd` repo-root +detection that does not require `.earsyntax/`). diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..426f7e8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,330 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import importPlugin from 'eslint-plugin-import-x'; +import unicornPlugin from 'eslint-plugin-unicorn'; +import prettierConfig from 'eslint-config-prettier'; + +const IGNORE_PATTERNS = [ + 'node_modules', + '**/node_modules/**', + 'dist', + '**/dist/**', + 'coverage', + '**/coverage/**', + '**/*.d.ts', + '**/*.tsbuildinfo', + '**/*.map', + 'pnpm-lock.yaml', + 'package-lock.json', + '.dependency-cruiser.cjs', + 'eslint.config.mjs', + // Config-style CJS files are not source we own; lint them only if/when we + // adopt rules for build configuration. + 'commitlint.config.cjs', + // Vitest/Vite-style `.config.ts` files don't live in any project's + // tsconfig include, so the typed lint rules cannot resolve them. Lint + // them only when we add a dedicated tsconfig for build configuration. + '**/vitest.config.ts', + // Scratch trees that are not part of the toolkit source. Kept as ignore + // globs so stray experiment directories do not fail lint; harmless when the + // directories are absent. + 'lib/**', + 'poc/**', + // *.proposal.ts files are literate design documents that reference symbols + // that do not exist yet. Lint would flag every line; the matching tsconfig + // also excludes this pattern. + '**/*.proposal.ts', +]; + +const typedFilePatterns = ['**/*.ts', '**/*.mts', '**/*.cts']; +const testFilePatterns = [ + '**/*.test.ts', + '**/*.spec.ts', + '**/__tests__/**/*.ts', + '**/test/**/*.ts', +]; + +const baseGlobals = { ...globals.es2024, ...globals.node }; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { ignores: IGNORE_PATTERNS }, + + // Base JS rules apply to all source files. + js.configs.recommended, + + // Typed TypeScript rules apply ONLY to .ts / .mts / .cts files. + ...tseslint.configs.strictTypeChecked.map((config) => ({ + ...config, + files: typedFilePatterns, + })), + ...tseslint.configs.stylisticTypeChecked.map((config) => ({ + ...config, + files: typedFilePatterns, + })), + + prettierConfig, + + { + files: typedFilePatterns, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + globals: baseGlobals, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + 'import-x': importPlugin, + 'unicorn': unicornPlugin, + }, + rules: { + // TypeScript strict rules + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'inline-type-imports', + disallowTypeAnnotations: true, + }, + ], + '@typescript-eslint/consistent-type-exports': [ + 'error', + { fixMixedExportsWithInlineTypeSpecifier: true }, + ], + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/require-await': 'error', + // Optional chaining, optional parameters, and default-value parameters + // are all allowed. These opinionated stylistic rules are left off so the + // rules do not force a particular arity-expression style on the code. + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/no-useless-default-assignment': 'off', + '@typescript-eslint/unified-signatures': 'off', + // `no-namespace` bans module-augmenting namespace declarations by default. + // We need them to extend `declare global { namespace PlaywrightTest { ... } }` + // with custom matchers. Allow inside `declare` blocks (the only legitimate + // use case for namespaces in this codebase). + '@typescript-eslint/no-namespace': [ + 'error', + { allowDeclarations: true, allowDefinitionFiles: true }, + ], + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { allowNumber: true, allowBoolean: true }, + ], + + // Import rules + 'import-x/no-cycle': ['error', { maxDepth: 10 }], + 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], + 'import-x/first': 'error', + 'import-x/newline-after-import': 'error', + 'import-x/no-mutable-exports': 'error', + 'import-x/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + ...testFilePatterns, + '**/*.config.ts', + '**/*.config.mts', + '**/*.config.mjs', + '**/*.config.cjs', + '**/*.config.js', + ], + optionalDependencies: false, + }, + ], + + // Strict boundary rules + 'no-restricted-syntax': [ + 'error', + { + // No double type assertions + selector: 'TSAsExpression > TSAsExpression', + message: + 'Double type assertions (x as unknown as Y) are not allowed. Fix the underlying type instead of forcing a cast.', + }, + { + // No `x as any` cast — escape hatch. `no-explicit-any` only catches + // `: any` annotations; the cast form needs an AST rule of its own. + selector: 'TSAsExpression > TSAnyKeyword', + message: + '`as any` is forbidden. Fix the underlying type or refactor the API so the cast is unneeded.', + }, + { + // No `x as never` cast — same reasoning. Returning `as never` from a + // stub function is a sign the function should not exist, or the + // surrounding type contract is wrong. + selector: 'TSAsExpression > TSNeverKeyword', + message: + '`as never` is forbidden. Replace the stubbed value with a real one, or refactor the type so the cast is unneeded.', + }, + { + // No "I" prefix on interfaces (TypeScript structural typing) + selector: 'TSInterfaceDeclaration[id.name=/^I[A-Z]/]', + message: + 'Do not prefix interfaces with "I". TypeScript uses structural typing.', + }, + // Optional properties, optional parameters, and optional chaining are + // intentionally allowed. The frozen @earsyntax/core contract in + // packages/core/src/types.ts uses optional properties throughout to + // mirror the Go reference JSON shapes, and the public API functions + // (lintEars, parseEars, ...) take optional `catalog?`/`options?` + // parameters. Earlier bans on `?:` and `?.` were blackbox-era rules. + // NO REDUNDANT TYPE ANNOTATION on `new` expressions — `new Foo()` + // already returns the typed instance. `const x: Foo = new Foo()` + // duplicates information. Drop the annotation: `const x = new Foo()`. + { + selector: 'VariableDeclarator[id.typeAnnotation][init.type="NewExpression"]', + message: + 'Redundant type annotation on a `new` expression — the constructor already returns the typed instance. Drop the annotation: `const x = new Foo()` instead of `const x: Foo = new Foo()`.', + }, + // NO `delete` OPERATOR. Deleting a key mutates an object in place and + // deoptimizes its shape. Construct a new object without the key instead + // (e.g. object rest: `const { removed: _removed, ...rest } = obj`). + { + selector: 'UnaryExpression[operator="delete"]', + message: + 'Do not use `delete`; construct a new object without the key instead (e.g. object rest destructuring).', + }, + ], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@earsyntax/*/src/*', '@earsyntax/*/src'], + message: + 'Import from the package entry point, not /src paths. Use the @earsyntax/ export.', + }, + { + group: ['../../../*'], + message: + 'Avoid deep relative imports. Use workspace package imports.', + }, + ], + }, + ], + + // Unicorn rules + 'unicorn/prefer-node-protocol': 'error', + 'unicorn/no-array-reduce': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-null': 'off', + 'unicorn/filename-case': 'off', + + // General rules + 'no-console': 'warn', + 'no-debugger': 'error', + 'prefer-const': 'error', + 'no-var': 'error', + 'eqeqeq': ['error', 'always'], + 'curly': ['error', 'all'], + // Immutability: do not mutate received values. `no-param-reassign` with + // `props: true` forbids both reassigning a parameter and mutating its + // properties; combined with the `delete` ban above this pushes the code + // toward constructing new objects rather than editing in place. + 'no-param-reassign': ['error', { props: true }], + }, + }, + { + files: testFilePatterns, + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + // Test files live 2-3 directories deep under test/, so a 3-level relative + // import is same-package (test/a/b/ → src/). Only cross-package imports + // (4+ levels) need to be restricted. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@earsyntax/*/src/*', '@earsyntax/*/src'], + message: + 'Import from the package entry point, not /src paths. Use the @earsyntax/ export.', + }, + { + group: ['../../../../*'], + message: + 'Avoid cross-package relative imports. Use workspace package imports instead.', + }, + ], + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + 'no-console': 'off', + 'import-x/no-extraneous-dependencies': 'off', + }, + }, + + // ESM script files (*.mjs). These are Node.js utility scripts, not TypeScript + // source. Apply Node globals so `console`, `process`, `URL`, etc. are defined. + { + files: ['**/*.mjs'], + languageOptions: { + globals: baseGlobals, + }, + rules: { + 'no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + + // CommonJS source files (*.cjs). These are config-style or bootstrap files + // that can't be ESM or TypeScript. Lint them with vanilla JS rules + Node + // globals + CommonJS source type. None of the typed TS rules apply. + { + files: ['**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: baseGlobals, + }, + rules: { + 'no-console': 'off', + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'prefer-const': 'error', + 'no-var': 'error', + 'eqeqeq': ['error', 'always'], + }, + }, +]; diff --git a/fixtures/diagnostics/migration-table.json b/fixtures/diagnostics/migration-table.json new file mode 100644 index 0000000..1de34fc --- /dev/null +++ b/fixtures/diagnostics/migration-table.json @@ -0,0 +1,41 @@ +{ + "description": "Frozen append-only diagnostic id migration table. Mirrors docs/refactor/host-native-facade.md. Each row is one old DiagnosticCode mapped to its stable EARS-E###/EARS-W### id and the default severity implied by the band. packages/core/src/registry.test.ts asserts the registry equals this table exactly, so any drift (a renumbered, reused, deleted, or reseverified id) fails the test.", + "rows": [ + { "id": "EARS-E001", "oldCode": "catalog.system_ambiguous", "defaultSeverity": "error" }, + { "id": "EARS-E002", "oldCode": "catalog.system_unresolved", "defaultSeverity": "error" }, + { "id": "EARS-E003", "oldCode": "ears.empty_clause", "defaultSeverity": "error" }, + { "id": "EARS-E004", "oldCode": "ears.empty_response", "defaultSeverity": "error" }, + { "id": "EARS-E005", "oldCode": "ears.invalid_clause_order", "defaultSeverity": "error" }, + { "id": "EARS-E006", "oldCode": "ears.invalid_if_then_form", "defaultSeverity": "error" }, + { "id": "EARS-E007", "oldCode": "ears.missing_shall", "defaultSeverity": "error" }, + { "id": "EARS-E008", "oldCode": "ears.missing_system", "defaultSeverity": "error" }, + { "id": "EARS-E009", "oldCode": "ears.multiple_shall", "defaultSeverity": "error" }, + { "id": "EARS-E010", "oldCode": "ears.no_match", "defaultSeverity": "error" }, + { "id": "EARS-E011", "oldCode": "expr.empty_subexpression", "defaultSeverity": "error" }, + { "id": "EARS-E012", "oldCode": "expr.invalid_operator_sequence", "defaultSeverity": "error" }, + { "id": "EARS-E013", "oldCode": "expr.unbalanced_parentheses", "defaultSeverity": "error" }, + { "id": "EARS-E014", "oldCode": "ears.keyword_case", "defaultSeverity": "error" }, + { "id": "EARS-E015", "oldCode": "ears.missing_leading_comma", "defaultSeverity": "error" }, + { "id": "EARS-E016", "oldCode": "ears.prohibition_not_allowed", "defaultSeverity": "error" }, + { "id": "EARS-W001", "oldCode": "catalog.event_ambiguous", "defaultSeverity": "warning" }, + { "id": "EARS-W002", "oldCode": "catalog.event_unresolved", "defaultSeverity": "warning" }, + { "id": "EARS-W003", "oldCode": "catalog.feature_ambiguous", "defaultSeverity": "warning" }, + { "id": "EARS-W004", "oldCode": "catalog.feature_unresolved", "defaultSeverity": "warning" }, + { "id": "EARS-W005", "oldCode": "catalog.state_ambiguous", "defaultSeverity": "warning" }, + { "id": "EARS-W006", "oldCode": "catalog.state_unresolved", "defaultSeverity": "warning" }, + { "id": "EARS-W007", "oldCode": "catalog.term_unreferenced", "defaultSeverity": "warning" }, + { "id": "EARS-W008", "oldCode": "expr.ambiguous_term", "defaultSeverity": "warning" }, + { "id": "EARS-W009", "oldCode": "expr.mixed_unresolved_terms", "defaultSeverity": "warning" }, + { + "id": "EARS-W010", + "oldCode": "expr.operator_precedence_warning", + "defaultSeverity": "warning" + }, + { "id": "EARS-W011", "oldCode": "expr.unknown_term", "defaultSeverity": "warning" }, + { "id": "EARS-W012", "oldCode": "lint.alias_used", "defaultSeverity": "warning" }, + { "id": "EARS-W013", "oldCode": "lint.multiple_responses", "defaultSeverity": "warning" }, + { "id": "EARS-W014", "oldCode": "lint.suspicious_text_shape", "defaultSeverity": "warning" }, + { "id": "EARS-W015", "oldCode": "lint.unparsed_tail", "defaultSeverity": "warning" }, + { "id": "EARS-W016", "oldCode": "lint.vague_response", "defaultSeverity": "warning" } + ] +} diff --git a/fixtures/ears-lint-go-parity/.gitkeep b/fixtures/ears-lint-go-parity/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ears-lint-go-parity/A301.json b/fixtures/ears-lint-go-parity/A301.json new file mode 100644 index 0000000..1a5a8d7 --- /dev/null +++ b/fixtures/ears-lint-go-parity/A301.json @@ -0,0 +1,289 @@ +{ + "id": "A301", + "text": "When reverse thrust command, the ECS shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "catalog.event_ambiguous", + "severity": "warning" + }, + { + "code": "expr.ambiguous_term", + "severity": "warning" + }, + { + "code": "catalog.system_ambiguous", + "severity": "error" + } + ], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "ECS" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/A303.json b/fixtures/ears-lint-go-parity/A303.json new file mode 100644 index 0000000..739b8d8 --- /dev/null +++ b/fixtures/ears-lint-go-parity/A303.json @@ -0,0 +1,293 @@ +{ + "id": "A303", + "text": "While degraded mode, when sensor fault, the diagnostics shall raise an alert.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [ + { + "code": "catalog.state_ambiguous", + "severity": "warning" + }, + { + "code": "expr.ambiguous_term", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "diagnostics" + } + }, + "responses": ["raise an alert"] + } +} diff --git a/fixtures/ears-lint-go-parity/I402.json b/fixtures/ears-lint-go-parity/I402.json new file mode 100644 index 0000000..9a7615d --- /dev/null +++ b/fixtures/ears-lint-go-parity/I402.json @@ -0,0 +1,281 @@ +{ + "id": "I402", + "text": "If sensor fault is detected, the diagnostic system shall store a fault record.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "pattern": "unwanted-behaviour", + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error" + } + ], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "role": "system", + "raw": "diagnostic system" + } + }, + "responses": ["store a fault record"] + } +} diff --git a/fixtures/ears-lint-go-parity/I404.json b/fixtures/ears-lint-go-parity/I404.json new file mode 100644 index 0000000..b6afbe6 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I404.json @@ -0,0 +1,281 @@ +{ + "id": "I404", + "text": "While aircraft is on ground, the engine control system shall shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "pattern": "state-driven", + "diagnostics": [ + { + "code": "ears.multiple_shall", + "severity": "error" + } + ], + "ast": { + "pattern": "state-driven", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["shall enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/I405.json b/fixtures/ears-lint-go-parity/I405.json new file mode 100644 index 0000000..b7908cb --- /dev/null +++ b/fixtures/ears-lint-go-parity/I405.json @@ -0,0 +1,272 @@ +{ + "id": "I405", + "text": "The engine control system enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.missing_shall", + "severity": "error" + } + ] + } +} diff --git a/fixtures/ears-lint-go-parity/I406.json b/fixtures/ears-lint-go-parity/I406.json new file mode 100644 index 0000000..442b7fb --- /dev/null +++ b/fixtures/ears-lint-go-parity/I406.json @@ -0,0 +1,272 @@ +{ + "id": "I406", + "text": "When reverse thrust is commanded, the engine control system.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.missing_shall", + "severity": "error" + } + ] + } +} diff --git a/fixtures/ears-lint-go-parity/I410.json b/fixtures/ears-lint-go-parity/I410.json new file mode 100644 index 0000000..46a29e6 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I410.json @@ -0,0 +1,290 @@ +{ + "id": "I410", + "text": "When reverse thrust is commanded, if emergency braking is active, then the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "trigger": { + "kind": "term", + "span": { + "start": 5, + "end": 32 + } + }, + "unwanted": { + "kind": "term", + "span": { + "start": 37, + "end": 64 + } + }, + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/I501.json b/fixtures/ears-lint-go-parity/I501.json new file mode 100644 index 0000000..f4f3b18 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I501.json @@ -0,0 +1,285 @@ +{ + "id": "I501", + "text": "While aircraft is on ground and, when reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "pattern": "complex", + "diagnostics": [ + { + "code": "expr.invalid_operator_sequence", + "severity": "error", + "span": { + "start": 28, + "end": 31 + } + } + ], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/I502.json b/fixtures/ears-lint-go-parity/I502.json new file mode 100644 index 0000000..4beecee --- /dev/null +++ b/fixtures/ears-lint-go-parity/I502.json @@ -0,0 +1,276 @@ +{ + "id": "I502", + "text": "While (aircraft is on ground and hydraulic pressure is available, when reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "expr.unbalanced_parentheses", + "severity": "error", + "span": { + "start": 0, + "end": 154 + } + } + ] + } +} diff --git a/fixtures/ears-lint-go-parity/I506.json b/fixtures/ears-lint-go-parity/I506.json new file mode 100644 index 0000000..2b7bc26 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I506.json @@ -0,0 +1,281 @@ +{ + "id": "I506", + "text": "While aircraft is on ground or or hydraulic pressure is available, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "pattern": "state-driven", + "diagnostics": [ + { + "code": "expr.invalid_operator_sequence", + "severity": "error" + } + ], + "ast": { + "pattern": "state-driven", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/K901.json b/fixtures/ears-lint-go-parity/K901.json new file mode 100644 index 0000000..fe261a1 --- /dev/null +++ b/fixtures/ears-lint-go-parity/K901.json @@ -0,0 +1,299 @@ +{ + "id": "K901", + "text": "While (aircraft is on ground and (hydraulic pressure is available or (service mode is active and not degraded mode is active))), when ((reverse thrust is commanded) or (emergency braking is active and not data link is lost)), the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [ + { + "code": "catalog.state_ambiguous", + "severity": "warning" + }, + { + "code": "expr.ambiguous_term", + "severity": "warning" + } + ], + "ast": { + "pattern": "complex", + "preconditions": { + "kind": "group", + "span": { + "start": 6, + "end": 127 + } + }, + "trigger": { + "kind": "group", + "span": { + "start": 134, + "end": 224 + } + }, + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-alias-used.json b/fixtures/ears-lint-go-parity/LIB-alias-used.json new file mode 100644 index 0000000..b0e7c4c --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-alias-used.json @@ -0,0 +1,60 @@ +{ + "id": "LIB-alias-used", + "text": "The engine ctrl system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "role": "system", + "raw": "engine ctrl system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json b/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json new file mode 100644 index 0000000..51ffc1a --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json @@ -0,0 +1,70 @@ +{ + "id": "LIB-ambiguous-event", + "text": "When reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ], + "conditions": [ + { + "id": "COND-REV", + "name": "reverse thrust is commanded" + } + ] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "catalog.event_ambiguous", + "severity": "warning" + }, + { + "code": "expr.ambiguous_term", + "severity": "warning" + } + ], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-comma-as-and.json b/fixtures/ears-lint-go-parity/LIB-comma-as-and.json new file mode 100644 index 0000000..30d74cd --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-comma-as-and.json @@ -0,0 +1,56 @@ +{ + "id": "LIB-comma-as-and", + "text": "While aircraft is on ground, hydraulic pressure is available, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-complex-expr.json b/fixtures/ears-lint-go-parity/LIB-complex-expr.json new file mode 100644 index 0000000..0d4014a --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-complex-expr.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-complex-expr", + "text": "While aircraft is on ground and hydraulic pressure is available, when reverse thrust is commanded or emergency braking is active, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-determinism.json b/fixtures/ears-lint-go-parity/LIB-determinism.json new file mode 100644 index 0000000..63495e5 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-determinism.json @@ -0,0 +1,68 @@ +{ + "id": "LIB-determinism", + "text": "When reverse thrust is commanded and, the unknown system shall do something appropriate.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": false, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "expr.invalid_operator_sequence", + "severity": "error" + }, + { + "code": "catalog.system_unresolved", + "severity": "error" + }, + { + "code": "lint.vague_response", + "severity": "warning" + } + ], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "unknown system" + } + }, + "responses": ["do something appropriate"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-event-driven.json b/fixtures/ears-lint-go-parity/LIB-event-driven.json new file mode 100644 index 0000000..a6850c3 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-event-driven.json @@ -0,0 +1,62 @@ +{ + "id": "LIB-event-driven", + "text": "When reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "term", + "span": { + "start": 5, + "end": 32 + } + }, + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json b/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json new file mode 100644 index 0000000..279c509 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-guided-suspicious", + "text": "maybe enable reverse thrust someday", + "options": { + "mode": "guided" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "diagnostics": [ + { + "code": "ears.no_match", + "severity": "warning" + }, + { + "code": "lint.suspicious_text_shape", + "severity": "warning" + } + ] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json b/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json new file mode 100644 index 0000000..3b71685 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json @@ -0,0 +1,60 @@ +{ + "id": "LIB-ifthen-missing-then", + "text": "If reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": false, + "pattern": "unwanted-behaviour", + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error" + } + ], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json b/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json new file mode 100644 index 0000000..adc2def --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-ifthen-valid", + "text": "If reverse thrust is commanded, then the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json b/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json new file mode 100644 index 0000000..5e0f6d8 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json @@ -0,0 +1,60 @@ +{ + "id": "LIB-multiple-trigger", + "text": "When reverse thrust is commanded, when emergency braking is active, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": false, + "pattern": "complex", + "diagnostics": [ + { + "code": "ears.invalid_clause_order", + "severity": "error" + } + ], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-optional-feature.json b/fixtures/ears-lint-go-parity/LIB-optional-feature.json new file mode 100644 index 0000000..97f07f3 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-optional-feature.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-optional-feature", + "text": "Where premium mode is enabled, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-state-driven.json b/fixtures/ears-lint-go-parity/LIB-state-driven.json new file mode 100644 index 0000000..af56088 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-state-driven.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-state-driven", + "text": "While aircraft is on ground, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-system-unresolved.json b/fixtures/ears-lint-go-parity/LIB-system-unresolved.json new file mode 100644 index 0000000..4c88566 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-system-unresolved.json @@ -0,0 +1,60 @@ +{ + "id": "LIB-system-unresolved", + "text": "The unknown system shall do something.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": false, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "catalog.system_unresolved", + "severity": "error" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "role": "system", + "raw": "unknown system" + } + }, + "responses": ["do something"] + } +} diff --git a/fixtures/ears-lint-go-parity/LIB-ubiquitous.json b/fixtures/ears-lint-go-parity/LIB-ubiquitous.json new file mode 100644 index 0000000..05aed61 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ubiquitous.json @@ -0,0 +1,55 @@ +{ + "id": "LIB-ubiquitous", + "text": "The engine control system shall enable reverse thrust.", + "options": { + "mode": "strict" + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENGINE", + "name": "engine control system", + "aliases": ["engine ctrl system"] + } + ], + "events": [ + { + "id": "EVT-REV", + "name": "reverse thrust is commanded" + }, + { + "id": "EVT-EMB", + "name": "emergency braking is active" + } + ], + "states": [ + { + "id": "STATE-GROUND", + "name": "aircraft is on ground" + }, + { + "id": "STATE-HYD", + "name": "hydraulic pressure is available" + } + ], + "features": [ + { + "id": "FEAT-PREMIUM", + "name": "premium mode is enabled" + } + ] + }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/N701.json b/fixtures/ears-lint-go-parity/N701.json new file mode 100644 index 0000000..682f217 --- /dev/null +++ b/fixtures/ears-lint-go-parity/N701.json @@ -0,0 +1,272 @@ +{ + "id": "N701", + "text": "The engine control system should enable reverse thrust when commanded.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.missing_shall", + "severity": "error" + } + ] + } +} diff --git a/fixtures/ears-lint-go-parity/S801.json b/fixtures/ears-lint-go-parity/S801.json new file mode 100644 index 0000000..599c7b1 --- /dev/null +++ b/fixtures/ears-lint-go-parity/S801.json @@ -0,0 +1,276 @@ +{ + "id": "S801", + "text": " While aircraft is on ground , when reverse thrust is commanded , the engine control system shall enable reverse thrust . ", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/S803.json b/fixtures/ears-lint-go-parity/S803.json new file mode 100644 index 0000000..9474760 --- /dev/null +++ b/fixtures/ears-lint-go-parity/S803.json @@ -0,0 +1,276 @@ +{ + "id": "S803", + "text": "IF sensor fault is detected, THEN the diagnostic system shall store a fault record.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "role": "system", + "raw": "diagnostic system" + } + }, + "responses": ["store a fault record"] + } +} diff --git a/fixtures/ears-lint-go-parity/V001.json b/fixtures/ears-lint-go-parity/V001.json new file mode 100644 index 0000000..b5980eb --- /dev/null +++ b/fixtures/ears-lint-go-parity/V001.json @@ -0,0 +1,276 @@ +{ + "id": "V001", + "text": "The brake control system shall apply brake torque.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "system": { + "role": "system", + "raw": "brake control system" + } + }, + "responses": ["apply brake torque"] + } +} diff --git a/fixtures/ears-lint-go-parity/V002.json b/fixtures/ears-lint-go-parity/V002.json new file mode 100644 index 0000000..68c23fe --- /dev/null +++ b/fixtures/ears-lint-go-parity/V002.json @@ -0,0 +1,283 @@ +{ + "id": "V002", + "text": "While aircraft is on ground, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "preconditions": { + "kind": "term", + "span": { + "start": 6, + "end": 27 + } + }, + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V003.json b/fixtures/ears-lint-go-parity/V003.json new file mode 100644 index 0000000..8cbb1f4 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V003.json @@ -0,0 +1,276 @@ +{ + "id": "V003", + "text": "When brake is requested, the brake control system shall apply brake torque.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "brake control system" + } + }, + "responses": ["apply brake torque"] + } +} diff --git a/fixtures/ears-lint-go-parity/V004.json b/fixtures/ears-lint-go-parity/V004.json new file mode 100644 index 0000000..6143b2a --- /dev/null +++ b/fixtures/ears-lint-go-parity/V004.json @@ -0,0 +1,283 @@ +{ + "id": "V004", + "text": "Where sunroof is installed, the door control system shall display the sunroof control.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "feature": { + "kind": "term", + "span": { + "start": 6, + "end": 26 + } + }, + "system": { + "role": "system", + "raw": "door control system" + } + }, + "responses": ["display the sunroof control"] + } +} diff --git a/fixtures/ears-lint-go-parity/V005.json b/fixtures/ears-lint-go-parity/V005.json new file mode 100644 index 0000000..5b7c5d3 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V005.json @@ -0,0 +1,283 @@ +{ + "id": "V005", + "text": "If an invalid credit card number is entered, then the ATM shall display please re-enter credit card details.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "unwanted": { + "kind": "term", + "span": { + "start": 3, + "end": 43 + } + }, + "system": { + "role": "system", + "raw": "ATM" + } + }, + "responses": ["display please re-enter credit card details"] + } +} diff --git a/fixtures/ears-lint-go-parity/V006.json b/fixtures/ears-lint-go-parity/V006.json new file mode 100644 index 0000000..f84ba1d --- /dev/null +++ b/fixtures/ears-lint-go-parity/V006.json @@ -0,0 +1,276 @@ +{ + "id": "V006", + "text": "While aircraft is on ground, when reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V102.json b/fixtures/ears-lint-go-parity/V102.json new file mode 100644 index 0000000..8b52f83 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V102.json @@ -0,0 +1,290 @@ +{ + "id": "V102", + "text": "While aircraft is on ground and (hydraulic pressure is available or service mode is active), when reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "preconditions": { + "kind": "and", + "span": { + "start": 6, + "end": 91 + } + }, + "trigger": { + "kind": "term", + "span": { + "start": 98, + "end": 125 + } + }, + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V105.json b/fixtures/ears-lint-go-parity/V105.json new file mode 100644 index 0000000..73de9b0 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V105.json @@ -0,0 +1,276 @@ +{ + "id": "V105", + "text": "Where remote start is enabled and premium navigation is enabled, the power management system shall precondition the cabin.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "system": { + "role": "system", + "raw": "power management system" + } + }, + "responses": ["precondition the cabin"] + } +} diff --git a/fixtures/ears-lint-go-parity/V108.json b/fixtures/ears-lint-go-parity/V108.json new file mode 100644 index 0000000..4f0f699 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V108.json @@ -0,0 +1,276 @@ +{ + "id": "V108", + "text": "While aircraft is on ground, where reverse thrust is installed, when reverse thrust is commanded, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V109.json b/fixtures/ears-lint-go-parity/V109.json new file mode 100644 index 0000000..f156997 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V109.json @@ -0,0 +1,276 @@ +{ + "id": "V109", + "text": "While aircraft is on ground and not service mode is active, when reverse thrust is commanded or reverse thrust is commanded by pilot, the engine control system shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "engine control system" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V110.json b/fixtures/ears-lint-go-parity/V110.json new file mode 100644 index 0000000..6e06e55 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V110.json @@ -0,0 +1,281 @@ +{ + "id": "V110", + "text": "When low voltage event and (sensor fault is detected or data link is lost), the diagnostic system shall record a degraded-power fault.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "diagnostic system" + } + }, + "responses": ["record a degraded-power fault"] + } +} diff --git a/fixtures/ears-lint-go-parity/V201.json b/fixtures/ears-lint-go-parity/V201.json new file mode 100644 index 0000000..f1c7645 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V201.json @@ -0,0 +1,285 @@ +{ + "id": "V201", + "text": "When brake request, the brake controller shall apply brake torque.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "event-driven", + "system": { + "role": "system", + "raw": "brake controller" + } + }, + "responses": ["apply brake torque"] + } +} diff --git a/fixtures/ears-lint-go-parity/V202.json b/fixtures/ears-lint-go-parity/V202.json new file mode 100644 index 0000000..e4d965b --- /dev/null +++ b/fixtures/ears-lint-go-parity/V202.json @@ -0,0 +1,289 @@ +{ + "id": "V202", + "text": "While on ground, when reverse thrust commanded, the FADEC shall enable reverse thrust.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "complex", + "system": { + "role": "system", + "raw": "FADEC" + } + }, + "responses": ["enable reverse thrust"] + } +} diff --git a/fixtures/ears-lint-go-parity/V204.json b/fixtures/ears-lint-go-parity/V204.json new file mode 100644 index 0000000..1dd0df8 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V204.json @@ -0,0 +1,285 @@ +{ + "id": "V204", + "text": "Where premium nav, the nav system shall display turn-by-turn guidance.", + "options": { + "mode": "strict", + "commaAsAnd": true + }, + "catalog": { + "systems": [ + { + "id": "SYS-ENG-CTRL", + "name": "engine control system", + "aliases": ["FADEC", "ECS"] + }, + { + "id": "SYS-ENV-CTRL", + "name": "environmental control system", + "aliases": ["ECS"] + }, + { + "id": "SYS-BRAKE-CTRL", + "name": "brake control system", + "aliases": ["brake controller", "BCS"] + }, + { + "id": "SYS-DIAG", + "name": "diagnostic system", + "aliases": ["diagnostics"] + }, + { + "id": "SYS-POWER-MGMT", + "name": "power management system", + "aliases": ["PMS"] + }, + { + "id": "SYS-NAV", + "name": "navigation system", + "aliases": ["nav system"] + }, + { + "id": "SYS-DOOR-CTRL", + "name": "door control system", + "aliases": ["door controller"] + }, + { + "id": "SYS-ATM", + "name": "ATM", + "aliases": ["cash machine"] + } + ], + "actors": [ + { + "id": "ACT-PILOT", + "name": "pilot", + "aliases": ["flight crew member"] + }, + { + "id": "ACT-FLIGHT-CREW", + "name": "flight crew", + "aliases": ["crew"] + }, + { + "id": "ACT-DRIVER", + "name": "driver" + }, + { + "id": "ACT-MAINT", + "name": "maintenance technician", + "aliases": ["technician", "maintainer"] + }, + { + "id": "ACT-USER", + "name": "user", + "aliases": ["operator"] + } + ], + "events": [ + { + "id": "EVT-REV-CMD", + "name": "reverse thrust is commanded", + "aliases": ["reverse thrust command", "reverse thrust commanded"] + }, + { + "id": "EVT-REV-CMD-PILOT", + "name": "reverse thrust is commanded by pilot", + "aliases": ["reverse thrust command"] + }, + { + "id": "EVT-BRAKE-REQ", + "name": "brake is requested", + "aliases": ["brake request", "brake requested"] + }, + { + "id": "EVT-EMERG-BRAKE", + "name": "emergency braking is active", + "aliases": ["emergency braking"] + }, + { + "id": "EVT-SENSOR-FAULT", + "name": "sensor fault is detected", + "aliases": ["sensor fault"] + }, + { + "id": "EVT-LINK-LOSS", + "name": "data link is lost", + "aliases": ["link loss"] + }, + { + "id": "EVT-DOOR-OPEN-CMD", + "name": "door open is commanded", + "aliases": ["door open command"] + }, + { + "id": "EVT-DOOR-CLOSE-CMD", + "name": "door close is commanded", + "aliases": ["door close command"] + }, + { + "id": "EVT-INVALID-CARD", + "name": "an invalid credit card number is entered", + "aliases": ["invalid credit card number"] + }, + { + "id": "EVT-LOW-VOLTAGE", + "name": "bus voltage falls below threshold", + "aliases": ["low voltage event"] + } + ], + "states": [ + { + "id": "STATE-ONGROUND", + "name": "aircraft is on ground", + "aliases": ["on ground", "aircraft on ground"] + }, + { + "id": "STATE-INFLIGHT", + "name": "aircraft is in flight", + "aliases": ["in flight"] + }, + { + "id": "STATE-HYD-PRESS", + "name": "hydraulic pressure is available", + "aliases": ["hydraulic pressure available"] + }, + { + "id": "STATE-SERVICE-MODE", + "name": "service mode is active", + "aliases": ["service mode"] + }, + { + "id": "STATE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "STATE-RUNWAY-MODE", + "name": "runway mode is active", + "aliases": ["runway mode"] + }, + { + "id": "STATE-AUTOPILOT", + "name": "autopilot is engaged", + "aliases": ["autopilot engaged"] + }, + { + "id": "STATE-CARD-ABSENT", + "name": "there is no card in the ATM", + "aliases": ["no card in the ATM"] + }, + { + "id": "STATE-DOOR-OPEN", + "name": "the door is open", + "aliases": ["door is open"] + }, + { + "id": "STATE-DOOR-CLOSED", + "name": "the door is closed", + "aliases": ["door is closed"] + } + ], + "features": [ + { + "id": "FEAT-REV", + "name": "reverse thrust is installed", + "aliases": ["reverse thrust installed"] + }, + { + "id": "FEAT-REMOTE-START", + "name": "remote start is enabled", + "aliases": ["remote start"] + }, + { + "id": "FEAT-PREMIUM-NAV", + "name": "premium navigation is enabled", + "aliases": ["premium navigation", "premium nav"] + }, + { + "id": "FEAT-SUNROOF", + "name": "sunroof is installed", + "aliases": ["sunroof"] + }, + { + "id": "FEAT-DATALINK", + "name": "data link is installed", + "aliases": ["data link installed"] + } + ], + "modes": [ + { + "id": "MODE-NORMAL", + "name": "normal mode is active", + "aliases": ["normal mode"] + }, + { + "id": "MODE-DEGRADED", + "name": "degraded mode is active", + "aliases": ["degraded mode"] + }, + { + "id": "MODE-MAINT", + "name": "maintenance mode is active", + "aliases": ["maintenance mode"] + } + ], + "conditions": [ + { + "id": "COND-OVERTEMP", + "name": "engine overtemperature exists", + "aliases": ["engine overtemperature"] + }, + { + "id": "COND-POWER-UNSTABLE", + "name": "power is unstable", + "aliases": ["unstable power"] + }, + { + "id": "COND-SAFE-TO-OPEN", + "name": "it is safe to open the door", + "aliases": ["safe to open"] + }, + { + "id": "COND-SAFE-TO-CLOSE", + "name": "it is safe to close the door", + "aliases": ["safe to close"] + } + ], + "dataTerms": [ + { + "id": "DATA-BUS-VOLTAGE", + "name": "bus voltage" + }, + { + "id": "DATA-BRAKE-TORQUE", + "name": "brake torque" + }, + { + "id": "DATA-CREDIT-CARD-NUMBER", + "name": "credit card number", + "aliases": ["card number"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + }, + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "optional-feature", + "system": { + "role": "system", + "raw": "nav system" + } + }, + "responses": ["display turn-by-turn guidance"] + } +} diff --git a/fixtures/host-repos/empty/.gitkeep b/fixtures/host-repos/empty/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md b/fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md new file mode 100644 index 0000000..97ca16a --- /dev/null +++ b/fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md @@ -0,0 +1,5 @@ +# Checkout requirements + +#### Acceptance Criteria + +- WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature diff --git a/fixtures/host-repos/multi/.cursor/rules.md b/fixtures/host-repos/multi/.cursor/rules.md new file mode 100644 index 0000000..a7570e8 --- /dev/null +++ b/fixtures/host-repos/multi/.cursor/rules.md @@ -0,0 +1,3 @@ +# Cursor rules + +Project rules. diff --git a/fixtures/host-repos/multi/.github/prompts/example.prompt.md b/fixtures/host-repos/multi/.github/prompts/example.prompt.md new file mode 100644 index 0000000..3d60191 --- /dev/null +++ b/fixtures/host-repos/multi/.github/prompts/example.prompt.md @@ -0,0 +1,3 @@ +# Example prompt + +Do the thing. diff --git a/fixtures/host-repos/multi/.kiro/specs/checkout/requirements.md b/fixtures/host-repos/multi/.kiro/specs/checkout/requirements.md new file mode 100644 index 0000000..97ca16a --- /dev/null +++ b/fixtures/host-repos/multi/.kiro/specs/checkout/requirements.md @@ -0,0 +1,5 @@ +# Checkout requirements + +#### Acceptance Criteria + +- WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature diff --git a/fixtures/host-repos/multi/.specify/config.yml b/fixtures/host-repos/multi/.specify/config.yml new file mode 100644 index 0000000..bc69615 --- /dev/null +++ b/fixtures/host-repos/multi/.specify/config.yml @@ -0,0 +1 @@ +name: demo diff --git a/fixtures/host-repos/multi/AGENTS.md b/fixtures/host-repos/multi/AGENTS.md new file mode 100644 index 0000000..f727834 --- /dev/null +++ b/fixtures/host-repos/multi/AGENTS.md @@ -0,0 +1,3 @@ +# Agent instructions + +Generic agent guidance. diff --git a/fixtures/host-repos/multi/GEMINI.md b/fixtures/host-repos/multi/GEMINI.md new file mode 100644 index 0000000..5c6118c --- /dev/null +++ b/fixtures/host-repos/multi/GEMINI.md @@ -0,0 +1,3 @@ +# Gemini instructions + +Gemini guidance. diff --git a/fixtures/host-repos/multi/openspec/specs/core/spec.md b/fixtures/host-repos/multi/openspec/specs/core/spec.md new file mode 100644 index 0000000..cb63987 --- /dev/null +++ b/fixtures/host-repos/multi/openspec/specs/core/spec.md @@ -0,0 +1,5 @@ +# Core spec + +### Requirement: Core + +The system shall start cleanly. diff --git a/fixtures/host-repos/openspec/openspec/changes/add-refunds/proposal.md b/fixtures/host-repos/openspec/openspec/changes/add-refunds/proposal.md new file mode 100644 index 0000000..2c465aa --- /dev/null +++ b/fixtures/host-repos/openspec/openspec/changes/add-refunds/proposal.md @@ -0,0 +1,5 @@ +# Add refunds + +## Why + +Customers need refunds. diff --git a/fixtures/host-repos/openspec/openspec/specs/checkout/spec.md b/fixtures/host-repos/openspec/openspec/specs/checkout/spec.md new file mode 100644 index 0000000..98fc91d --- /dev/null +++ b/fixtures/host-repos/openspec/openspec/specs/checkout/spec.md @@ -0,0 +1,5 @@ +# Checkout spec + +### Requirement: Confirm order + +The system shall confirm the order when payment succeeds. diff --git a/fixtures/host-repos/speckit/specs/checkout/spec.md b/fixtures/host-repos/speckit/specs/checkout/spec.md new file mode 100644 index 0000000..3f72171 --- /dev/null +++ b/fixtures/host-repos/speckit/specs/checkout/spec.md @@ -0,0 +1,3 @@ +# Checkout spec + +The system shall confirm the order when payment succeeds. diff --git a/fixtures/invalid/catalog-system-ambiguous.json b/fixtures/invalid/catalog-system-ambiguous.json new file mode 100644 index 0000000..a5aa6a3 --- /dev/null +++ b/fixtures/invalid/catalog-system-ambiguous.json @@ -0,0 +1,31 @@ +{ + "id": "INV-014", + "text": "The BFF shall verify the HMAC signature.", + "catalog": { + "systems": [ + { + "id": "SYS-BILLING", + "name": "billing service", + "aliases": ["BFF"] + }, + { + "id": "SYS-BACKEND", + "name": "backend for frontend", + "aliases": ["BFF"] + } + ] + }, + "options": { + "mode": "strict" + }, + "expected": { + "valid": false, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "catalog.system_ambiguous", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/catalog-system-unresolved.json b/fixtures/invalid/catalog-system-unresolved.json new file mode 100644 index 0000000..459f2c2 --- /dev/null +++ b/fixtures/invalid/catalog-system-unresolved.json @@ -0,0 +1,26 @@ +{ + "id": "INV-013", + "text": "The reconciliation engine shall verify the HMAC signature.", + "catalog": { + "systems": [ + { + "id": "SYS-BILLING", + "name": "billing service", + "aliases": ["BFF"] + } + ] + }, + "options": { + "mode": "strict" + }, + "expected": { + "valid": false, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "catalog.system_unresolved", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/empty-clause.json b/fixtures/invalid/empty-clause.json new file mode 100644 index 0000000..c952ef7 --- /dev/null +++ b/fixtures/invalid/empty-clause.json @@ -0,0 +1,13 @@ +{ + "id": "INV-005", + "text": "When , the billing service shall verify the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.empty_clause", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/empty-response.json b/fixtures/invalid/empty-response.json new file mode 100644 index 0000000..4bfabaa --- /dev/null +++ b/fixtures/invalid/empty-response.json @@ -0,0 +1,13 @@ +{ + "id": "INV-004", + "text": "The billing service shall .", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.empty_response", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/empty-subexpression.json b/fixtures/invalid/empty-subexpression.json new file mode 100644 index 0000000..127024c --- /dev/null +++ b/fixtures/invalid/empty-subexpression.json @@ -0,0 +1,13 @@ +{ + "id": "INV-012", + "text": "When a payment webhook is received and (), the billing service shall verify the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "expr.empty_subexpression", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/frame-metadata-strict.json b/fixtures/invalid/frame-metadata-strict.json new file mode 100644 index 0000000..d9e59cc --- /dev/null +++ b/fixtures/invalid/frame-metadata-strict.json @@ -0,0 +1,8 @@ +{ + "id": "INV-FRAME", + "text": "REQ-001 When a payment webhook is received, the billing service shall verify the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [{ "code": "ears.no_match", "severity": "error" }] + } +} diff --git a/fixtures/invalid/if-without-then.json b/fixtures/invalid/if-without-then.json new file mode 100644 index 0000000..d12cac5 --- /dev/null +++ b/fixtures/invalid/if-without-then.json @@ -0,0 +1,14 @@ +{ + "id": "INV-006", + "text": "If the HMAC signature is invalid, the billing service shall reject the webhook.", + "expected": { + "valid": false, + "pattern": "unwanted-behaviour", + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/invalid-clause-order.json b/fixtures/invalid/invalid-clause-order.json new file mode 100644 index 0000000..40be724 --- /dev/null +++ b/fixtures/invalid/invalid-clause-order.json @@ -0,0 +1,14 @@ +{ + "id": "INV-007", + "text": "When a payment webhook is received, while the payment provider is available, the billing service shall queue the event.", + "expected": { + "valid": false, + "pattern": "complex", + "diagnostics": [ + { + "code": "ears.invalid_clause_order", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/invalid-operator-sequence-trailing-and.json b/fixtures/invalid/invalid-operator-sequence-trailing-and.json new file mode 100644 index 0000000..808cae1 --- /dev/null +++ b/fixtures/invalid/invalid-operator-sequence-trailing-and.json @@ -0,0 +1,16 @@ +{ + "id": "INV-011", + "text": "While the payment provider is available and, when a payment webhook is received, the billing service shall verify the HMAC signature.", + "options": { + "commaAsAnd": true + }, + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "expr.invalid_operator_sequence", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/invalid-operator-sequence.json b/fixtures/invalid/invalid-operator-sequence.json new file mode 100644 index 0000000..5c279fd --- /dev/null +++ b/fixtures/invalid/invalid-operator-sequence.json @@ -0,0 +1,13 @@ +{ + "id": "INV-010", + "text": "When a payment webhook is received or or a refund is requested, the billing service shall open a dispute case.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "expr.invalid_operator_sequence", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/keyword-case-lowercase-when.json b/fixtures/invalid/keyword-case-lowercase-when.json new file mode 100644 index 0000000..56928f8 --- /dev/null +++ b/fixtures/invalid/keyword-case-lowercase-when.json @@ -0,0 +1,9 @@ +{ + "id": "INV-D007", + "text": "when a payment webhook is received, the billing service shall verify the HMAC signature.", + "expected": { + "valid": false, + "pattern": "event-driven", + "diagnostics": [{ "code": "ears.keyword_case", "severity": "error" }] + } +} diff --git a/fixtures/invalid/missing-leading-comma.json b/fixtures/invalid/missing-leading-comma.json new file mode 100644 index 0000000..3ce52bc --- /dev/null +++ b/fixtures/invalid/missing-leading-comma.json @@ -0,0 +1,9 @@ +{ + "id": "INV-D001", + "text": "When a payment webhook is received the billing service shall verify the HMAC signature.", + "expected": { + "valid": false, + "pattern": "event-driven", + "diagnostics": [{ "code": "ears.missing_leading_comma", "severity": "error" }] + } +} diff --git a/fixtures/invalid/missing-shall.json b/fixtures/invalid/missing-shall.json new file mode 100644 index 0000000..0ae9ebb --- /dev/null +++ b/fixtures/invalid/missing-shall.json @@ -0,0 +1,13 @@ +{ + "id": "INV-001", + "text": "The billing service verifies the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.missing_shall", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/missing-system.json b/fixtures/invalid/missing-system.json new file mode 100644 index 0000000..a5f78de --- /dev/null +++ b/fixtures/invalid/missing-system.json @@ -0,0 +1,13 @@ +{ + "id": "INV-003", + "text": "When a payment webhook is received, shall verify the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.missing_system", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/multiple-shall.json b/fixtures/invalid/multiple-shall.json new file mode 100644 index 0000000..cfb70a6 --- /dev/null +++ b/fixtures/invalid/multiple-shall.json @@ -0,0 +1,14 @@ +{ + "id": "INV-002", + "text": "While the payment provider is unavailable, the billing service shall shall queue retryable events.", + "expected": { + "valid": false, + "pattern": "state-driven", + "diagnostics": [ + { + "code": "ears.multiple_shall", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/multiple-when.json b/fixtures/invalid/multiple-when.json new file mode 100644 index 0000000..0c22e2c --- /dev/null +++ b/fixtures/invalid/multiple-when.json @@ -0,0 +1,9 @@ +{ + "id": "INV-D003", + "text": "When a payment webhook is received, when a refund is requested, the billing service shall open a dispute case.", + "expected": { + "valid": false, + "pattern": "complex", + "diagnostics": [{ "code": "ears.invalid_clause_order", "severity": "error" }] + } +} diff --git a/fixtures/invalid/no-match.json b/fixtures/invalid/no-match.json new file mode 100644 index 0000000..c78c572 --- /dev/null +++ b/fixtures/invalid/no-match.json @@ -0,0 +1,13 @@ +{ + "id": "INV-008", + "text": "Payment webhooks are important for billing accuracy.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "ears.no_match", + "severity": "error" + } + ] + } +} diff --git a/fixtures/invalid/prohibition-strict.json b/fixtures/invalid/prohibition-strict.json new file mode 100644 index 0000000..acc1be9 --- /dev/null +++ b/fixtures/invalid/prohibition-strict.json @@ -0,0 +1,9 @@ +{ + "id": "INV-D004", + "text": "The billing service shall not log the payment token.", + "expected": { + "valid": false, + "pattern": "ubiquitous", + "diagnostics": [{ "code": "ears.prohibition_not_allowed", "severity": "error" }] + } +} diff --git a/fixtures/invalid/pronoun-system.json b/fixtures/invalid/pronoun-system.json new file mode 100644 index 0000000..381bf1b --- /dev/null +++ b/fixtures/invalid/pronoun-system.json @@ -0,0 +1,8 @@ +{ + "id": "INV-D005", + "text": "When a payment webhook is received, it shall verify the HMAC signature.", + "expected": { + "valid": false, + "diagnostics": [{ "code": "ears.missing_system", "severity": "error" }] + } +} diff --git a/fixtures/invalid/then-outside-if.json b/fixtures/invalid/then-outside-if.json new file mode 100644 index 0000000..a79cf94 --- /dev/null +++ b/fixtures/invalid/then-outside-if.json @@ -0,0 +1,9 @@ +{ + "id": "INV-D002", + "text": "When a payment webhook is received, the billing service shall then reject the webhook.", + "expected": { + "valid": false, + "pattern": "event-driven", + "diagnostics": [{ "code": "ears.invalid_if_then_form", "severity": "error" }] + } +} diff --git a/fixtures/invalid/unbalanced-parentheses.json b/fixtures/invalid/unbalanced-parentheses.json new file mode 100644 index 0000000..2cc8e8b --- /dev/null +++ b/fixtures/invalid/unbalanced-parentheses.json @@ -0,0 +1,13 @@ +{ + "id": "INV-009", + "text": "While (the payment provider is available and the retry queue is not full, the billing service shall queue retryable events.", + "expected": { + "valid": false, + "diagnostics": [ + { + "code": "expr.unbalanced_parentheses", + "severity": "error" + } + ] + } +} diff --git a/fixtures/pipeline/kiro-requirements.candidates.json b/fixtures/pipeline/kiro-requirements.candidates.json new file mode 100644 index 0000000..d3bdf4e --- /dev/null +++ b/fixtures/pipeline/kiro-requirements.candidates.json @@ -0,0 +1,18 @@ +[ + { + "file": "kiro-requirements.md", + "line": 7, + "col": 4, + "text": "WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro-requirements.md", + "line": 8, + "col": 4, + "text": "IF the signature is invalid THEN THE SYSTEM SHALL reject the webhook.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + } +] diff --git a/fixtures/pipeline/kiro-requirements.md b/fixtures/pipeline/kiro-requirements.md new file mode 100644 index 0000000..9aa9b51 --- /dev/null +++ b/fixtures/pipeline/kiro-requirements.md @@ -0,0 +1,16 @@ +# Checkout feature + +**User Story:** As a shopper, I want to pay, so that I can complete my order. + +#### Acceptance Criteria + +1. WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature. +2. IF the signature is invalid THEN THE SYSTEM SHALL reject the webhook. + +## Design notes + +- This bullet sits outside acceptance criteria and is not a requirement. + +```text +- WHEN this fenced bullet appears THE SYSTEM SHALL be ignored. +``` diff --git a/fixtures/pipeline/openspec-spec.candidates.json b/fixtures/pipeline/openspec-spec.candidates.json new file mode 100644 index 0000000..c0e8d00 --- /dev/null +++ b/fixtures/pipeline/openspec-spec.candidates.json @@ -0,0 +1,26 @@ +[ + { + "file": "openspec-spec.md", + "line": 5, + "col": 1, + "text": "The billing service shall verify the signature.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec-spec.md", + "line": 9, + "col": 1, + "text": "If the signature is invalid, then the billing service shall reject the webhook.", + "profile": "openspec", + "locatorRuleId": "openspec.scenario" + }, + { + "file": "openspec-spec.md", + "line": 13, + "col": 1, + "text": "The billing service shall log every attempt.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + } +] diff --git a/fixtures/pipeline/openspec-spec.md b/fixtures/pipeline/openspec-spec.md new file mode 100644 index 0000000..7181b67 --- /dev/null +++ b/fixtures/pipeline/openspec-spec.md @@ -0,0 +1,13 @@ +## Signature handling + +### Requirement: Verify signatures + +The billing service shall verify the signature. + +#### Scenario: invalid signature + +If the signature is invalid, then the billing service shall reject the webhook. + +### Requirement: Audit + +The billing service shall log every attempt. diff --git a/fixtures/pipeline/speckit-spec.candidates.json b/fixtures/pipeline/speckit-spec.candidates.json new file mode 100644 index 0000000..e38f818 --- /dev/null +++ b/fixtures/pipeline/speckit-spec.candidates.json @@ -0,0 +1,20 @@ +[ + { + "file": "speckit-spec.md", + "line": 5, + "col": 15, + "text": "The billing service shall verify the signature.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-001" + }, + { + "file": "speckit-spec.md", + "line": 6, + "col": 15, + "text": "When a payment webhook arrives, the billing service shall record the attempt.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-002" + } +] diff --git a/fixtures/pipeline/speckit-spec.md b/fixtures/pipeline/speckit-spec.md new file mode 100644 index 0000000..ec464bc --- /dev/null +++ b/fixtures/pipeline/speckit-spec.md @@ -0,0 +1,10 @@ +# Feature spec + +## Requirements + +- **FR-001**: The billing service shall verify the signature. +- **FR-002**: When a payment webhook arrives, the billing service shall record the attempt. + +## Design + +When the cache warms, this design note merely opens with an EARS keyword and must not be extracted. diff --git a/fixtures/pipeline/strict-basic.candidates.json b/fixtures/pipeline/strict-basic.candidates.json new file mode 100644 index 0000000..443f096 --- /dev/null +++ b/fixtures/pipeline/strict-basic.candidates.json @@ -0,0 +1,18 @@ +[ + { + "file": "strict-basic.ears", + "line": 3, + "col": 1, + "text": "The billing service shall verify the signature.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict-basic.ears", + "line": 4, + "col": 1, + "text": "When a payment webhook arrives, the billing service shall record the attempt.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + } +] diff --git a/fixtures/pipeline/strict-basic.ears b/fixtures/pipeline/strict-basic.ears new file mode 100644 index 0000000..ad635cf --- /dev/null +++ b/fixtures/pipeline/strict-basic.ears @@ -0,0 +1,4 @@ +# Billing requirements + +The billing service shall verify the signature. +When a payment webhook arrives, the billing service shall record the attempt. diff --git a/fixtures/profiles/README.md b/fixtures/profiles/README.md new file mode 100644 index 0000000..cd4da45 --- /dev/null +++ b/fixtures/profiles/README.md @@ -0,0 +1,146 @@ +# Profile fixtures + +These fixtures are the executable definition of each built-in profile. A profile +is data (see `docs/contracts/profile.md`); its fixtures pin what that data means +for real host content: which lines a profile locates, which requirements it +accepts, and which diagnostics it emits on the requirements it rejects. + +Each profile owns one directory under `fixtures/profiles/`. Within a directory, +the fixtures share a common layout so a single runner can drive every profile. + +## Layout + +For each profile there are three kinds of file: + +| File | Purpose | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.ears` (or host document) | The input document. One requirement per line for `.ears`; the host's native document shape for markdown profiles. | +| `.expected.json` | The lint expectation for that document under the profile: findings summary plus a per-line list of the diagnostics the profile should emit. | +| `.candidates.json` | The extract expectation: the candidate list the profile's locator yields, in the `Candidate` field shape (`file`, `line`, `col`, `text`, `profile`, `locatorRuleId`, optional `requirementId`). Snapshot target for the `extract` command. | + +A profile that draws a line between accept and reject (for example strict versus +ears-x on `shall not`) records both sides. Same-document, two-profile pairs use +a per-line `strict` / `ears-x` block inside one `*.expected.json` so one file +drives both sides of the ruling. Cross-profile summaries live in `matrix.json`. + +### `matrix.json` + +`matrix.json` at the root of `fixtures/profiles/` is the cross-profile summary. +Each row names a fixture and its expected `ok` result under each profile that +document targets, with a short note. It carries the superset invariant statement +and the strict-fail / profile-pass pairs at a glance; per-diagnostic detail +stays in each fixture's `*.expected.json`. + +### Expectation and verification fields + +Every `*.expected.json` line entry uses: + +- `expect`: array of `{ id, code, severity }` the profile should emit for that + line. Empty means the line is clean. `id` is the registry id + (`EARS-E###` / `EARS-W###`); `code` is the dotted alias, included for + readability. +- `ruling`: the `ES-D-###` decision from `GRAMMAR.md` the line witnesses, where + one applies. +- `verification`: `verified-core-api` when the expectation was confirmed against + `lintEars` in the current parser, or `pending-c4b` when it depends on a + host-native grammar ruling not yet wired into the parser. Pending entries also + carry a `currentBehavior` note describing what the parser does today, so the + gap is explicit rather than silent. + +## strict + +`strict` is canonical Mavin EARS and the default profile. It locates every +non-empty line of `.ears` and plain-text files (`strict.every-line`), applies no +dialect tolerances, requires no id, and sets no severity overrides. + +| Fixture | What it pins | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `strict/valid.ears` | One clean document across all six patterns (ubiquitous, state-driven `While`, event-driven `When`, optional-feature `Where`, unwanted-behaviour `If`-`then`, and two complex combinations: `While`+`When`, `Where`+`If`). Zero diagnostics. | +| `strict/invalid.ears` | One violation per line, each mapped to the exact registry id and the `ES-D` ruling it breaks: keyword case (`EARS-E014`, ES-D-007), leading comma (`EARS-E015`, ES-D-001), prohibition (`EARS-E016`, ES-D-004), If without then (`EARS-E006`, ES-D-002), two triggers (`EARS-E005`, ES-D-003), pronoun system (`EARS-E008`, ES-D-005), double shall (`EARS-E009`, ES-D-008). | + +`strict/valid.ears` is also the superset anchor: `matrix.json` asserts it +validates identically under ears-x. + +## ears-x + +`ears-x` is a strict superset. Every strict-valid requirement is ears-x-valid +unchanged; ears-x only adds tolerances (frame metadata, `[source:]` tags, and +`shall not` prohibition) and an optional `REQ-###` id shape. It uses the same +`every-line` locator over `.ears` and plain text. + +| Fixture | What it pins | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ears-x/prohibition.ears` | `shall not` prohibitions across ubiquitous, state-driven, and event-driven forms. Valid under ears-x (`allowProhibition`), rejected under strict with `EARS-E016`. The paired `*.expected.json` records both profile results per line. | +| `ears-x/frame-metadata.ears` | `REQ-###` frame ids and `[source: path:line]` tags. Valid under ears-x (`allowFrameMetadata`; ids match `idFormat.pattern` `^REQ-\d+$`), rejected under strict where the prefixed line matches no shell pattern (`EARS-E010`). | + +## kiro + +`kiro` validates EARS embedded in a Kiro `requirements.md`. It relaxes casing to +case-insensitive, accepts the literal `THE SYSTEM` as the system name, makes the +leading-clause comma optional, and skips user-story wrapper lines as frame +content. Its locator targets bullet and numbered list items under a +`#### Acceptance Criteria` heading (`kiro.acceptance-criteria-item`), ignores +code fences, and sets `EARS-W011` and `EARS-W014` to `off` so Kiro's free-form +domain text stays clean when a catalog is supplied. The host document keeps its +realistic name (`requirements.md`, `design.md`); its sidecars follow the uniform +`.expected.json` / `.candidates.json` convention above. + +| Fixture | What it pins | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kiro/requirements.md` | A real-world-shaped Kiro spec: three requirements, each a user story plus a `#### Acceptance Criteria` numbered list, in Kiro house style (all-caps `WHEN`/`IF`/`WHILE`/`WHERE`/`THEN`/`SHALL`, literal `THE SYSTEM`, no leading comma). Covers event-driven, unwanted-behaviour, state-driven, optional-feature, and ubiquitous forms. Clean under kiro; fails under strict. | +| `kiro/requirements.expected.json` | Per-criterion, two-profile expectation (per-line `kiro` / `strict` block). `kiro` is `ok: true` with empty `expect` on every line; `strict` is `ok: false` with `EARS-E014` (casing, ES-D-007) plus `EARS-E015` (missing leading comma, ES-D-001) for rows with a leading clause, and `EARS-E014` alone for the ubiquitous row. Carries the top-level `verification: pending-c4b` and `currentBehavior` note: the parser does not yet consume the dialect block. | +| `kiro/requirements.candidates.json` | The nine candidates the kiro locator yields from `requirements.md`, in the Candidate shape (`file`/`line`/`col`/`text`/`profile`/`locatorRuleId`). Snapshot target for `extract`; the `col` and marker-stripping conventions are documented in the file, pending reconciliation with the extractor (W2-pipeline). | +| `kiro/design.md` | False-positive guard: a Kiro-shaped design document with EARS keywords in narrative prose and in a fenced code block, and no `#### Acceptance Criteria` heading. | +| `kiro/design.candidates.json` | Asserts the guard yields zero candidates: no Acceptance Criteria heading to anchor the list-item rule, and `codeFences: ignore` removes the fenced requirement-looking lines. | +| `kiro/NOTES.md` | Observed Kiro conventions (sourced from kiro.dev docs) and exactly what the profile relaxes relative to strict. | + +## speckit + +`speckit` validates EARS embedded in a Spec Kit `specs/**/spec.md`. Its dialect +is byte-for-byte `strict`: no casing, literal-system-name, leading-comma, +story-wrapper, frame-metadata, or prohibition relaxation. The profile differs +from strict only in its markdown locator. The include rule is a +`heading-section` on `^(functional )?requirements$` (`speckit.requirements-section`), +matching both `## Requirements` and `### Functional Requirements`; the exclude +rule (`speckit.non-requirement-section`) drops sibling prose subsections +(`### Key Entities`, design, background, and similar) that Spec Kit nests inside +or beside the Requirements section. Code fences are ignored and no `idFormat` +pattern is set; the `FR-###` label is treated as list-item structure the +extractor strips, not as a grammar tolerance (see `speckit/NOTES.md`). The host +documents keep their realistic names (`spec.md`, `plan.md`); their sidecars +follow the uniform `.candidates.json` / `.expected.json` convention +above. + +| Fixture | What it pins | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `speckit/spec.md` | A real-Spec-Kit-shaped feature spec: full section layout (User Scenarios, Requirements, Key Entities, Success Criteria, Assumptions) with five `- **FR-###**:` items in adopted-EARS house style, one per template (ubiquitous, event-driven, state-driven, optional-feature, unwanted-behaviour). Clean under speckit; identical verdict under strict. | +| `speckit/spec.candidates.json` | The five candidates the speckit locator yields from `spec.md`, in the `Candidate` shape (`file`/`line`/`col`/`text`/`profile`/`locatorRuleId`/`requirementId`). Also records `skippedButKeywordOpening`: narrative lines that open with `When`/`While`/`Where`/`If` and one Key Entities bullet, each with why it is not a candidate. Pending-verification. | +| `speckit/spec.expected.json` | The lint expectation: `spec.md` and `plan.md` both clean under speckit, plus a `strictDelta` block stating that no clean-under-speckit / fail-under-strict pair applies because no grammar field is relaxed. Pending-verification. | +| `speckit/plan.md` | Locator skip fixture: a Spec Kit `plan.md` with no Requirements heading. Contains keyword-opening narrative and one fully strict-valid EARS sentence in Phase 0, none of which is under a requirements heading. | +| `speckit/plan.candidates.json` | Asserts the skip fixture yields zero candidates and lists the keyword-opening decoys that are correctly ignored. Pending-verification. | +| `speckit/NOTES.md` | Observed Spec Kit conventions (sourced from `github/spec-kit`), the locator rationale, and the strict-grammar / FR-label decision. | + +## openspec + +`openspec` validates EARS embedded in OpenSpec specs and change deltas. Its +dialect is byte-for-byte `strict`: no casing, literal-system-name, leading-comma, +story-wrapper, frame-metadata, or prohibition relaxation. The profile differs +from strict only in its markdown locator and `documentKinds: ['markdown']`. Two +`block` rules locate requirements: `openspec.requirement` on `### Requirement:` +carries the one EARS statement per requirement, and `openspec.scenario` on +`#### Scenario:` is retained per contract but yields no candidate for standard +Gherkin `- **WHEN**/**THEN**/**AND**` steps. A block's candidate is its first +EARS-shaped body line, so scenario steps are frame content. Code fences are +ignored and no `idFormat` pattern is set. Extraction is delta-section-agnostic: +`## ADDED`/`## MODIFIED`/`## REMOVED` are H2 organizational headers, not locator +targets. The openspec directory uses its own descriptive file set, listed here. + +| Fixture | What it pins | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `openspec/spec.md` | A real-OpenSpec-shaped base capability spec (`openspec/specs//spec.md`): `## Purpose`, a `## Command Syntax` fence, and five `### Requirement:` blocks with Gherkin scenarios, one per template (ubiquitous, event-driven, state-driven, optional-feature, unwanted-behaviour). Clean under openspec. | +| `openspec/change.md` | A change delta (`openspec/changes//specs//spec.md`) with `## ADDED`, `## MODIFIED`, and `## REMOVED` sections, demonstrating delta-section-agnostic extraction. Clean under openspec. | +| `openspec/spec.candidates.json` | The five candidates the openspec locator yields from `spec.md`, in the `Candidate` shape (`file`/`line`/`col`/`text`/`profile`/`locatorRuleId`). Pending-verification (extractor not yet wired); candidate statements confirmed clean against `lintEars`. | +| `openspec/change.candidates.json` | The four candidates from `change.md` (2 ADDED, 1 MODIFIED, 1 REMOVED), same shape. Pending-verification; candidate statements confirmed clean against `lintEars`. | +| `openspec/project.md` | False-positive guard: an `openspec/project.md`-shaped context document with keyword-opening prose and a fenced block, and no `### Requirement:` / `#### Scenario:` heading. | +| `openspec/project.candidates.json` | Asserts the guard yields zero candidates: no requirement or scenario block opens, so the block locator has nothing to target. Lists the `keywordOpeningDecoys` that are correctly ignored. | +| `openspec/NOTES.md` | Observed OpenSpec conventions (sourced from `Fission-AI/OpenSpec`), the locator rationale, the scenario-extraction decision, delta path conventions, the `SHALL`/`shall` caveat, and the no-relaxation / no-strict-pair statement. | diff --git a/fixtures/profiles/ears-x/frame-metadata.candidates.json b/fixtures/profiles/ears-x/frame-metadata.candidates.json new file mode 100644 index 0000000..5ab83b4 --- /dev/null +++ b/fixtures/profiles/ears-x/frame-metadata.candidates.json @@ -0,0 +1,35 @@ +{ + "fixture": "ears-x/frame-metadata.ears", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line", + "description": "Expected extract output under the ears-x every-line locator. The extractor does not move positions: text is the whole raw line including the REQ-### prefix and [source:] tag, and col stays 1. The REQ-### frame id is lifted into requirementId; the linter strips the frame at parse time, not the extractor.", + "candidates": [ + { + "file": "ears-x/frame-metadata.ears", + "line": 1, + "col": 1, + "text": "REQ-001: The billing service shall verify the HMAC signature of every incoming webhook.", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line", + "requirementId": "REQ-001" + }, + { + "file": "ears-x/frame-metadata.ears", + "line": 2, + "col": 1, + "text": "REQ-002: When a payment webhook is received, the billing service shall verify the HMAC signature. [source: billing/webhook.ts:42]", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line", + "requirementId": "REQ-002" + }, + { + "file": "ears-x/frame-metadata.ears", + "line": 3, + "col": 1, + "text": "REQ-003: If the HMAC signature is invalid, then the billing service shall reject the webhook. [source: billing/verify.ts:88]", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line", + "requirementId": "REQ-003" + } + ] +} diff --git a/fixtures/profiles/ears-x/frame-metadata.ears b/fixtures/profiles/ears-x/frame-metadata.ears new file mode 100644 index 0000000..bc0c7f4 --- /dev/null +++ b/fixtures/profiles/ears-x/frame-metadata.ears @@ -0,0 +1,3 @@ +REQ-001: The billing service shall verify the HMAC signature of every incoming webhook. +REQ-002: When a payment webhook is received, the billing service shall verify the HMAC signature. [source: billing/webhook.ts:42] +REQ-003: If the HMAC signature is invalid, then the billing service shall reject the webhook. [source: billing/verify.ts:88] diff --git a/fixtures/profiles/ears-x/frame-metadata.expected.json b/fixtures/profiles/ears-x/frame-metadata.expected.json new file mode 100644 index 0000000..4e7b4c4 --- /dev/null +++ b/fixtures/profiles/ears-x/frame-metadata.expected.json @@ -0,0 +1,43 @@ +{ + "fixture": "ears-x/frame-metadata.ears", + "profile": "ears-x", + "description": "REQ-### frame ids and [source: path:line] tags. Valid under ears-x (dialect.allowFrameMetadata=true; ids match idFormat.pattern ^REQ-\\d+$), invalid under strict (frame metadata is not part of the strict EARS sentence grammar, so the prefixed line does not match any shell pattern). Each line carries a per-profile expectation.", + "verification": "verified-core-api", + "lines": [ + { + "line": 1, + "text": "REQ-001: The billing service shall verify the HMAC signature of every incoming webhook.", + "requirementId": "REQ-001", + "pattern": "ubiquitous", + "strict": { + "ok": false, + "expect": [{ "id": "EARS-E010", "code": "ears.no_match", "severity": "error" }] + }, + "ears-x": { "ok": true, "expect": [] } + }, + { + "line": 2, + "text": "REQ-002: When a payment webhook is received, the billing service shall verify the HMAC signature. [source: billing/webhook.ts:42]", + "requirementId": "REQ-002", + "source": "billing/webhook.ts:42", + "pattern": "event-driven", + "strict": { + "ok": false, + "expect": [{ "id": "EARS-E010", "code": "ears.no_match", "severity": "error" }] + }, + "ears-x": { "ok": true, "expect": [] } + }, + { + "line": 3, + "text": "REQ-003: If the HMAC signature is invalid, then the billing service shall reject the webhook. [source: billing/verify.ts:88]", + "requirementId": "REQ-003", + "source": "billing/verify.ts:88", + "pattern": "unwanted-behaviour", + "strict": { + "ok": false, + "expect": [{ "id": "EARS-E010", "code": "ears.no_match", "severity": "error" }] + }, + "ears-x": { "ok": true, "expect": [] } + } + ] +} diff --git a/fixtures/profiles/ears-x/prohibition.candidates.json b/fixtures/profiles/ears-x/prohibition.candidates.json new file mode 100644 index 0000000..5efff61 --- /dev/null +++ b/fixtures/profiles/ears-x/prohibition.candidates.json @@ -0,0 +1,32 @@ +{ + "fixture": "ears-x/prohibition.ears", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line", + "description": "Expected extract output under the ears-x every-line locator. Same locator as strict; the ears-x tolerances are a parse-layer concern, not an extraction concern.", + "candidates": [ + { + "file": "ears-x/prohibition.ears", + "line": 1, + "col": 1, + "text": "The billing service shall not store raw card numbers.", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line" + }, + { + "file": "ears-x/prohibition.ears", + "line": 2, + "col": 1, + "text": "While the account is delinquent, the billing service shall not issue new invoices.", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line" + }, + { + "file": "ears-x/prohibition.ears", + "line": 3, + "col": 1, + "text": "When a chargeback is received, the billing service shall not retry the payment.", + "profile": "ears-x", + "locatorRuleId": "ears-x.every-line" + } + ] +} diff --git a/fixtures/profiles/ears-x/prohibition.ears b/fixtures/profiles/ears-x/prohibition.ears new file mode 100644 index 0000000..6217911 --- /dev/null +++ b/fixtures/profiles/ears-x/prohibition.ears @@ -0,0 +1,3 @@ +The billing service shall not store raw card numbers. +While the account is delinquent, the billing service shall not issue new invoices. +When a chargeback is received, the billing service shall not retry the payment. diff --git a/fixtures/profiles/ears-x/prohibition.expected.json b/fixtures/profiles/ears-x/prohibition.expected.json new file mode 100644 index 0000000..63ac7a6 --- /dev/null +++ b/fixtures/profiles/ears-x/prohibition.expected.json @@ -0,0 +1,44 @@ +{ + "fixture": "ears-x/prohibition.ears", + "profile": "ears-x", + "description": "Prohibition requirements (shall not). Valid under ears-x (dialect.allowProhibition=true; ast.prohibition=true), invalid under strict (ES-D-004, EARS-E016). Each line carries a per-profile expectation so a single file drives both sides of the ruling.", + "verification": "verified-core-api", + "lines": [ + { + "line": 1, + "text": "The billing service shall not store raw card numbers.", + "pattern": "ubiquitous", + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E016", "code": "ears.prohibition_not_allowed", "severity": "error" } + ] + }, + "ears-x": { "ok": true, "expect": [], "prohibition": true } + }, + { + "line": 2, + "text": "While the account is delinquent, the billing service shall not issue new invoices.", + "pattern": "state-driven", + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E016", "code": "ears.prohibition_not_allowed", "severity": "error" } + ] + }, + "ears-x": { "ok": true, "expect": [], "prohibition": true } + }, + { + "line": 3, + "text": "When a chargeback is received, the billing service shall not retry the payment.", + "pattern": "event-driven", + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E016", "code": "ears.prohibition_not_allowed", "severity": "error" } + ] + }, + "ears-x": { "ok": true, "expect": [], "prohibition": true } + } + ] +} diff --git a/fixtures/profiles/kiro/NOTES.md b/fixtures/profiles/kiro/NOTES.md new file mode 100644 index 0000000..d6b1fec --- /dev/null +++ b/fixtures/profiles/kiro/NOTES.md @@ -0,0 +1,46 @@ +# Kiro profile notes + +Observed conventions of AWS Kiro's `requirements.md`, and exactly what the +`kiro` profile relaxes relative to strict. Sourced from the Kiro documentation +(kiro.dev/docs/specs) and its published requirements examples. + +## Observed Kiro house style + +- A `requirements.md` opens with `# Requirements Document`, an `## Introduction` + paragraph, and a `## Requirements` section. +- Each requirement is a `### Requirement N` heading with a `**User Story:**` + line in the form `As a , I want , so that `. +- Acceptance criteria live under a `#### Acceptance Criteria` heading as a + numbered list. +- Criteria are written in EARS with all-caps keywords and the literal system + name: `WHEN THE SYSTEM SHALL ` for event-driven, and + `IF THEN THE SYSTEM SHALL ` for unwanted behaviour. + State-driven and optional-feature use `WHILE` and `WHERE` the same way. +- There is no comma after the leading clause; the keyword boundary is carried by + the all-caps keyword and, for the `IF` form, the `THEN` keyword. +- The event-driven form has no `THEN`; only the unwanted-behaviour `IF` form + does, which matches strict EARS structure once cased and comma-delimited. + +## What the profile relaxes (and only this) + +| Relaxation | Dialect field | Effect | +| ------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Case-insensitive / all-caps keywords | `keywordCase: 'case-insensitive'` | `WHEN`, `IF`, `WHILE`, `WHERE`, `THEN`, `SHALL` are accepted; strict requires `When`/`If`/`While`/`Where` and lowercase `shall` (else `EARS-E014`). | +| Literal `THE SYSTEM` | `allowLiteralSystemName: ['THE SYSTEM']` | `THE SYSTEM` is accepted as the system name; strict expects the canonical `the ` form. | +| Optional leading comma | `commaAfterLeadingClause: 'optional'` | `WHEN THE SYSTEM ...` with no comma is accepted; strict requires the comma (else `EARS-E015`). | +| User-story wrapper skipped | `allowStoryWrapper: true` | `As a ..., I want ..., so that ...` frame lines are non-requirement content, not parsed as EARS. | + +## What the profile does NOT relax + +- No frame metadata (`REQ-###`, `[source:]`): those are ears-x, not kiro. +- No prohibition (`shall not`): kiro keeps `EARS-E016` an error, same as strict. +- Locator is narrow: only list items under `#### Acceptance Criteria`. Prose and + code fences never become candidates (`codeFences: 'ignore'`). + +## Severity overrides + +`EARS-W011` (unknown clause term) and `EARS-W014` (suspicious text shape) are set +to `off`. Kiro criteria use free-form domain vocabulary that a catalog will not +recognize, so these two warnings would otherwise fire against clean house-style +text. Without a catalog neither warning fires; the overrides matter only when a +catalog is supplied. diff --git a/fixtures/profiles/kiro/design.candidates.json b/fixtures/profiles/kiro/design.candidates.json new file mode 100644 index 0000000..414d728 --- /dev/null +++ b/fixtures/profiles/kiro/design.candidates.json @@ -0,0 +1,7 @@ +{ + "fixture": "kiro/design.md", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item", + "description": "False-positive guard. Expected extract output under the kiro locator: zero candidates. The kiro locator only matches list items under a #### Acceptance Criteria heading, and design.md has none. EARS keywords appear only in narrative prose (Sync engine, Sequence) and in a fenced code block; codeFences is 'ignore', so the fenced requirement-looking lines are never eligible, and the bullet lists under Overview and Considerations sit under non-Acceptance-Criteria headings, so they are not candidates. Snapshot target for the future `extract` command, pending the extractor (W2-pipeline).", + "candidates": [] +} diff --git a/fixtures/profiles/kiro/design.md b/fixtures/profiles/kiro/design.md new file mode 100644 index 0000000..6d09c32 --- /dev/null +++ b/fixtures/profiles/kiro/design.md @@ -0,0 +1,44 @@ +# Design Document + +## Overview + +This design accompanies the draft-sync requirements. It is deliberately shaped +like a Kiro design.md: narrative prose, headings that are not Acceptance +Criteria, and code fences that contain requirement-looking lines. The kiro +locator must extract zero candidates from this file, because it only targets +list items under a `#### Acceptance Criteria` heading and there is no such +heading here. + +## Sync engine + +When the connection drops the client keeps a local queue, and when it recovers +it replays that queue in order. The phrase "the system shall" appears in this +sentence as ordinary prose, not as a requirement, so it must not be extracted. +If a conflict is detected the resolver prefers the most recent write; this is a +design decision, not an acceptance criterion. + +## Considerations + +- While the queue drains the UI shows a subtle progress hint. This bullet sits + under Considerations, not under Acceptance Criteria, so it is frame prose. +- Where bandwidth is constrained the client batches writes. Again a design + note, not a requirement candidate. + +## Example criteria (illustrative only) + +The following fenced block shows the shape a requirement takes once it reaches +requirements.md. It is a code sample and the kiro profile ignores code fences, +so none of these lines are candidates: + +```text +1. WHEN a user submits valid credentials THE SYSTEM SHALL establish a session. +2. IF a write conflict occurs THEN THE SYSTEM SHALL keep the most recent version. +3. THE SYSTEM SHALL retry a failed sync after two minutes. +``` + +## Sequence + +The sign-in sequence is described in prose below. WHEN the token expires the +client silently refreshes it before retrying, and THE SYSTEM SHALL never block +the editor while it does so. These sentences are narrative and carry no +Acceptance Criteria heading, so the locator skips them. diff --git a/fixtures/profiles/kiro/requirements.candidates.json b/fixtures/profiles/kiro/requirements.candidates.json new file mode 100644 index 0000000..d1cd8bc --- /dev/null +++ b/fixtures/profiles/kiro/requirements.candidates.json @@ -0,0 +1,80 @@ +{ + "fixture": "kiro/requirements.md", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item", + "description": "Expected extract output under the kiro locator: numbered list items under a #### Acceptance Criteria heading. Snapshot target for the future `extract` command; the extraction engine (plan Agent 08 / W2-pipeline) is not wired yet, so this is the intended output pending verification. Field shape mirrors the Candidate contract (file, line, col, text, profile, locatorRuleId). Convention: `col` is the 1-based column of the first character of the requirement text after the ordered-list marker and its trailing space are stripped (column 4 for a single-digit `N. ` marker); `text` is the requirement sentence only, without the marker. Reconcile the marker-stripping and col convention with W2 once the extractor exists.", + "candidates": [ + { + "file": "kiro/requirements.md", + "line": 20, + "col": 4, + "text": "WHEN a user submits valid credentials THE SYSTEM SHALL establish an authenticated session.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 21, + "col": 4, + "text": "WHEN a user submits an unrecognized email THE SYSTEM SHALL display a sign-in error that does not reveal which field was wrong.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 22, + "col": 4, + "text": "IF five consecutive sign-in attempts fail THEN THE SYSTEM SHALL lock the account for fifteen minutes.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 31, + "col": 4, + "text": "WHILE a background sync is in progress THE SYSTEM SHALL disable the manual save control.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 32, + "col": 4, + "text": "WHERE offline mode is enabled THE SYSTEM SHALL queue outbound changes on the local device.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 33, + "col": 4, + "text": "THE SYSTEM SHALL persist the working draft every thirty seconds.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 42, + "col": 4, + "text": "WHEN a sync request returns a network error THE SYSTEM SHALL show a retry banner above the editor.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 43, + "col": 4, + "text": "IF the retry banner is dismissed THEN THE SYSTEM SHALL schedule a silent retry after two minutes.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + }, + { + "file": "kiro/requirements.md", + "line": 44, + "col": 4, + "text": "WHEN the access token expires THE SYSTEM SHALL redirect the author to the sign-in page.", + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" + } + ] +} diff --git a/fixtures/profiles/kiro/requirements.expected.json b/fixtures/profiles/kiro/requirements.expected.json new file mode 100644 index 0000000..cd4322a --- /dev/null +++ b/fixtures/profiles/kiro/requirements.expected.json @@ -0,0 +1,123 @@ +{ + "fixture": "kiro/requirements.md", + "profile": "kiro", + "description": "Kiro house-style acceptance criteria (all-caps keywords, literal THE SYSTEM, no leading comma). Clean under kiro; the same lines fail under strict on casing (ES-D-007, EARS-E014) and, where a leading clause is present, the missing leading comma (ES-D-001, EARS-E015). Each line carries a per-profile expectation so one file drives both sides. The kiro profile also sets EARS-W011 and EARS-W014 to off so free-form domain vocabulary stays clean when a catalog is supplied; without a catalog neither warning fires, confirmed against lintEars on the canonical-cased equivalent of every line.", + "verification": "pending-c4b", + "currentBehavior": "The parser does not yet consume the dialect block (packages/core/src/shell-parser.ts and lint.ts ignore Options.dialect). Running lintEars on the raw all-caps, no-comma lines yields EARS-E008 (missing system name) for the leading-clause forms and parses the ubiquitous form as valid; it does not yet emit EARS-E014 or EARS-E015. Once C4b wires keywordCase='strict' to emit EARS-E014 and commaAfterLeadingClause='required' to emit EARS-E015, the strict column below holds and kiro (case-insensitive, THE SYSTEM literal, optional comma) stays clean.", + "lines": [ + { + "line": 20, + "text": "WHEN a user submits valid credentials THE SYSTEM SHALL establish an authenticated session.", + "pattern": "event-driven", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 21, + "text": "WHEN a user submits an unrecognized email THE SYSTEM SHALL display a sign-in error that does not reveal which field was wrong.", + "pattern": "event-driven", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 22, + "text": "IF five consecutive sign-in attempts fail THEN THE SYSTEM SHALL lock the account for fifteen minutes.", + "pattern": "unwanted-behaviour", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 31, + "text": "WHILE a background sync is in progress THE SYSTEM SHALL disable the manual save control.", + "pattern": "state-driven", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 32, + "text": "WHERE offline mode is enabled THE SYSTEM SHALL queue outbound changes on the local device.", + "pattern": "optional-feature", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 33, + "text": "THE SYSTEM SHALL persist the working draft every thirty seconds.", + "pattern": "ubiquitous", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [{ "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }] + } + }, + { + "line": 42, + "text": "WHEN a sync request returns a network error THE SYSTEM SHALL show a retry banner above the editor.", + "pattern": "event-driven", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 43, + "text": "IF the retry banner is dismissed THEN THE SYSTEM SHALL schedule a silent retry after two minutes.", + "pattern": "unwanted-behaviour", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + }, + { + "line": 44, + "text": "WHEN the access token expires THE SYSTEM SHALL redirect the author to the sign-in page.", + "pattern": "event-driven", + "kiro": { "ok": true, "expect": [] }, + "strict": { + "ok": false, + "expect": [ + { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }, + { "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" } + ] + } + } + ] +} diff --git a/fixtures/profiles/kiro/requirements.md b/fixtures/profiles/kiro/requirements.md new file mode 100644 index 0000000..781dfbf --- /dev/null +++ b/fixtures/profiles/kiro/requirements.md @@ -0,0 +1,44 @@ +# Requirements Document + +## Introduction + +This document captures the requirements for the draft-sync feature of the +notes application. Each requirement pairs a user story with acceptance criteria +written in EARS notation, following the house style Kiro generates: all-caps +keywords, the literal phrase THE SYSTEM as the system name, and no comma after a +leading clause. + +## Requirements + +### Requirement 1 + +**User Story:** As a registered user, I want to sign in with my email and +password, so that I can reach my saved drafts from any device. + +#### Acceptance Criteria + +1. WHEN a user submits valid credentials THE SYSTEM SHALL establish an authenticated session. +2. WHEN a user submits an unrecognized email THE SYSTEM SHALL display a sign-in error that does not reveal which field was wrong. +3. IF five consecutive sign-in attempts fail THEN THE SYSTEM SHALL lock the account for fifteen minutes. + +### Requirement 2 + +**User Story:** As an author, I want my work saved automatically, so that I do +not lose changes when the connection drops. + +#### Acceptance Criteria + +1. WHILE a background sync is in progress THE SYSTEM SHALL disable the manual save control. +2. WHERE offline mode is enabled THE SYSTEM SHALL queue outbound changes on the local device. +3. THE SYSTEM SHALL persist the working draft every thirty seconds. + +### Requirement 3 + +**User Story:** As an author, I want to be told when a sync fails, so that I can +retry before closing the application. + +#### Acceptance Criteria + +1. WHEN a sync request returns a network error THE SYSTEM SHALL show a retry banner above the editor. +2. IF the retry banner is dismissed THEN THE SYSTEM SHALL schedule a silent retry after two minutes. +3. WHEN the access token expires THE SYSTEM SHALL redirect the author to the sign-in page. diff --git a/fixtures/profiles/matrix.json b/fixtures/profiles/matrix.json new file mode 100644 index 0000000..c3a7edb --- /dev/null +++ b/fixtures/profiles/matrix.json @@ -0,0 +1,27 @@ +{ + "description": "Cross-profile expectation matrix for the strict and ears-x fixtures. Row 1 is the superset proof: every line of strict/valid.ears validates clean under both strict and ears-x, unchanged. Rows 2 and 3 are the strict-fail / ears-x-pass pairs that witness the two ears-x tolerances (prohibition, frame metadata). Per-line and per-diagnostic detail lives in each fixture's sibling *.expected.json.", + "supersetInvariant": "Every strict-valid requirement is ears-x-valid unchanged. ears-x only adds tolerances (frame metadata, [source:] tags, shall not prohibition); it never rejects anything strict accepts.", + "rows": [ + { + "fixture": "strict/valid.ears", + "strict": { "ok": true }, + "ears-x": { "ok": true }, + "note": "Superset proof: identical clean result under both profiles.", + "verification": "verified-core-api" + }, + { + "fixture": "ears-x/prohibition.ears", + "strict": { "ok": false, "expectId": "EARS-E016" }, + "ears-x": { "ok": true }, + "note": "shall not: rejected under strict (ES-D-004), accepted as a prohibition kind under ears-x.", + "verification": "verified-core-api" + }, + { + "fixture": "ears-x/frame-metadata.ears", + "strict": { "ok": false, "expectId": "EARS-E010" }, + "ears-x": { "ok": true }, + "note": "REQ-### frame id and [source:] tag: rejected under strict (not part of the sentence grammar), accepted under ears-x.", + "verification": "verified-core-api" + } + ] +} diff --git a/fixtures/profiles/openspec/NOTES.md b/fixtures/profiles/openspec/NOTES.md new file mode 100644 index 0000000..2eb693c --- /dev/null +++ b/fixtures/profiles/openspec/NOTES.md @@ -0,0 +1,140 @@ +# OpenSpec profile fixtures: notes + +These fixtures exercise the `openspec` built-in profile +(`packages/core/src/profiles/builtins.ts`). They cover a base capability spec, a +change delta, and a false-positive guard, plus author-derived extraction +snapshots. + +## OpenSpec conventions modeled + +OpenSpec (https://github.com/Fission-AI/OpenSpec) stores specs as plain +Markdown. The two document families this profile locates over: + +- **Base specs**: `openspec/specs//spec.md`. Layout: `# Title`, a + `## Purpose` section, optional narrative sections (for example + `## Command Syntax` with fenced examples), then `## Requirements` holding one + or more `### Requirement: ` blocks. Each requirement block opens with a + single requirement statement, followed by one or more `#### Scenario: ` + blocks written as Gherkin steps (`- **WHEN**`, `- **THEN**`, `- **AND**`). +- **Change deltas**: `openspec/changes//specs//spec.md`. + Same `### Requirement:` / `#### Scenario:` shape, but grouped under H2 delta + headers: `## ADDED Requirements`, `## MODIFIED Requirements`, + `## REMOVED Requirements`. + +`spec.md`, `change.md`, and `project.md` in this directory are flattened stand-ins +for those paths (the fixtures live at the profile root for test simplicity; the +real path conventions are what the locator's `documentKinds` and downstream path +matching key off). + +## Locator rationale + +The profile uses two `block` locator rules (see `docs/contracts/profile.md`, +"LocatorRule"): + +| Rule id | `blockPrefix` | Purpose | +| ---------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `openspec.requirement` | `### Requirement:` | Carries the one EARS requirement statement per requirement. | +| `openspec.scenario` | `#### Scenario:` | Retained per the profile contract; contributes candidates only if a stray EARS-shaped line appears inside a scenario. | + +Candidate selection for a located block is its **first EARS-shaped body line** (a +line beginning with `When` / `While` / `Where` / `If`, or matching the +ubiquitous `The shall ...` form). This is the crux of the +scenario-extraction decision below. + +Because OpenSpec nests `#### Scenario:` (H4) inside `### Requirement:` (H3), the +scenario block region is a subset of the requirement block region. In a +well-formed OpenSpec document the requirement rule (listed first) claims the one +statement line, and the scenario rule adds nothing. The scenario rule is kept +because the profile contract lists scenario blocks as locator targets and as a +defensive net for a canonical requirement line mistakenly written inside a +scenario body. + +Fenced code blocks are ignored (`codeFences: 'ignore'`), so the `## Command +Syntax` and `## Example` fences never produce candidates. + +## Scenario-extraction decision + +**Decision: scenario Gherkin steps are frame content and produce zero +candidates.** An OpenSpec scenario is a Gherkin trigger/outcome pair +(`- **WHEN** ...`, `- **THEN** ...`, `- **AND** ...`). These are not canonical +EARS sentences: the `- **WHEN**` bullet is a fragment, and the `- **THEN**` +outcome is written in present tense without `shall`. Reconstructing an EARS +event-driven requirement from a WHEN/THEN pair would require inserting `shall` +and rewriting tense, which is not deterministic and is out of scope. Treating the +raw Gherkin bullets as candidates would produce false positives under every EARS +dialect. The "first EARS-shaped body line" rule therefore skips them: a scenario +block has no EARS-shaped line, so it yields no candidate. This mirrors how the +`kiro` profile skips user-story wrapper lines as frame content. + +The `openspec.scenario` locator rule is still present (the contract lists +scenario blocks as targets). It only ever fires if an author writes a literal +canonical requirement line inside a scenario body; standard Gherkin steps never +trigger it. + +## Delta path conventions + +Extraction is **delta-section-agnostic**. `## ADDED Requirements`, +`## MODIFIED Requirements`, and `## REMOVED Requirements` are H2 organizational +headers, not locator targets. The `openspec.requirement` block rule fires on +every `### Requirement:` block regardless of which delta section contains it, so: + +- ADDED requirements are candidates (new statements to validate). +- MODIFIED requirements are candidates (the revised statement is validated). +- REMOVED requirements are candidates too (the block still carries a statement + line). `change.md` includes a REMOVED block with no scenario to confirm the + block rule does not depend on a trailing scenario. + +Delta-awareness lives in the path conventions (`openspec/changes/**` vs +`openspec/specs/**`), not in the markdown locator: the locator does not parse +ADDED/MODIFIED/REMOVED semantics; a consumer that cares about delta status reads +it from the H2 header, while EARS validation runs uniformly on every requirement +statement. + +## Dialect: no relaxation vs strict + +The `openspec` dialect is identical to `strict` +(`keywordCase: 'strict'`, `allowLiteralSystemName: []`, +`commaAfterLeadingClause: 'required'`, no story wrapper, frame metadata, or +prohibition). It differs from `strict` only in the markdown locator and +`documentKinds: ['markdown']`. **No dialect relaxation is needed, so there is no +strict-failure pair for this profile.** Under the `strict` profile the same +`.md` files locate nothing at all (`strict.documentKinds` is `['ears','text']`), +so running these fixtures under `strict` yields an empty result rather than a set +of grammar failures. The openspec/strict difference is purely locator plus +document kind, exactly as `docs/contracts/profile.md` states for the near-strict +profiles. + +Caveat on `SHALL` vs `shall`: real OpenSpec house style often writes requirement +statements with RFC-2119 uppercase `SHALL` (`The system SHALL ...`). Under the +frozen `openspec` dialect (`keywordCase: 'strict'`) the canonical lowercase +`shall` is required, so these fixtures use lowercase `shall` to validate clean. A +project that writes uppercase `SHALL` under the `openspec` profile would raise a +keyword-casing finding once the dialect layer is wired; that is expected and +correct for a near-strict profile. + +## Expected-diagnostics matrix + +| Fixture | Profile | Candidates | Expected findings | +| ----------------------- | ---------- | ------------------------------------------ | ---------------------------------- | +| `spec.md` | `openspec` | 5 (one per `### Requirement:`) | clean (zero findings) | +| `change.md` | `openspec` | 4 (2 ADDED, 1 MODIFIED, 1 REMOVED) | clean (zero findings) | +| `project.md` | `openspec` | 0 | clean, and no candidate is located | +| `spec.md` / `change.md` | `strict` | 0 (markdown not in `strict.documentKinds`) | empty result, no findings | + +There is no strict-failure fixture because the dialect is not relaxed (see +above). All extracted candidate statements were checked clean against the base +linter (`lintEars`); see the verification note. + +## Verification status + +- **Verified now**: every extracted candidate statement in `spec.candidates.json` + and `change.candidates.json` lints clean under `@earsyntax/core`'s `lintEars` + (base strict Mavin grammar). The built-in `openspec` profile validates under + `validateProfile`, and `pnpm build`/`pnpm test` stay green. +- **Pending-verification**: the extraction snapshots (`file`/`line`/`col`/`text`/ + `locatorRuleId`) are author-derived. The profile-driven locator/extractor and + the dialect layer (keyword casing, comma, etc.) are not yet wired into + `@earsyntax/core`, so the `extract` command output cannot be diffed against + these snapshots yet. When the extractor lands, these files are the expected + output for `extract fixtures/profiles/openspec/spec.md --profile openspec` + and the change equivalent. diff --git a/fixtures/profiles/openspec/change.candidates.json b/fixtures/profiles/openspec/change.candidates.json new file mode 100644 index 0000000..54df489 --- /dev/null +++ b/fixtures/profiles/openspec/change.candidates.json @@ -0,0 +1,41 @@ +{ + "fixture": "openspec/change.md", + "profile": "openspec", + "locatorRuleId": "openspec.requirement", + "description": "Expected `extract --profile openspec --json` candidates for change.md. Snapshot authored ahead of the extractor implementation; marked pending-verification. Extraction is delta-section-agnostic: the `openspec.requirement` block rule fires on every `### Requirement:` block whether it sits under `## ADDED`, `## MODIFIED`, or `## REMOVED` (those H2 headers are organizational, not locator targets). Candidates below: 2 ADDED, 1 MODIFIED, 1 REMOVED. Field shape mirrors the Candidate contract.", + "pendingVerification": true, + "candidates": [ + { + "file": "openspec/change.md", + "line": 13, + "col": 1, + "text": "The system shall retain build artifacts for the retention window of their assigned tier.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/change.md", + "line": 22, + "col": 1, + "text": "When an operator pins an artifact, the system shall retain the artifact until the pin is removed.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/change.md", + "line": 34, + "col": 1, + "text": "If an artifact exceeds its tier retention window and is unpinned, then the system shall schedule the artifact for deletion.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/change.md", + "line": 46, + "col": 1, + "text": "The system shall retain build artifacts for the configured retention window.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + } + ] +} diff --git a/fixtures/profiles/openspec/change.md b/fixtures/profiles/openspec/change.md new file mode 100644 index 0000000..dc41632 --- /dev/null +++ b/fixtures/profiles/openspec/change.md @@ -0,0 +1,46 @@ +# Add tiered artifact retention + +## Purpose + +Introduce tiered retention windows and a pinning control, tighten the expiry +rule, and drop the legacy fixed-window guarantee. This is a change delta in the +shape of `openspec/changes//specs//spec.md`. + +## ADDED Requirements + +### Requirement: Tiered retention windows + +The system shall retain build artifacts for the retention window of their assigned tier. + +#### Scenario: Tier window applied + +- **WHEN** an artifact is assigned to a retention tier +- **THEN** the system applies that tier's retention window to the artifact + +### Requirement: Manual artifact pinning + +When an operator pins an artifact, the system shall retain the artifact until the pin is removed. + +#### Scenario: Pinned artifact survives expiry + +- **WHEN** an operator pins an artifact +- **AND** the artifact passes its tier retention window +- **THEN** the system keeps the artifact until the pin is removed + +## MODIFIED Requirements + +### Requirement: Expiry of overdue artifacts + +If an artifact exceeds its tier retention window and is unpinned, then the system shall schedule the artifact for deletion. + +#### Scenario: Overdue unpinned artifact scheduled for deletion + +- **WHEN** an artifact is past its tier retention window +- **AND** the artifact is not pinned +- **THEN** the system schedules the artifact for deletion + +## REMOVED Requirements + +### Requirement: Artifact retention window + +The system shall retain build artifacts for the configured retention window. diff --git a/fixtures/profiles/openspec/project.candidates.json b/fixtures/profiles/openspec/project.candidates.json new file mode 100644 index 0000000..d1de2ae --- /dev/null +++ b/fixtures/profiles/openspec/project.candidates.json @@ -0,0 +1,35 @@ +{ + "fixture": "openspec/project.md", + "profile": "openspec", + "locatorRuleId": "openspec.requirement", + "description": "Locator skip fixture. `extract --profile openspec --json` over project.md must report ZERO candidates. No `### Requirement:` or `#### Scenario:` heading opens a block, so the block locator never targets a region, even though several narrative lines open with EARS keywords (`When`, `If`, `While`, `Where`) and one prose sentence contains 'The system shall'. Snapshot authored ahead of the extractor implementation; marked pending-verification.", + "pendingVerification": true, + "candidates": [], + "keywordOpeningDecoys": [ + { + "line": 11, + "text": "When the team plans a change, they write a proposal under `openspec/changes/`.", + "reason": "Overview prose opening with 'When'; not inside a ### Requirement: or #### Scenario: block." + }, + { + "line": 12, + "text": "If a reviewer requests edits, the author revises the delta before archiving.", + "reason": "Overview prose opening with 'If'; not inside a located block." + }, + { + "line": 13, + "text": "The system shall remain the source of truth for retention behavior; this", + "reason": "Narrative sentence containing 'The system shall', explicitly labeled non-requirement; not inside a located block." + }, + { + "line": 18, + "text": "- While a change is in review, keep its delta small and focused.", + "reason": "Conventions bullet opening with 'While'; no requirement/scenario block opens here." + }, + { + "line": 19, + "text": "- Where a capability already exists, extend its spec rather than duplicating it.", + "reason": "Conventions bullet opening with 'Where'; no requirement/scenario block opens here." + } + ] +} diff --git a/fixtures/profiles/openspec/project.md b/fixtures/profiles/openspec/project.md new file mode 100644 index 0000000..7101a83 --- /dev/null +++ b/fixtures/profiles/openspec/project.md @@ -0,0 +1,28 @@ +# Project Context + +This is an `openspec/project.md`-shaped document. It carries project conventions +and narrative context, not requirements. It must produce zero requirement +candidates under the openspec profile even though several lines below open with +EARS keywords, because the locator targets only `### Requirement:` and +`#### Scenario:` blocks, never free prose. + +## Overview + +When the team plans a change, they write a proposal under `openspec/changes/`. +If a reviewer requests edits, the author revises the delta before archiving. +The system shall remain the source of truth for retention behavior; this +sentence is narrative and must not be extracted as a requirement. + +## Conventions + +- While a change is in review, keep its delta small and focused. +- Where a capability already exists, extend its spec rather than duplicating it. + +## Example + +```bash +openspec validate add-tiered-retention +``` + +The paragraph above and the fenced block are context only. No `### Requirement:` +heading opens a block here, so nothing on this page is a candidate. diff --git a/fixtures/profiles/openspec/spec.candidates.json b/fixtures/profiles/openspec/spec.candidates.json new file mode 100644 index 0000000..874f9f8 --- /dev/null +++ b/fixtures/profiles/openspec/spec.candidates.json @@ -0,0 +1,49 @@ +{ + "fixture": "openspec/spec.md", + "profile": "openspec", + "locatorRuleId": "openspec.requirement", + "description": "Expected `extract --profile openspec --json` candidates for spec.md. Snapshot authored ahead of the extractor implementation; marked pending-verification. A block's candidate is its first EARS-shaped body line: the single requirement statement OpenSpec places directly under each `### Requirement:` heading (col 1). `#### Scenario:` blocks hold only Gherkin WHEN/THEN/AND steps, which are frame content and yield no candidate. Field shape mirrors the Candidate contract (file, line, col, text, profile, locatorRuleId).", + "pendingVerification": true, + "candidates": [ + { + "file": "openspec/spec.md", + "line": 25, + "col": 1, + "text": "The system shall retain build artifacts for the configured retention window.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/spec.md", + "line": 34, + "col": 1, + "text": "When a build completes, the system shall record the artifact retention deadline.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/spec.md", + "line": 44, + "col": 1, + "text": "While a retention hold is active, the system shall exempt held artifacts from deletion.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/spec.md", + "line": 54, + "col": 1, + "text": "Where remote storage is configured, the system shall replicate retained artifacts to the remote bucket.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + }, + { + "file": "openspec/spec.md", + "line": 64, + "col": 1, + "text": "If an artifact exceeds its retention deadline, then the system shall schedule the artifact for deletion.", + "profile": "openspec", + "locatorRuleId": "openspec.requirement" + } + ] +} diff --git a/fixtures/profiles/openspec/spec.md b/fixtures/profiles/openspec/spec.md new file mode 100644 index 0000000..318e985 --- /dev/null +++ b/fixtures/profiles/openspec/spec.md @@ -0,0 +1,70 @@ +# Artifact Retention Specification + +## Purpose + +Define how the system retains, holds, replicates, and expires build artifacts so +that storage stays bounded while audit and compliance holds are honored. This is +a base capability spec in the shape of `openspec/specs//spec.md`. + +## Command Syntax + +```bash +retain set-window --days 30 +retain hold +``` + +Options: + +- `--days`: retention window length in days. +- `--force`: skip the confirmation prompt. + +## Requirements + +### Requirement: Artifact retention window + +The system shall retain build artifacts for the configured retention window. + +#### Scenario: Artifact within the retention window + +- **WHEN** an artifact is younger than the retention window +- **THEN** the system retains the artifact and its metadata + +### Requirement: Retention deadline on build completion + +When a build completes, the system shall record the artifact retention deadline. + +#### Scenario: Deadline recorded at completion + +- **WHEN** a build finishes successfully +- **THEN** the system stores a retention deadline computed from the completion time +- **AND** the deadline is visible in the artifact metadata + +### Requirement: Retention hold exemption + +While a retention hold is active, the system shall exempt held artifacts from deletion. + +#### Scenario: Hold blocks expiry + +- **WHEN** a retention hold covers an artifact +- **AND** the artifact has passed its retention deadline +- **THEN** the system keeps the artifact until the hold is released + +### Requirement: Remote replication of retained artifacts + +Where remote storage is configured, the system shall replicate retained artifacts to the remote bucket. + +#### Scenario: Replication to remote bucket + +- **WHEN** remote storage is configured +- **AND** an artifact is retained +- **THEN** the system copies the artifact to the remote bucket + +### Requirement: Expiry of overdue artifacts + +If an artifact exceeds its retention deadline, then the system shall schedule the artifact for deletion. + +#### Scenario: Overdue artifact scheduled for deletion + +- **WHEN** an artifact is past its retention deadline +- **AND** no retention hold applies +- **THEN** the system schedules the artifact for deletion diff --git a/fixtures/profiles/schema/invalid/bad-enum-keywordcase.json b/fixtures/profiles/schema/invalid/bad-enum-keywordcase.json new file mode 100644 index 0000000..b796bad --- /dev/null +++ b/fixtures/profiles/schema/invalid/bad-enum-keywordcase.json @@ -0,0 +1,23 @@ +{ + "expectedErrors": [{ "path": "dialect.keywordCase", "code": "invalid-enum" }], + "profile": { + "name": "strict", + "notation": "ears", + "dialect": { + "keywordCase": "loose", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["ears", "text"], + "include": [{ "id": "strict.every-line", "kind": "every-line" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/invalid/bad-locator-kind.json b/fixtures/profiles/schema/invalid/bad-locator-kind.json new file mode 100644 index 0000000..50900d8 --- /dev/null +++ b/fixtures/profiles/schema/invalid/bad-locator-kind.json @@ -0,0 +1,23 @@ +{ + "expectedErrors": [{ "path": "locator.include[0].kind", "code": "invalid-enum" }], + "profile": { + "name": "openspec", + "notation": "ears", + "dialect": { + "keywordCase": "strict", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["markdown"], + "include": [{ "id": "openspec.req", "kind": "paragraph" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/invalid/bad-regex-pattern.json b/fixtures/profiles/schema/invalid/bad-regex-pattern.json new file mode 100644 index 0000000..a985db1 --- /dev/null +++ b/fixtures/profiles/schema/invalid/bad-regex-pattern.json @@ -0,0 +1,23 @@ +{ + "expectedErrors": [{ "path": "idFormat.pattern", "code": "invalid-pattern" }], + "profile": { + "name": "ears-x", + "notation": "ears", + "dialect": { + "keywordCase": "strict", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": true, + "allowProhibition": true + }, + "locator": { + "documentKinds": ["ears", "text"], + "include": [{ "id": "ears-x.every-line", "kind": "every-line" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false, "pattern": "REQ-(\\d+" } + } +} diff --git a/fixtures/profiles/schema/invalid/missing-dialect.json b/fixtures/profiles/schema/invalid/missing-dialect.json new file mode 100644 index 0000000..2423cbb --- /dev/null +++ b/fixtures/profiles/schema/invalid/missing-dialect.json @@ -0,0 +1,15 @@ +{ + "expectedErrors": [{ "path": "dialect", "code": "missing-key" }], + "profile": { + "name": "strict", + "notation": "ears", + "locator": { + "documentKinds": ["ears", "text"], + "include": [{ "id": "strict.every-line", "kind": "every-line" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json b/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json new file mode 100644 index 0000000..fcb5029 --- /dev/null +++ b/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json @@ -0,0 +1,30 @@ +{ + "expectedErrors": [{ "path": "locator.include[0].regex", "code": "unknown-key" }], + "profile": { + "name": "speckit", + "notation": "ears", + "dialect": { + "keywordCase": "strict", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["markdown"], + "include": [ + { + "id": "speckit.req", + "kind": "heading-section", + "headingPattern": "^requirements$", + "regex": true + } + ], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/invalid/unknown-severity-id.json b/fixtures/profiles/schema/invalid/unknown-severity-id.json new file mode 100644 index 0000000..f3f629c --- /dev/null +++ b/fixtures/profiles/schema/invalid/unknown-severity-id.json @@ -0,0 +1,25 @@ +{ + "expectedErrors": [{ "path": "severity.EARS-E999", "code": "unknown-diagnostic-id" }], + "profile": { + "name": "kiro", + "notation": "ears", + "dialect": { + "keywordCase": "case-insensitive", + "allowLiteralSystemName": ["THE SYSTEM"], + "commaAfterLeadingClause": "optional", + "allowStoryWrapper": true, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["markdown"], + "include": [ + { "id": "kiro.item", "kind": "list-item", "underHeading": "^acceptance criteria$" } + ], + "exclude": [], + "codeFences": "ignore" + }, + "severity": { "EARS-E999": "off" }, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/invalid/unknown-top-key.json b/fixtures/profiles/schema/invalid/unknown-top-key.json new file mode 100644 index 0000000..465a6fe --- /dev/null +++ b/fixtures/profiles/schema/invalid/unknown-top-key.json @@ -0,0 +1,24 @@ +{ + "expectedErrors": [{ "path": "workspace", "code": "unknown-key" }], + "profile": { + "name": "strict", + "notation": "ears", + "workspace": ".earsyntax", + "dialect": { + "keywordCase": "strict", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["ears", "text"], + "include": [{ "id": "strict.every-line", "kind": "every-line" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } + } +} diff --git a/fixtures/profiles/schema/valid/full-markdown.json b/fixtures/profiles/schema/valid/full-markdown.json new file mode 100644 index 0000000..bd4e8aa --- /dev/null +++ b/fixtures/profiles/schema/valid/full-markdown.json @@ -0,0 +1,37 @@ +{ + "name": "kiro", + "notation": "ears", + "dialect": { + "keywordCase": "case-insensitive", + "allowLiteralSystemName": ["THE SYSTEM"], + "commaAfterLeadingClause": "optional", + "allowStoryWrapper": true, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["markdown"], + "include": [ + { + "id": "kiro.acceptance-criteria-item", + "kind": "list-item", + "underHeading": "^acceptance criteria$", + "listMarker": "any", + "note": "Items under acceptance criteria." + } + ], + "exclude": [ + { + "id": "kiro.design-section", + "kind": "heading-section", + "headingPattern": "^design$" + } + ], + "codeFences": "ignore" + }, + "severity": { + "EARS-W011": "off", + "EARS-E006": "warning" + }, + "idFormat": { "required": true, "pattern": "^REQ-\\d+$" } +} diff --git a/fixtures/profiles/schema/valid/minimal-strict.json b/fixtures/profiles/schema/valid/minimal-strict.json new file mode 100644 index 0000000..33a259a --- /dev/null +++ b/fixtures/profiles/schema/valid/minimal-strict.json @@ -0,0 +1,20 @@ +{ + "name": "strict", + "notation": "ears", + "dialect": { + "keywordCase": "strict", + "allowLiteralSystemName": [], + "commaAfterLeadingClause": "required", + "allowStoryWrapper": false, + "allowFrameMetadata": false, + "allowProhibition": false + }, + "locator": { + "documentKinds": ["ears", "text"], + "include": [{ "id": "strict.every-line", "kind": "every-line" }], + "exclude": [], + "codeFences": "ignore" + }, + "severity": {}, + "idFormat": { "required": false } +} diff --git a/fixtures/profiles/speckit/NOTES.md b/fixtures/profiles/speckit/NOTES.md new file mode 100644 index 0000000..10ff7e8 --- /dev/null +++ b/fixtures/profiles/speckit/NOTES.md @@ -0,0 +1,93 @@ +# Spec Kit profile notes + +Observed conventions from GitHub Spec Kit (`github/spec-kit`, `templates/spec-template.md`) +and the design decisions behind the `speckit` profile and these fixtures. + +## Spec Kit document shape + +A Spec Kit feature lives in `specs/<###-feature-name>/` and holds several +markdown files. Only `spec.md` carries requirements; `plan.md` and `research.md` +are narrative planning documents. + +`spec.md` has a fixed section layout (heading levels reproduced exactly): + +- `# Feature Specification: ` with `**Feature Branch**`, `**Created**`, + `**Status**`, `**Input**` metadata lines. +- `## User Scenarios & Testing *(mandatory)*` containing `### User Story N` + blocks (plain-language journeys, `**Acceptance Scenarios**` in + Given/When/Then form) and an `### Edge Cases` list. +- `## Requirements *(mandatory)*` containing `### Functional Requirements` + (the requirement list) and an optional `### Key Entities` subsection. +- `## Success Criteria *(mandatory)*` with `### Measurable Outcomes` (`SC-###`). +- `## Assumptions`. + +The upstream template writes functional requirements as +`- **FR-###**: System MUST `. Teams adopting EARS keep the +`- **FR-###**:` label and the bullet-list structure but write the requirement +body in EARS form (`the system shall ...`) instead of the loose "System MUST" +placeholder. The fixture `spec.md` reflects that adopted-EARS house style: five +`FR-###` items, one for each EARS template. + +## Locator rationale + +The include rule is a `heading-section` on `^(functional )?requirements$`. It +matches both `## Requirements` and `### Functional Requirements`, which is +deliberate: some Spec Kit specs put the FR list directly under `## Requirements` +and some nest it under `### Functional Requirements`. Matching either heading +locates the FR bullets in both layouts. + +Matching `## Requirements` means the section body also spans the nested +`### Key Entities` subsection (a section body runs to the next heading of equal +or higher level). Key Entities bullets use the same `- **Label**: text` shape as +requirements but are not EARS. The exclude rule +(`^(design|background|context|overview|non-goals?|key entities|success criteria|assumptions)$`) +removes them, so `- **Workspace**: ...` never becomes a candidate. + +Narrative that opens with an EARS keyword is handled two ways: + +1. Prose in `## User Scenarios & Testing`, `### Edge Cases`, and the planning + documents is simply never under a Requirements heading, so the include rule + does not reach it. The fixture places `When ...`, `While ...`, `Where ...`, + and `If ...` sentences at the start of narrative lines to prove they are not + located (see `spec.candidates.json` -> `skippedButKeywordOpening`). +2. Non-requirement bullets that DO fall inside the Requirements section body + (Key Entities) are removed by the exclude rule. + +`plan.md` is the locator skip fixture: it has no Requirements heading at all, so +`extract` reports zero candidates even though it contains a fully strict-valid +EARS sentence in its Phase 0 narrative (`The system shall reuse ...`). This is +the strongest false-positive guard: a real requirement sentence in the wrong +document is still correctly ignored. + +## Dialect decision: strict grammar, FR ids are structure not grammar + +`speckit` keeps every dialect field identical to `strict`. The consequences: + +- `allowFrameMetadata` stays `false`. The `FR-###` label is markdown list-item + structure, so the extractor strips the leading `- ` marker and the + `**FR-###**: ` label and hands the parser the bare EARS sentence. The + frame-metadata form that ears-x accepts (`REQ-### the system shall ...`, + `[source: path:line]`) remains a strict error under speckit; FR labels are not + that mechanism. +- No `idFormat.pattern` is set. Enforcing `^FR-\d+$` was considered and + rejected: `idFormat.required` is `false` for Spec Kit (a spec may hold a + requirement without an FR label), and a malformed label is better surfaced as + an extractor concern than folded into dialect validation. Keeping `idFormat` + empty preserves the contract's "speckit differs from strict only in its + locator" guarantee. + +Because no grammar field is relaxed, there is no clean-under-speckit / +fail-under-strict pair. The plan's Agent 11 task 2 ("create strict failure pair +IF Spec Kit syntax needs relaxation") does not apply: Spec Kit syntax, once the +FR label is treated as list structure, is plain strict EARS. This is stated +explicitly in `spec.expected.json` -> `strictDelta.applicable = false`. + +## Verification status + +The extractor and the markdown-profile linter wiring are not implemented yet +(plan Agents 05-08). Every `*.expected.json` here is authored against the frozen +profile schema and the locator semantics in `docs/contracts/profile.md`, and is +marked `pendingVerification: true`. Line and column numbers are computed from the +current fixture text; `col` is the 1-based column where the EARS sentence begins +after the stripped `- **FR-###**: ` prefix (15 for a 3-digit FR label). Column +semantics should be re-confirmed against the extractor once it lands. diff --git a/fixtures/profiles/speckit/plan.candidates.json b/fixtures/profiles/speckit/plan.candidates.json new file mode 100644 index 0000000..f6e0034 --- /dev/null +++ b/fixtures/profiles/speckit/plan.candidates.json @@ -0,0 +1,29 @@ +{ + "note": "Locator skip fixture. `extract --profile speckit --json` over plan.md must report ZERO candidates. plan.md contains no heading matching the include pattern `^(functional )?requirements$`, so no region is ever a candidate, even though several narrative lines open with EARS keywords (`When`, `While`, `Where`, `If`) and one sentence is a fully formed strict-EARS requirement ('The system shall reuse the existing email-identity provider.'). Snapshot authored ahead of the extractor implementation; marked pending-verification.", + "pendingVerification": true, + "file": "speckit/plan.md", + "profile": "speckit", + "candidates": [], + "keywordOpeningDecoys": [ + { + "line": 18, + "text": "While the constitution requires every feature to stay testable in isolation,", + "reason": "Constitution Check prose opening with 'While'; not under a Requirements heading." + }, + { + "line": 20, + "text": "Where a gate would otherwise block the slice, the plan notes the exception here.", + "reason": "Constitution Check prose opening with 'Where'; not under a Requirements heading." + }, + { + "line": 33, + "text": "The system shall reuse the existing email-identity provider.", + "reason": "Strict-valid EARS sentence, but it sits in Phase 0 - Research narrative, not a Requirements section, so it is never located." + }, + { + "line": 39, + "text": "When the design is settled, the team promotes these notes into the spec.", + "reason": "Phase 1 prose opening with 'When'; not under a Requirements heading." + } + ] +} diff --git a/fixtures/profiles/speckit/plan.md b/fixtures/profiles/speckit/plan.md new file mode 100644 index 0000000..5d2267d --- /dev/null +++ b/fixtures/profiles/speckit/plan.md @@ -0,0 +1,41 @@ +# Implementation Plan: Project Workspace Sharing + +**Branch**: `014-workspace-sharing` + +**Spec**: `specs/014-workspace-sharing/spec.md` + +**Status**: Draft + +## Technical Context + +The workspace service already owns membership records. When the sharing feature +lands, it will reuse that table rather than introduce a parallel store. If the +audit-logging module is unavailable, the plan falls back to no-op recording so +the core invite flow still ships. + +## Constitution Check + +While the constitution requires every feature to stay testable in isolation, +this plan keeps invitation, role change, and audit logging on separate seams. +Where a gate would otherwise block the slice, the plan notes the exception here. + +## Project Structure + +``` +specs/014-workspace-sharing/ + spec.md + plan.md + research.md +``` + +## Phase 0 - Research + +The system shall reuse the existing email-identity provider. That sentence reads +like a requirement, but it lives in a plan narrative, not a Requirements +section, so the speckit locator must not treat it as a candidate. + +## Phase 1 - Design Notes + +When the design is settled, the team promotes these notes into the spec. Until +then nothing in this document is an EARS requirement and the extractor reports +zero candidates for it. diff --git a/fixtures/profiles/speckit/spec.candidates.json b/fixtures/profiles/speckit/spec.candidates.json new file mode 100644 index 0000000..752e073 --- /dev/null +++ b/fixtures/profiles/speckit/spec.candidates.json @@ -0,0 +1,85 @@ +{ + "note": "Expected `extract --profile speckit --json` candidates for spec.md. Snapshot authored ahead of the extractor implementation (plan Agents 05-08); marked pending-verification. Assumptions: (1) the heading-section include rule collects the body lines of the `## Requirements` / `### Functional Requirements` section; (2) the extractor strips the leading markdown list marker `- ` and the bold requirement label `**FR-###**: `, so the candidate `text` is the bare EARS sentence and `col` is the 1-based column where that sentence begins (15 for a 3-digit FR label); (3) the exclude rule removes the `### Key Entities` subsection, so its `- **[Entity]**: ...` bullets never appear here. `plan.md` is a separate zero-candidate fixture (see plan.candidates.json).", + "pendingVerification": true, + "file": "speckit/spec.md", + "profile": "speckit", + "candidates": [ + { + "file": "speckit/spec.md", + "line": 55, + "col": 15, + "text": "The system shall allow a workspace owner to invite a teammate by email address.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-001" + }, + { + "file": "speckit/spec.md", + "line": 56, + "col": 15, + "text": "When a teammate accepts an invitation, the system shall grant them the role recorded on that invitation.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-002" + }, + { + "file": "speckit/spec.md", + "line": 57, + "col": 15, + "text": "While an invitation is pending, the system shall allow the owner to revoke it.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-003" + }, + { + "file": "speckit/spec.md", + "line": 58, + "col": 15, + "text": "Where the workspace has audit logging enabled, the system shall record every member role change.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-004" + }, + { + "file": "speckit/spec.md", + "line": 59, + "col": 15, + "text": "If an owner attempts to demote the last remaining owner, then the system shall reject the change.", + "profile": "speckit", + "locatorRuleId": "speckit.requirements-section", + "requirementId": "FR-005" + } + ], + "skippedButKeywordOpening": [ + { + "line": 16, + "reason": "User Scenarios prose opening with 'When'; outside any Requirements heading, so the include rule never matches it.", + "text": "When the teammate accepts, they gain access at the role the owner chose." + }, + { + "line": 17, + "reason": "User Scenarios prose opening with 'While'; outside any Requirements heading.", + "text": "While the invitation is pending, the owner can revoke it at any time." + }, + { + "line": 32, + "reason": "User Story 2 prose opening with 'If'; outside any Requirements heading.", + "text": "If a teammate needs broader access, the owner promotes them from viewer to editor." + }, + { + "line": 33, + "reason": "User Story 2 prose opening with 'Where'; outside any Requirements heading.", + "text": "Where the workspace has audit logging turned on, every role change is recorded for later review." + }, + { + "line": 48, + "reason": "Edge Cases bullet opening with 'When'; outside any Requirements heading.", + "text": "When the last owner tries to demote themselves, the panel blocks the change to keep the workspace owned." + }, + { + "line": 63, + "reason": "Key Entities bullet inside the Requirements block; removed by the speckit.non-requirement-section exclude rule.", + "text": "Workspace: The shared container that members belong to; owns projects and the member roster." + } + ] +} diff --git a/fixtures/profiles/speckit/spec.expected.json b/fixtures/profiles/speckit/spec.expected.json new file mode 100644 index 0000000..8fe6cad --- /dev/null +++ b/fixtures/profiles/speckit/spec.expected.json @@ -0,0 +1,24 @@ +{ + "note": "Expected `validate` diagnostics for the speckit fixtures. Authored ahead of the extractor and linter wiring for markdown profiles; marked pending-verification. The speckit dialect is byte-for-byte strict (keywordCase strict, allowLiteralSystemName [], commaAfterLeadingClause required, allowStoryWrapper false, allowFrameMetadata false, allowProhibition false), so every located candidate is judged by the same grammar strict uses.", + "pendingVerification": true, + "cases": [ + { + "file": "speckit/spec.md", + "profile": "speckit", + "expect": "clean", + "diagnostics": [], + "rationale": "All five located candidates (FR-001..FR-005) are canonical strict-EARS sentences, one per template: ubiquitous, event-driven (When ..., comma), state-driven (While ..., comma), optional-feature (Where ..., comma), and unwanted-behavior (If ..., then). Narrative that opens with EARS keywords lives outside the Requirements section and Key Entities bullets are excluded, so neither reaches the linter." + }, + { + "file": "speckit/plan.md", + "profile": "speckit", + "expect": "clean", + "diagnostics": [], + "rationale": "Zero candidates located (no Requirements heading), so there is nothing to lint. This is the locator skip case, not a grammar-clean case." + } + ], + "strictDelta": { + "applicable": false, + "explanation": "No strict-fail pair is provided, and per the plan's 'if Spec Kit syntax needs relaxation' conditional this is intentional. speckit does not relax any grammar field relative to strict; it differs only in the markdown locator. The extracted candidate text (bare EARS sentences, FR label and list marker stripped by the extractor) is identical to what strict would judge, so spec.md validates the same under strict and speckit. The two profiles diverge only in what the locator hands the parser, not in the verdict on a given requirement sentence. Because there is no dialect delta, there is no line that is clean under speckit yet fails under strict." + } +} diff --git a/fixtures/profiles/speckit/spec.md b/fixtures/profiles/speckit/spec.md new file mode 100644 index 0000000..dde7557 --- /dev/null +++ b/fixtures/profiles/speckit/spec.md @@ -0,0 +1,77 @@ +# Feature Specification: Project Workspace Sharing + +**Feature Branch**: `014-workspace-sharing` + +**Created**: 2026-08-01 + +**Status**: Draft + +**Input**: User description: "Let a workspace owner invite teammates and control what they can edit." + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Invite a teammate (Priority: P1) + +A workspace owner opens the members panel and sends an invitation to a teammate email. +When the teammate accepts, they gain access at the role the owner chose. +While the invitation is pending, the owner can revoke it at any time. + +**Why this priority**: Sharing is the core of the feature; nothing else is usable until an owner can bring a second person into a workspace. + +**Independent Test**: Can be fully tested by inviting one teammate and confirming their access, without any other story implemented. + +**Acceptance Scenarios**: + +1. **Given** an owner on the members panel, **When** they invite a valid email, **Then** a pending invitation appears. +2. **Given** a pending invitation, **When** the teammate accepts, **Then** they receive the assigned role. + +--- + +### User Story 2 - Adjust a member role (Priority: P2) + +If a teammate needs broader access, the owner promotes them from viewer to editor. +Where the workspace has audit logging turned on, every role change is recorded for later review. + +**Why this priority**: Role changes are common but the workspace is still viable with invite-only access if this ships later. + +**Independent Test**: Can be tested by changing one member between roles and observing the effective permissions. + +**Acceptance Scenarios**: + +1. **Given** an editor and a viewer, **When** the owner swaps their roles, **Then** permissions swap accordingly. + +--- + +### Edge Cases + +- What happens when an invitation is sent to an email that already has access? +- When the last owner tries to demote themselves, the panel blocks the change to keep the workspace owned. +- How does the system handle an invitation link opened after it has expired? + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: The system shall allow a workspace owner to invite a teammate by email address. +- **FR-002**: When a teammate accepts an invitation, the system shall grant them the role recorded on that invitation. +- **FR-003**: While an invitation is pending, the system shall allow the owner to revoke it. +- **FR-004**: Where the workspace has audit logging enabled, the system shall record every member role change. +- **FR-005**: If an owner attempts to demote the last remaining owner, then the system shall reject the change. + +### Key Entities _(include if feature involves data)_ + +- **Workspace**: The shared container that members belong to; owns projects and the member roster. +- **Invitation**: A pending grant of access; carries the target email, the assigned role, and an expiry. +- **Membership**: The link between a person and a workspace, holding the effective role. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: An owner can invite a teammate in under 30 seconds from the members panel. +- **SC-002**: 95% of accepted invitations grant the correct role on the first attempt. + +## Assumptions + +- The existing authentication system supplies verified email identities. +- Cross-workspace sharing is out of scope for this version. diff --git a/fixtures/profiles/strict/invalid.candidates.json b/fixtures/profiles/strict/invalid.candidates.json new file mode 100644 index 0000000..3a10aae --- /dev/null +++ b/fixtures/profiles/strict/invalid.candidates.json @@ -0,0 +1,64 @@ +{ + "fixture": "strict/invalid.ears", + "profile": "strict", + "locatorRuleId": "strict.every-line", + "description": "Expected extract output under the strict every-line locator. Extraction is locate-only and does not parse or lint, so every non-empty line is a candidate regardless of whether it later validates.", + "candidates": [ + { + "file": "strict/invalid.ears", + "line": 1, + "col": 1, + "text": "WHEN a payment webhook is received, the billing service shall verify the HMAC signature.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 2, + "col": 1, + "text": "When a payment webhook is received the billing service shall verify the HMAC signature.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 3, + "col": 1, + "text": "The billing service shall not store raw card numbers.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 4, + "col": 1, + "text": "If the payment is declined, the billing service shall notify the customer.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 5, + "col": 1, + "text": "When a payment webhook is received, when a refund is requested, the billing service shall issue a refund.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 6, + "col": 1, + "text": "When a payment webhook is received, it shall verify the HMAC signature.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/invalid.ears", + "line": 7, + "col": 1, + "text": "While the payment provider is unavailable, the billing service shall shall queue retryable events.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + } + ] +} diff --git a/fixtures/profiles/strict/invalid.ears b/fixtures/profiles/strict/invalid.ears new file mode 100644 index 0000000..6cd69f8 --- /dev/null +++ b/fixtures/profiles/strict/invalid.ears @@ -0,0 +1,7 @@ +WHEN a payment webhook is received, the billing service shall verify the HMAC signature. +When a payment webhook is received the billing service shall verify the HMAC signature. +The billing service shall not store raw card numbers. +If the payment is declined, the billing service shall notify the customer. +When a payment webhook is received, when a refund is requested, the billing service shall issue a refund. +When a payment webhook is received, it shall verify the HMAC signature. +While the payment provider is unavailable, the billing service shall shall queue retryable events. diff --git a/fixtures/profiles/strict/invalid.expected.json b/fixtures/profiles/strict/invalid.expected.json new file mode 100644 index 0000000..56b613f --- /dev/null +++ b/fixtures/profiles/strict/invalid.expected.json @@ -0,0 +1,59 @@ +{ + "fixture": "strict/invalid.ears", + "profile": "strict", + "description": "One violation of each strict ES-D ruling, one per line. Each line names the exact registry id the strict profile emits and the ES-D decision it witnesses. Every line is verified against lintEars with the strict dialect.", + "findings": { "ok": false }, + "lines": [ + { + "line": 1, + "text": "WHEN a payment webhook is received, the billing service shall verify the HMAC signature.", + "ruling": "ES-D-007 keyword case", + "expect": [{ "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }], + "verification": "verified-core-api" + }, + { + "line": 2, + "text": "When a payment webhook is received the billing service shall verify the HMAC signature.", + "ruling": "ES-D-001 comma after leading clause", + "expect": [{ "id": "EARS-E015", "code": "ears.missing_leading_comma", "severity": "error" }], + "verification": "verified-core-api" + }, + { + "line": 3, + "text": "The billing service shall not store raw card numbers.", + "ruling": "ES-D-004 negative response (shall not)", + "expect": [ + { "id": "EARS-E016", "code": "ears.prohibition_not_allowed", "severity": "error" } + ], + "verification": "verified-core-api" + }, + { + "line": 4, + "text": "If the payment is declined, the billing service shall notify the customer.", + "ruling": "ES-D-002 then keyword required in unwanted behaviour", + "expect": [{ "id": "EARS-E006", "code": "ears.invalid_if_then_form", "severity": "error" }], + "verification": "verified-core-api" + }, + { + "line": 5, + "text": "When a payment webhook is received, when a refund is requested, the billing service shall issue a refund.", + "ruling": "ES-D-003 one trigger maximum (two When clauses)", + "expect": [{ "id": "EARS-E005", "code": "ears.invalid_clause_order", "severity": "error" }], + "verification": "verified-core-api" + }, + { + "line": 6, + "text": "When a payment webhook is received, it shall verify the HMAC signature.", + "ruling": "ES-D-005 one system name, no pronoun reference", + "expect": [{ "id": "EARS-E008", "code": "ears.missing_system", "severity": "error" }], + "verification": "verified-core-api" + }, + { + "line": 7, + "text": "While the payment provider is unavailable, the billing service shall shall queue retryable events.", + "ruling": "ES-D-008 one requirement per line (second shall outside an and-joined response)", + "expect": [{ "id": "EARS-E009", "code": "ears.multiple_shall", "severity": "error" }], + "verification": "verified-core-api" + } + ] +} diff --git a/fixtures/profiles/strict/valid.candidates.json b/fixtures/profiles/strict/valid.candidates.json new file mode 100644 index 0000000..a8cf70d --- /dev/null +++ b/fixtures/profiles/strict/valid.candidates.json @@ -0,0 +1,64 @@ +{ + "fixture": "strict/valid.ears", + "profile": "strict", + "locatorRuleId": "strict.every-line", + "description": "Expected extract output under the strict every-line locator: each non-empty line is one candidate. Snapshot target for the future `extract` command. Field shape mirrors the Candidate contract (file, line, col, text, profile, locatorRuleId).", + "candidates": [ + { + "file": "strict/valid.ears", + "line": 1, + "col": 1, + "text": "The billing service shall verify the HMAC signature of every incoming webhook.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 2, + "col": 1, + "text": "While the payment provider is unavailable, the billing service shall queue retryable webhook events.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 3, + "col": 1, + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 4, + "col": 1, + "text": "Where tax calculation is enabled, the billing service shall add sales tax to each invoice.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 5, + "col": 1, + "text": "If the HMAC signature is invalid, then the billing service shall reject the webhook.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 6, + "col": 1, + "text": "While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + }, + { + "file": "strict/valid.ears", + "line": 7, + "col": 1, + "text": "Where dunning management is enabled, if a charge is declined, then the billing service shall schedule a retry.", + "profile": "strict", + "locatorRuleId": "strict.every-line" + } + ] +} diff --git a/fixtures/profiles/strict/valid.ears b/fixtures/profiles/strict/valid.ears new file mode 100644 index 0000000..d74f6e5 --- /dev/null +++ b/fixtures/profiles/strict/valid.ears @@ -0,0 +1,7 @@ +The billing service shall verify the HMAC signature of every incoming webhook. +While the payment provider is unavailable, the billing service shall queue retryable webhook events. +When a payment webhook is received, the billing service shall verify the HMAC signature. +Where tax calculation is enabled, the billing service shall add sales tax to each invoice. +If the HMAC signature is invalid, then the billing service shall reject the webhook. +While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. +Where dunning management is enabled, if a charge is declined, then the billing service shall schedule a retry. diff --git a/fixtures/profiles/strict/valid.expected.json b/fixtures/profiles/strict/valid.expected.json new file mode 100644 index 0000000..c8488ff --- /dev/null +++ b/fixtures/profiles/strict/valid.expected.json @@ -0,0 +1,16 @@ +{ + "fixture": "strict/valid.ears", + "profile": "strict", + "description": "One clean document exercising all six EARS patterns in canonical Mavin style over the strict every-line locator. Every line is valid with zero diagnostics under strict, and unchanged under ears-x (see matrix.json for the superset proof).", + "findings": { "ok": true, "errors": 0, "warnings": 0 }, + "verification": "verified-core-api", + "lines": [ + { "line": 1, "pattern": "ubiquitous", "expect": [] }, + { "line": 2, "pattern": "state-driven", "expect": [] }, + { "line": 3, "pattern": "event-driven", "expect": [] }, + { "line": 4, "pattern": "optional-feature", "expect": [] }, + { "line": 5, "pattern": "unwanted-behaviour", "expect": [] }, + { "line": 6, "pattern": "complex", "note": "While + When combination", "expect": [] }, + { "line": 7, "pattern": "complex", "note": "Where + If-then combination", "expect": [] } + ] +} diff --git a/fixtures/schema.md b/fixtures/schema.md new file mode 100644 index 0000000..5d717e6 --- /dev/null +++ b/fixtures/schema.md @@ -0,0 +1,134 @@ +# Fixture Schema + +Fixtures are the source-of-truth examples for the EARS toolkit. Each fixture file is a single JSON object describing one requirement, the options it is linted under, an optional catalog, and the expected result. Grammar, parser, catalog, and compatibility agents all read and write against this shape. + +## File layout + +- One JSON object per file. +- Valid fixtures live under `fixtures/valid/` (and `fixtures/canonical/`). +- Invalid fixtures live under `fixtures/invalid/`. +- Compatibility fixtures live under `fixtures/compatibility/` (and `fixtures/ears-lint-go-parity/`). + +## Object shape + +```json +{ + "id": "REQ-001", + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature.", + "options": { + "mode": "strict", + "commaAsAnd": false, + "vagueTerms": ["appropriate", "sufficient", "as needed"] + }, + "catalog": { + "systems": [{ "id": "SYS-BILLING", "name": "billing service" }], + "events": [{ "id": "EVT-WEBHOOK", "name": "a payment webhook is received" }] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { "raw": "billing service", "role": "system" } + }, + "responses": ["verify the HMAC signature"] + } +} +``` + +### Top-level fields + +| Field | Type | Required | Meaning | +| ---------- | --------- | -------- | ------------------------------------------------------------------ | +| `id` | `string` | yes | Stable fixture identifier, echoed into `LintResult.id`. | +| `text` | `string` | yes | The raw requirement text passed to `lintEars`. | +| `options` | `object` | no | Subset of `Options`. Omitted fields fall back to library defaults. | +| `catalog` | `Catalog` | no | Catalog passed to the linter. Omit for catalog-free fixtures. | +| `expected` | `object` | yes | The assertions the fixture makes about the result. | + +### `options` + +A partial `Options` object. Any field may be omitted; omitted fields use the library defaults (`mode: "strict"`, `commaAsAnd: false`, `vagueTerms: ["appropriate", "sufficient", "as needed"]`). + +| Field | Type | Meaning | +| ------------ | ---------- | ---------------------------------------------- | +| `mode` | `Mode` | `"strict"` or `"guided"`. | +| `commaAsAnd` | `boolean` | Treat unambiguous clause-body commas as `and`. | +| `vagueTerms` | `string[]` | Terms flagged when they appear in a response. | + +### `catalog` + +A full `Catalog` object as defined in `packages/core/src/types.ts`. Only the groups a fixture needs must be present. + +### `expected` + +| Field | Type | Required | Meaning | +| ------------- | ----------------- | -------- | ----------------------------------------------------------------- | +| `valid` | `boolean` | yes | Expected `LintResult.valid`. | +| `pattern` | `Pattern` | no | Expected classified pattern. Assert only when the fixture parses. | +| `diagnostics` | `Diagnostic[]` | yes | Expected diagnostics. May be empty. See matching semantics below. | +| `ast` | partial `EarsAst` | no | Selected AST fields to assert. Compared as a subset. See below. | +| `responses` | `string[]` | no | Expected `ast.responses`. Convenience alias for `ast.responses`. | + +Each entry in `expected.diagnostics` is a partial `Diagnostic`: + +```json +{ "code": "ears.missing_shall", "severity": "error", "span": { "start": 0, "end": 12 } } +``` + +| Field | Type | Required | Meaning | +| ---------- | ---------------- | -------- | -------------------------------------- | +| `code` | `DiagnosticCode` | yes | The registered diagnostic code. | +| `severity` | `Severity` | yes | `"error"`, `"warning"`, or `"info"`. | +| `span` | `Span` | no | Asserted only when present. See below. | + +## Matching semantics + +The runner compares an actual `LintResult` against `expected` using these rules. They are exact and load-bearing; fixture authors and runner implementers must follow them identically. + +### `valid` + +Compared for strict equality against `LintResult.valid`. + +### `pattern` + +When present, compared for strict equality against `LintResult.pattern`. When absent, `pattern` is not asserted. + +### `diagnostics` (exact code + severity multiset) + +The actual and expected diagnostics are compared as a **multiset of `(code, severity)` pairs**: + +- Order does not matter. +- Every expected pair must appear in the actual set, and every actual pair must appear in the expected set (no missing, no extra). +- Duplicates count: two expected `lint.vague_response` warnings require exactly two actual ones. + +`message` is never compared (messages are owned by the diagnostics agent and may change). + +### `span` on a diagnostic (asserted only when present) + +For a given expected diagnostic entry: + +- If the entry has no `span`, span is not asserted for that diagnostic. +- If the entry has a `span`, the matched actual diagnostic must carry an exactly equal `span` (`start` and `end` both equal). + +Span assertion is opt-in per diagnostic so most fixtures stay resilient to offset churn while representative fixtures can pin exact offsets. + +### `ast` (subset match) + +`expected.ast` is compared against `LintResult.ast` as a **recursive subset**: + +- Only keys present in `expected.ast` are checked; keys absent from `expected.ast` are ignored. +- Nested objects are compared by the same subset rule. +- Arrays are compared element by element, in order, using the subset rule per element; the actual array must have at least as many elements as the expected array, and only the first `expected.length` elements are checked. +- Primitive leaves are compared for strict equality. + +This lets a fixture assert, for example, only `ast.pattern` and `ast.system.role` without spelling out the entire tree. + +### `responses` + +When present, compared for strict equality (ordered, element-by-element) against `LintResult.ast.responses`. Equivalent to asserting `ast.responses`; provided as a convenience for response-splitting fixtures. + +## Determinism + +Because the linter is deterministic (no LLM, no network, no file system, stable diagnostic sort, preserved batch order), every fixture must produce the same result on every run. A fixture whose expectations depend on run order or environment is invalid. diff --git a/fixtures/valid/case-insensitive-if-then-upper.json b/fixtures/valid/case-insensitive-if-then-upper.json new file mode 100644 index 0000000..d3e5868 --- /dev/null +++ b/fixtures/valid/case-insensitive-if-then-upper.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-018", + "text": "IF the HMAC signature is invalid, THEN the billing service shall reject the webhook.", + "options": { + "dialect": { + "keywordCase": "case-insensitive" + } + }, + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["reject the webhook"] + } +} diff --git a/fixtures/valid/case-insensitive-when-lower.json b/fixtures/valid/case-insensitive-when-lower.json new file mode 100644 index 0000000..efa538e --- /dev/null +++ b/fixtures/valid/case-insensitive-when-lower.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-016", + "text": "when a payment webhook is received, the billing service shall verify the HMAC signature.", + "options": { + "dialect": { + "keywordCase": "case-insensitive" + } + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/case-insensitive-when-upper.json b/fixtures/valid/case-insensitive-when-upper.json new file mode 100644 index 0000000..3a0a475 --- /dev/null +++ b/fixtures/valid/case-insensitive-when-upper.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-017", + "text": "WHEN a payment webhook is received, THE billing service SHALL verify the HMAC signature.", + "options": { + "dialect": { + "keywordCase": "case-insensitive" + } + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/case-insensitive-while-mixed.json b/fixtures/valid/case-insensitive-while-mixed.json new file mode 100644 index 0000000..50af062 --- /dev/null +++ b/fixtures/valid/case-insensitive-while-mixed.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-019", + "text": "While the payment provider is unavailable, the billing service SHALL queue retryable events.", + "options": { + "dialect": { + "keywordCase": "case-insensitive" + } + }, + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["queue retryable events"] + } +} diff --git a/fixtures/valid/catalog-clause-term-unresolved.json b/fixtures/valid/catalog-clause-term-unresolved.json new file mode 100644 index 0000000..59ea87c --- /dev/null +++ b/fixtures/valid/catalog-clause-term-unresolved.json @@ -0,0 +1,21 @@ +{ + "id": "VAL-041", + "text": "When a chargeback is received, the billing service shall open a dispute case.", + "catalog": { + "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["BFF"] }], + "events": [{ "id": "EVT-WEBHOOK", "name": "a payment webhook is received" }] + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [ + { "code": "expr.unknown_term", "severity": "warning" }, + { "code": "catalog.event_unresolved", "severity": "warning" } + ], + "ast": { + "pattern": "event-driven", + "system": { "raw": "billing service", "role": "system" } + }, + "responses": ["open a dispute case"] + } +} diff --git a/fixtures/valid/complex-boolean-nested.json b/fixtures/valid/complex-boolean-nested.json new file mode 100644 index 0000000..83185af --- /dev/null +++ b/fixtures/valid/complex-boolean-nested.json @@ -0,0 +1,32 @@ +{ + "id": "VAL-034", + "text": "While the payment provider is available and (the retry queue is not full or the system is in maintenance mode), when a payment webhook is received, the billing service shall verify the HMAC signature.", + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "preconditions": { + "kind": "and", + "items": [ + { + "kind": "term" + }, + { + "kind": "group" + } + ] + }, + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/complex-when-if.json b/fixtures/valid/complex-when-if.json new file mode 100644 index 0000000..e107260 --- /dev/null +++ b/fixtures/valid/complex-when-if.json @@ -0,0 +1,25 @@ +{ + "id": "VAL-033", + "text": "When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook.", + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "unwanted": { + "kind": "term", + "text": "the HMAC signature is invalid" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["reject the webhook"] + } +} diff --git a/fixtures/valid/complex-where-if.json b/fixtures/valid/complex-where-if.json new file mode 100644 index 0000000..4e9f0ca --- /dev/null +++ b/fixtures/valid/complex-where-if.json @@ -0,0 +1,11 @@ +{ + "id": "VAL-CWIF", + "text": "Where dunning management is enabled, if the payment is declined, then the billing service shall retry the charge.", + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { "pattern": "complex", "system": { "raw": "billing service", "role": "system" } }, + "responses": ["retry the charge"] + } +} diff --git a/fixtures/valid/complex-while-when.json b/fixtures/valid/complex-while-when.json new file mode 100644 index 0000000..d2c9f2c --- /dev/null +++ b/fixtures/valid/complex-while-when.json @@ -0,0 +1,25 @@ +{ + "id": "VAL-031", + "text": "While the payment provider is available, when a payment webhook is received, the billing service shall verify the HMAC signature.", + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "preconditions": { + "kind": "term", + "text": "the payment provider is available" + }, + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/complex-while-where-when.json b/fixtures/valid/complex-while-where-when.json new file mode 100644 index 0000000..dc940aa --- /dev/null +++ b/fixtures/valid/complex-while-where-when.json @@ -0,0 +1,29 @@ +{ + "id": "VAL-032", + "text": "While the payment provider is available, where dunning management is enabled, when a payment is declined, the billing service shall retry the charge.", + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [], + "ast": { + "pattern": "complex", + "preconditions": { + "kind": "term", + "text": "the payment provider is available" + }, + "feature": { + "kind": "term", + "text": "dunning management is enabled" + }, + "trigger": { + "kind": "term", + "text": "a payment is declined" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["retry the charge"] + } +} diff --git a/fixtures/valid/event-driven-basic.json b/fixtures/valid/event-driven-basic.json new file mode 100644 index 0000000..2103f3c --- /dev/null +++ b/fixtures/valid/event-driven-basic.json @@ -0,0 +1,21 @@ +{ + "id": "VAL-007", + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/event-driven-refund.json b/fixtures/valid/event-driven-refund.json new file mode 100644 index 0000000..fd26c33 --- /dev/null +++ b/fixtures/valid/event-driven-refund.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-008", + "text": "When a refund is requested, the billing service shall issue a refund to the original payment method.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["issue a refund to the original payment method"] + } +} diff --git a/fixtures/valid/event-driven-subscription.json b/fixtures/valid/event-driven-subscription.json new file mode 100644 index 0000000..2a2b3b0 --- /dev/null +++ b/fixtures/valid/event-driven-subscription.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-009", + "text": "When a subscription is canceled, the billing service shall stop future invoicing.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["stop future invoicing"] + } +} diff --git a/fixtures/valid/expr-and-keyword-comma-off.json b/fixtures/valid/expr-and-keyword-comma-off.json new file mode 100644 index 0000000..4260d81 --- /dev/null +++ b/fixtures/valid/expr-and-keyword-comma-off.json @@ -0,0 +1,31 @@ +{ + "id": "VAL-024", + "text": "When a payment webhook is received and the signature header is present, the billing service shall verify the HMAC signature.", + "options": { + "commaAsAnd": false + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "and", + "items": [ + { + "kind": "term" + }, + { + "kind": "term" + } + ] + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/expr-and.json b/fixtures/valid/expr-and.json new file mode 100644 index 0000000..8459595 --- /dev/null +++ b/fixtures/valid/expr-and.json @@ -0,0 +1,28 @@ +{ + "id": "VAL-020", + "text": "When a payment webhook is received and the signature header is present, the billing service shall verify the HMAC signature.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "and", + "items": [ + { + "kind": "term" + }, + { + "kind": "term" + } + ] + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/expr-comma-as-and.json b/fixtures/valid/expr-comma-as-and.json new file mode 100644 index 0000000..04c9f01 --- /dev/null +++ b/fixtures/valid/expr-comma-as-and.json @@ -0,0 +1,23 @@ +{ + "id": "VAL-025", + "text": "When a payment webhook is received, the signature header is present, the billing service shall verify the HMAC signature.", + "options": { + "commaAsAnd": true + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "and" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/expr-group.json b/fixtures/valid/expr-group.json new file mode 100644 index 0000000..b3edd00 --- /dev/null +++ b/fixtures/valid/expr-group.json @@ -0,0 +1,28 @@ +{ + "id": "VAL-023", + "text": "When (a refund is requested or a chargeback is received) and the account is active, the billing service shall open a dispute case.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "and", + "items": [ + { + "kind": "group" + }, + { + "kind": "term" + } + ] + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["open a dispute case"] + } +} diff --git a/fixtures/valid/expr-not.json b/fixtures/valid/expr-not.json new file mode 100644 index 0000000..271db93 --- /dev/null +++ b/fixtures/valid/expr-not.json @@ -0,0 +1,23 @@ +{ + "id": "VAL-022", + "text": "While not the retry queue is full, the billing service shall accept new events.", + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "preconditions": { + "kind": "not", + "item": { + "kind": "term" + } + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["accept new events"] + } +} diff --git a/fixtures/valid/expr-or.json b/fixtures/valid/expr-or.json new file mode 100644 index 0000000..1af7aff --- /dev/null +++ b/fixtures/valid/expr-or.json @@ -0,0 +1,28 @@ +{ + "id": "VAL-021", + "text": "When a refund is requested or a chargeback is received, the billing service shall open a dispute case.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "or", + "items": [ + { + "kind": "term" + }, + { + "kind": "term" + } + ] + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["open a dispute case"] + } +} diff --git a/fixtures/valid/expr-precedence-warning.json b/fixtures/valid/expr-precedence-warning.json new file mode 100644 index 0000000..3f885e4 --- /dev/null +++ b/fixtures/valid/expr-precedence-warning.json @@ -0,0 +1,33 @@ +{ + "id": "VAL-030", + "text": "When a payment webhook is received and a refund is requested or a chargeback is received, the billing service shall open a dispute case.", + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [ + { + "code": "expr.operator_precedence_warning", + "severity": "warning" + } + ], + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "or", + "items": [ + { + "kind": "and" + }, + { + "kind": "term" + } + ] + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["open a dispute case"] + } +} diff --git a/fixtures/valid/frame-metadata-earsx.json b/fixtures/valid/frame-metadata-earsx.json new file mode 100644 index 0000000..1356695 --- /dev/null +++ b/fixtures/valid/frame-metadata-earsx.json @@ -0,0 +1,12 @@ +{ + "id": "VAL-FRAME", + "text": "REQ-001 When a payment webhook is received, the billing service shall verify the HMAC signature. [source: specs/billing.md:12]", + "options": { "dialect": { "allowFrameMetadata": true } }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { "pattern": "event-driven", "system": { "raw": "billing service", "role": "system" } }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/guided-catalog-system-unresolved.json b/fixtures/valid/guided-catalog-system-unresolved.json new file mode 100644 index 0000000..9904c2b --- /dev/null +++ b/fixtures/valid/guided-catalog-system-unresolved.json @@ -0,0 +1,15 @@ +{ + "id": "VAL-040", + "text": "The reconciliation engine shall verify the HMAC signature.", + "options": { + "mode": "guided" + }, + "catalog": { + "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["BFF"] }] + }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [{ "code": "catalog.system_unresolved", "severity": "warning" }] + } +} diff --git a/fixtures/valid/guided-empty-subexpression.json b/fixtures/valid/guided-empty-subexpression.json new file mode 100644 index 0000000..db97293 --- /dev/null +++ b/fixtures/valid/guided-empty-subexpression.json @@ -0,0 +1,11 @@ +{ + "id": "VAL-039", + "text": "When a payment webhook is received and (), the billing service shall verify the HMAC signature.", + "options": { + "mode": "guided" + }, + "expected": { + "valid": true, + "diagnostics": [{ "code": "expr.empty_subexpression", "severity": "warning" }] + } +} diff --git a/fixtures/valid/guided-if-without-then.json b/fixtures/valid/guided-if-without-then.json new file mode 100644 index 0000000..ea1a606 --- /dev/null +++ b/fixtures/valid/guided-if-without-then.json @@ -0,0 +1,12 @@ +{ + "id": "VAL-036", + "text": "If the HMAC signature is invalid, the billing service shall reject the webhook.", + "options": { + "mode": "guided" + }, + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [{ "code": "ears.invalid_if_then_form", "severity": "warning" }] + } +} diff --git a/fixtures/valid/guided-invalid-clause-order.json b/fixtures/valid/guided-invalid-clause-order.json new file mode 100644 index 0000000..5a29da5 --- /dev/null +++ b/fixtures/valid/guided-invalid-clause-order.json @@ -0,0 +1,12 @@ +{ + "id": "VAL-037", + "text": "When a payment webhook is received, while the payment provider is available, the billing service shall queue the event.", + "options": { + "mode": "guided" + }, + "expected": { + "valid": true, + "pattern": "complex", + "diagnostics": [{ "code": "ears.invalid_clause_order", "severity": "warning" }] + } +} diff --git a/fixtures/valid/guided-missing-shall.json b/fixtures/valid/guided-missing-shall.json new file mode 100644 index 0000000..6d22b95 --- /dev/null +++ b/fixtures/valid/guided-missing-shall.json @@ -0,0 +1,11 @@ +{ + "id": "VAL-035", + "text": "The billing service verifies the HMAC signature.", + "options": { + "mode": "guided" + }, + "expected": { + "valid": true, + "diagnostics": [{ "code": "ears.missing_shall", "severity": "warning" }] + } +} diff --git a/fixtures/valid/guided-unbalanced-parentheses.json b/fixtures/valid/guided-unbalanced-parentheses.json new file mode 100644 index 0000000..5060a8c --- /dev/null +++ b/fixtures/valid/guided-unbalanced-parentheses.json @@ -0,0 +1,11 @@ +{ + "id": "VAL-038", + "text": "While (the payment provider is available and the retry queue is not full, the billing service shall queue retryable events.", + "options": { + "mode": "guided" + }, + "expected": { + "valid": true, + "diagnostics": [{ "code": "expr.unbalanced_parentheses", "severity": "warning" }] + } +} diff --git a/fixtures/valid/kiro-optional-leading-comma.json b/fixtures/valid/kiro-optional-leading-comma.json new file mode 100644 index 0000000..2615e4f --- /dev/null +++ b/fixtures/valid/kiro-optional-leading-comma.json @@ -0,0 +1,12 @@ +{ + "id": "VAL-D001", + "text": "When a payment webhook is received the billing service shall verify the HMAC signature.", + "options": { "dialect": { "commaAfterLeadingClause": "optional" } }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { "pattern": "event-driven", "system": { "raw": "billing service", "role": "system" } }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/lint-alias-used.json b/fixtures/valid/lint-alias-used.json new file mode 100644 index 0000000..92a3c23 --- /dev/null +++ b/fixtures/valid/lint-alias-used.json @@ -0,0 +1,32 @@ +{ + "id": "VAL-026", + "text": "The BFF shall verify the HMAC signature.", + "catalog": { + "systems": [ + { + "id": "SYS-BILLING", + "name": "billing service", + "aliases": ["BFF"] + } + ] + }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "lint.alias_used", + "severity": "warning" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "BFF", + "role": "system", + "viaAlias": true + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/lint-multiple-responses.json b/fixtures/valid/lint-multiple-responses.json new file mode 100644 index 0000000..0e41e2a --- /dev/null +++ b/fixtures/valid/lint-multiple-responses.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-028", + "text": "The billing service shall verify the HMAC signature; reject invalid webhooks.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "lint.multiple_responses", + "severity": "warning" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature", "reject invalid webhooks"] + } +} diff --git a/fixtures/valid/lint-unparsed-tail.json b/fixtures/valid/lint-unparsed-tail.json new file mode 100644 index 0000000..23f6b84 --- /dev/null +++ b/fixtures/valid/lint-unparsed-tail.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-029", + "text": "The billing service shall verify the HMAC signature. Additional operator notes follow here.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "lint.unparsed_tail", + "severity": "warning" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/lint-vague-response.json b/fixtures/valid/lint-vague-response.json new file mode 100644 index 0000000..efe5d0f --- /dev/null +++ b/fixtures/valid/lint-vague-response.json @@ -0,0 +1,22 @@ +{ + "id": "VAL-027", + "text": "The billing service shall retry failed charges as needed.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [ + { + "code": "lint.vague_response", + "severity": "warning" + } + ], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["retry failed charges as needed"] + } +} diff --git a/fixtures/valid/literal-system-name.json b/fixtures/valid/literal-system-name.json new file mode 100644 index 0000000..f5450f9 --- /dev/null +++ b/fixtures/valid/literal-system-name.json @@ -0,0 +1,14 @@ +{ + "id": "VAL-D005", + "text": "WHEN a payment webhook is received, THE SYSTEM shall verify the HMAC signature.", + "options": { + "dialect": { "keywordCase": "case-insensitive", "allowLiteralSystemName": ["THE SYSTEM"] } + }, + "expected": { + "valid": true, + "pattern": "event-driven", + "diagnostics": [], + "ast": { "pattern": "event-driven", "system": { "raw": "THE SYSTEM", "role": "system" } }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/optional-feature-basic.json b/fixtures/valid/optional-feature-basic.json new file mode 100644 index 0000000..fb9d38d --- /dev/null +++ b/fixtures/valid/optional-feature-basic.json @@ -0,0 +1,21 @@ +{ + "id": "VAL-010", + "text": "Where dunning management is enabled, the billing service shall retry declined charges.", + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "feature": { + "kind": "term", + "text": "dunning management is enabled" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["retry declined charges"] + } +} diff --git a/fixtures/valid/optional-feature-multicurrency.json b/fixtures/valid/optional-feature-multicurrency.json new file mode 100644 index 0000000..1ea52f9 --- /dev/null +++ b/fixtures/valid/optional-feature-multicurrency.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-012", + "text": "Where multi-currency is enabled, the billing service shall convert charges to the customer currency.", + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["convert charges to the customer currency"] + } +} diff --git a/fixtures/valid/optional-feature-tax.json b/fixtures/valid/optional-feature-tax.json new file mode 100644 index 0000000..dc2d059 --- /dev/null +++ b/fixtures/valid/optional-feature-tax.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-011", + "text": "Where tax calculation is enabled, the billing service shall add sales tax to each invoice.", + "expected": { + "valid": true, + "pattern": "optional-feature", + "diagnostics": [], + "ast": { + "pattern": "optional-feature", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["add sales tax to each invoice"] + } +} diff --git a/fixtures/valid/prohibition-earsx.json b/fixtures/valid/prohibition-earsx.json new file mode 100644 index 0000000..a9ebd96 --- /dev/null +++ b/fixtures/valid/prohibition-earsx.json @@ -0,0 +1,16 @@ +{ + "id": "VAL-D004", + "text": "The billing service shall not log the payment token.", + "options": { "dialect": { "allowProhibition": true } }, + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "prohibition": true, + "system": { "raw": "billing service", "role": "system" } + }, + "responses": ["not log the payment token"] + } +} diff --git a/fixtures/valid/single-shall-and-response.json b/fixtures/valid/single-shall-and-response.json new file mode 100644 index 0000000..4ffc276 --- /dev/null +++ b/fixtures/valid/single-shall-and-response.json @@ -0,0 +1,10 @@ +{ + "id": "VAL-D008", + "text": "The billing service shall verify the HMAC signature and record an audit entry.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "responses": ["verify the HMAC signature and record an audit entry"] + } +} diff --git a/fixtures/valid/state-driven-basic.json b/fixtures/valid/state-driven-basic.json new file mode 100644 index 0000000..e9b5cee --- /dev/null +++ b/fixtures/valid/state-driven-basic.json @@ -0,0 +1,21 @@ +{ + "id": "VAL-004", + "text": "While the payment provider is unavailable, the billing service shall queue retryable events.", + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "preconditions": { + "kind": "term", + "text": "the payment provider is unavailable" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["queue retryable events"] + } +} diff --git a/fixtures/valid/state-driven-maintenance.json b/fixtures/valid/state-driven-maintenance.json new file mode 100644 index 0000000..cdf6eb8 --- /dev/null +++ b/fixtures/valid/state-driven-maintenance.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-006", + "text": "While the system is in maintenance mode, the billing service shall reject incoming webhooks.", + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["reject incoming webhooks"] + } +} diff --git a/fixtures/valid/state-driven-provider-degraded.json b/fixtures/valid/state-driven-provider-degraded.json new file mode 100644 index 0000000..a7f7cab --- /dev/null +++ b/fixtures/valid/state-driven-provider-degraded.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-005", + "text": "While the payment provider is degraded, the billing service shall reduce the webhook batch size.", + "expected": { + "valid": true, + "pattern": "state-driven", + "diagnostics": [], + "ast": { + "pattern": "state-driven", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["reduce the webhook batch size"] + } +} diff --git a/fixtures/valid/ubiquitous-audit-log.json b/fixtures/valid/ubiquitous-audit-log.json new file mode 100644 index 0000000..3246da9 --- /dev/null +++ b/fixtures/valid/ubiquitous-audit-log.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-003", + "text": "The payment gateway shall record an audit log entry for every processed webhook.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "payment gateway", + "role": "system" + } + }, + "responses": ["record an audit log entry for every processed webhook"] + } +} diff --git a/fixtures/valid/ubiquitous-basic.json b/fixtures/valid/ubiquitous-basic.json new file mode 100644 index 0000000..2a3ab1f --- /dev/null +++ b/fixtures/valid/ubiquitous-basic.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-001", + "text": "The billing service shall verify the HMAC signature.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["verify the HMAC signature"] + } +} diff --git a/fixtures/valid/ubiquitous-retry-limit.json b/fixtures/valid/ubiquitous-retry-limit.json new file mode 100644 index 0000000..00fa3cb --- /dev/null +++ b/fixtures/valid/ubiquitous-retry-limit.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-002", + "text": "The billing service shall retry failed webhook deliveries up to five times.", + "expected": { + "valid": true, + "pattern": "ubiquitous", + "diagnostics": [], + "ast": { + "pattern": "ubiquitous", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["retry failed webhook deliveries up to five times"] + } +} diff --git a/fixtures/valid/unwanted-behaviour-basic.json b/fixtures/valid/unwanted-behaviour-basic.json new file mode 100644 index 0000000..5dfc8aa --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-basic.json @@ -0,0 +1,21 @@ +{ + "id": "VAL-013", + "text": "If the HMAC signature is invalid, then the billing service shall reject the webhook.", + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "unwanted": { + "kind": "term", + "text": "the HMAC signature is invalid" + }, + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["reject the webhook"] + } +} diff --git a/fixtures/valid/unwanted-behaviour-declined.json b/fixtures/valid/unwanted-behaviour-declined.json new file mode 100644 index 0000000..b928bc4 --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-declined.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-014", + "text": "If the payment is declined, then the billing service shall notify the customer.", + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["notify the customer"] + } +} diff --git a/fixtures/valid/unwanted-behaviour-stale.json b/fixtures/valid/unwanted-behaviour-stale.json new file mode 100644 index 0000000..f85c510 --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-stale.json @@ -0,0 +1,17 @@ +{ + "id": "VAL-015", + "text": "If the webhook timestamp is stale, then the billing service shall discard the event.", + "expected": { + "valid": true, + "pattern": "unwanted-behaviour", + "diagnostics": [], + "ast": { + "pattern": "unwanted-behaviour", + "system": { + "raw": "billing service", + "role": "system" + } + }, + "responses": ["discard the event"] + } +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..3753595 --- /dev/null +++ b/lerna.json @@ -0,0 +1,18 @@ +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "version": "independent", + "npmClient": "pnpm", + "packages": ["packages/*"], + "command": { + "version": { + "allowBranch": ["master", "next"], + "message": "chore(release): version packages [skip ci]", + "conventionalCommits": true + }, + "publish": { + "allowBranch": ["master", "next"], + "registry": "https://registry.npmjs.org/", + "conventionalCommits": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b41c138 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "earsyntax-monorepo", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "author": "Suites", + "description": "Monorepo for the earsyntax toolkit: deterministic parsing, linting, extraction, and CLI facade for EARS requirements", + "scripts": { + "build": "tsc -b tsconfig.build.json", + "watch:ts": "tsc -b tsconfig.build.json --watch --pretty --incremental --preserveWatchOutput", + "clean": "pnpm --filter './packages/**' exec rimraf dist tsconfig.build.tsbuildinfo tsconfig.tsbuildinfo", + "lint": "eslint . --cache", + "lint:fix": "eslint . --cache --fix", + "typecheck": "tsc -b tsconfig.build.json", + "typecheck:tests": "tsc --project tsconfig.tests.json", + "check:deps": "dependency-cruiser --config .dependency-cruiser.cjs packages", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yaml,yml}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yaml,yml}\"", + "test": "pnpm -r --filter './packages/**' test", + "conformance": "pnpm build && pnpm typecheck && pnpm typecheck:tests && pnpm lint && pnpm test && vitest run --config vitest.conformance.config.ts", + "prepare": "husky", + "version:alpha": "lerna version --conventional-commits --conventional-prerelease --preid alpha --no-private", + "version:graduate": "lerna version --conventional-graduate --no-private", + "publish:packages": "pnpm -r --filter './packages/**' publish --no-git-checks --access public", + "release:preview": "lerna version --conventional-commits --no-git-tag-version --no-push --no-private" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@9.15.4", + "devDependencies": { + "@commitlint/cli": "catalog:", + "@commitlint/config-conventional": "catalog:", + "@eslint/js": "catalog:", + "@types/node": "catalog:", + "@typescript-eslint/eslint-plugin": "catalog:", + "@typescript-eslint/parser": "catalog:", + "dependency-cruiser": "catalog:", + "eslint": "catalog:", + "eslint-config-prettier": "catalog:", + "eslint-plugin-import-x": "catalog:", + "eslint-plugin-unicorn": "catalog:", + "globals": "catalog:", + "husky": "catalog:", + "lerna": "catalog:", + "lint-staged": "catalog:", + "prettier": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "vitest": "catalog:" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix --no-warn-ignored" + ] + } +} diff --git a/packages/cli-contract/package.json b/packages/cli-contract/package.json new file mode 100644 index 0000000..1c076e3 --- /dev/null +++ b/packages/cli-contract/package.json @@ -0,0 +1,49 @@ +{ + "name": "@earsyntax/cli-contract", + "version": "0.0.1-alpha.0", + "type": "module", + "license": "Apache-2.0", + "author": "Suites", + "description": "Shared report output contracts (JSON/SARIF) for ears-lint.", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "predev": "pnpm rimraf dist tsconfig.build.tsbuildinfo", + "build": "pnpm tsc -p tsconfig.build.json", + "dev": "pnpm tsc -p tsconfig.build.json --watch --incremental", + "lint": "pnpm eslint \"src/**/*.ts\"", + "lint:fix": "pnpm eslint \"src/**/*.ts\" --fix", + "test": "vitest run", + "test:watch": "vitest", + "boundaries": "depcruise src --config .dependency-cruiser.cjs", + "verify": "pnpm build && pnpm test", + "prepack": "pnpm build" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@earsyntax/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "dependency-cruiser": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cli-contract/src/exit-codes.test.ts b/packages/cli-contract/src/exit-codes.test.ts new file mode 100644 index 0000000..361de26 --- /dev/null +++ b/packages/cli-contract/src/exit-codes.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { Findings } from '@earsyntax/core'; +import { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForFindings } from './exit-codes.js'; + +function findings(partial: Partial): Findings { + const summary = { files: 1, requirements: 1, valid: 1, errors: 0, warnings: 0, ...partial }; + return { ok: summary.errors === 0, summary, diagnostics: [] }; +} + +describe('exit code constants', () => { + it('are the closed 0/1/2 surface (no exit 3)', () => { + expect(EXIT_OK).toBe(0); + expect(EXIT_LINT_ERRORS).toBe(1); + expect(EXIT_USAGE).toBe(2); + }); +}); + +describe('exitCodeForFindings', () => { + it('returns EXIT_LINT_ERRORS when findings have error diagnostics', () => { + expect(exitCodeForFindings(findings({ errors: 2, valid: 0 }))).toBe(EXIT_LINT_ERRORS); + }); + + it('returns EXIT_OK when findings have no error diagnostics', () => { + expect(exitCodeForFindings(findings({}))).toBe(EXIT_OK); + }); + + it('treats warnings alone as EXIT_OK', () => { + expect(exitCodeForFindings(findings({ warnings: 3 }))).toBe(EXIT_OK); + }); +}); diff --git a/packages/cli-contract/src/exit-codes.ts b/packages/cli-contract/src/exit-codes.ts new file mode 100644 index 0000000..9a9053c --- /dev/null +++ b/packages/cli-contract/src/exit-codes.ts @@ -0,0 +1,35 @@ +/** + * The exit-code contract for the host-native `earsyntax` CLI. + * + * The surface is `0`, `1`, `2` only (exit `3`, the old workspace-refusal code, + * is removed). A findings run derives its outcome code from a {@link Findings} + * value with {@link exitCodeForFindings}; a tool selects {@link EXIT_USAGE} + * itself for argument, profile, or environment failures, since those are not + * represented in a findings result. + */ + +import type { Findings } from '@earsyntax/core'; + +/** No error-severity findings. The run succeeded. */ +export const EXIT_OK = 0; + +/** At least one error-severity finding was reported. */ +export const EXIT_LINT_ERRORS = 1; + +/** A usage or environment failure occurred (bad flag, unknown profile, ...). */ +export const EXIT_USAGE = 2; + +/** + * The exit code implied by a findings result. + * + * Returns {@link EXIT_LINT_ERRORS} when the findings contain any error-severity + * diagnostic, otherwise {@link EXIT_OK}. It never returns {@link EXIT_USAGE}: + * usage and environment failures are a CLI concern, not a property of a + * completed findings run. Equivalent to `findings.ok ? EXIT_OK : EXIT_LINT_ERRORS`. + * + * @param findings The findings to inspect. + * @returns {@link EXIT_OK} or {@link EXIT_LINT_ERRORS}. + */ +export function exitCodeForFindings(findings: Findings): typeof EXIT_OK | typeof EXIT_LINT_ERRORS { + return findings.summary.errors > 0 ? EXIT_LINT_ERRORS : EXIT_OK; +} diff --git a/packages/cli-contract/src/findings-report.test.ts b/packages/cli-contract/src/findings-report.test.ts new file mode 100644 index 0000000..b34d8e2 --- /dev/null +++ b/packages/cli-contract/src/findings-report.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import type { Findings } from '@earsyntax/core'; +import { canonicalizeFindings, serializeFindings } from './findings-report.js'; + +const sample: Findings = { + ok: false, + summary: { files: 1, requirements: 3, valid: 2, errors: 1, warnings: 1 }, + diagnostics: [ + { + id: 'EARS-E006', + severity: 'error', + file: '.kiro/specs/checkout/requirements.md', + line: 12, + col: 3, + message: 'An If clause is missing its required then boundary.', + fix: 'Write: If , then the shall .', + requirementId: 'REQ-003', + }, + { + id: 'EARS-W016', + severity: 'warning', + file: '.kiro/specs/checkout/requirements.md', + line: 20, + message: 'The response contains a vague term.', + }, + ], +}; + +describe('serializeFindings', () => { + it('emits keys in the frozen contract order', () => { + const keys = (json: string) => Array.from(json.matchAll(/"(\w+)":/g), (m) => m[1]); + + expect(Object.keys(canonicalizeFindings(sample))).toEqual(['ok', 'summary', 'diagnostics']); + + const summaryKeys = keys(JSON.stringify(canonicalizeFindings(sample).summary)); + expect(summaryKeys).toEqual(['files', 'requirements', 'valid', 'errors', 'warnings']); + + const firstDiag = keys(JSON.stringify(canonicalizeFindings(sample).diagnostics[0])); + expect(firstDiag).toEqual([ + 'id', + 'severity', + 'file', + 'line', + 'col', + 'message', + 'fix', + 'requirementId', + ]); + }); + + it('omits optional keys when absent', () => { + const second = canonicalizeFindings(sample).diagnostics[1] as object; + expect(Object.keys(second)).toEqual(['id', 'severity', 'file', 'line', 'message']); + expect(Object.hasOwn(second, 'col')).toBe(false); + expect(Object.hasOwn(second, 'fix')).toBe(false); + expect(Object.hasOwn(second, 'requirementId')).toBe(false); + }); + + it('is byte-identical for the same input', () => { + expect(serializeFindings(sample)).toBe(serializeFindings(sample)); + }); + + it('is byte-identical regardless of input key order', () => { + const reordered: Findings = { + diagnostics: sample.diagnostics.map((d) => ({ + requirementId: d.requirementId, + message: d.message, + line: d.line, + file: d.file, + severity: d.severity, + id: d.id, + ...(d.col === undefined ? {} : { col: d.col }), + ...(d.fix === undefined ? {} : { fix: d.fix }), + })), + summary: { + warnings: sample.summary.warnings, + errors: sample.summary.errors, + valid: sample.summary.valid, + requirements: sample.summary.requirements, + files: sample.summary.files, + }, + ok: sample.ok, + }; + expect(serializeFindings(reordered)).toBe(serializeFindings(sample)); + }); + + it('uses 2-space indentation and no trailing newline', () => { + const out = serializeFindings(sample); + expect(out).toContain('\n "summary": {'); + expect(out.endsWith('\n')).toBe(false); + expect(JSON.parse(out)).toEqual(canonicalizeFindings(sample)); + }); + + it('round-trips a clean, empty findings result', () => { + const clean: Findings = { + ok: true, + summary: { files: 0, requirements: 0, valid: 0, errors: 0, warnings: 0 }, + diagnostics: [], + }; + expect(JSON.parse(serializeFindings(clean))).toEqual(clean); + }); +}); diff --git a/packages/cli-contract/src/findings-report.ts b/packages/cli-contract/src/findings-report.ts new file mode 100644 index 0000000..0f2c871 --- /dev/null +++ b/packages/cli-contract/src/findings-report.ts @@ -0,0 +1,78 @@ +/** + * Canonical JSON serialization of the Findings model. + * + * Findings v1 (`docs/contracts/findings.md`) is the single result every + * findings-bearing command returns. The canonical result is built in + * `@earsyntax/core` (`toFindings`); this module owns only its stable, byte- + * deterministic JSON projection. It reconstructs every object in the frozen key + * order so serialization stays canonical regardless of how the input + * {@link Findings} was assembled. + * + * Determinism contract: no I/O, no clock, no randomness. The same input always + * serializes to a byte-identical string. + */ + +import type { Findings, FindingsDiagnostic } from '@earsyntax/core'; + +/** + * Rebuild one diagnostic with keys in the fixed contract order: `id`, + * `severity`, `file`, `line`, `col?`, `message`, `fix?`, `requirementId?`. + * Optional keys are included only when present. + */ +function canonicalDiagnostic(diagnostic: FindingsDiagnostic): FindingsDiagnostic { + const out = {} as FindingsDiagnostic; + out.id = diagnostic.id; + out.severity = diagnostic.severity; + out.file = diagnostic.file; + out.line = diagnostic.line; + if (diagnostic.col !== undefined) { + out.col = diagnostic.col; + } + out.message = diagnostic.message; + if (diagnostic.fix !== undefined) { + out.fix = diagnostic.fix; + } + if (diagnostic.requirementId !== undefined) { + out.requirementId = diagnostic.requirementId; + } + return out; +} + +/** + * Rebuild a {@link Findings} value with every key in canonical order. + * + * `Findings`: `ok`, `summary`, `diagnostics`. `summary`: `files`, + * `requirements`, `valid`, `errors`, `warnings`. Each diagnostic follows + * {@link canonicalDiagnostic}. + * + * @param findings The findings to normalize. + * @returns A new findings value with canonical key order. + */ +export function canonicalizeFindings(findings: Findings): Findings { + return { + ok: findings.ok, + summary: { + files: findings.summary.files, + requirements: findings.summary.requirements, + valid: findings.summary.valid, + errors: findings.summary.errors, + warnings: findings.summary.warnings, + }, + diagnostics: findings.diagnostics.map(canonicalDiagnostic), + }; +} + +/** + * Serialize a {@link Findings} value to a stable, 2-space-indented JSON string. + * + * Keys are emitted in the frozen contract order (see + * {@link canonicalizeFindings}), so identical input always yields a byte- + * identical string. No trailing newline is appended: the command envelope layer + * owns final output framing. + * + * @param findings The findings to serialize. + * @returns The canonical JSON string. + */ +export function serializeFindings(findings: Findings): string { + return JSON.stringify(canonicalizeFindings(findings), null, 2); +} diff --git a/packages/cli-contract/src/index.ts b/packages/cli-contract/src/index.ts new file mode 100644 index 0000000..d540249 --- /dev/null +++ b/packages/cli-contract/src/index.ts @@ -0,0 +1,23 @@ +/** + * `@earsyntax/cli-contract` public API surface. + * + * Shared output contracts so external tools can consume `earsyntax` results + * without depending on the CLI. This package defines pure serializers and DATA + * shapes only: no I/O, no argument parsing, no `process.exit`, and no + * assumptions about command names or flags. + * + * The canonical result is the Findings model, built in `@earsyntax/core` + * (`toFindings`) and serialized here by {@link serializeFindings}. SARIF is a + * projection of that same Findings model ({@link buildSarifLog}). + * + * Determinism contract: serializers construct object keys in a fixed order and + * preserve input order, so identical input always produces identical output. + */ + +export type { Findings } from '@earsyntax/core'; + +export { canonicalizeFindings, serializeFindings } from './findings-report.js'; + +export { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForFindings } from './exit-codes.js'; + +export { buildSarifLog, serializeSarifLog } from './sarif.js'; diff --git a/packages/cli-contract/src/sarif.test.ts b/packages/cli-contract/src/sarif.test.ts new file mode 100644 index 0000000..e79a944 --- /dev/null +++ b/packages/cli-contract/src/sarif.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for the SARIF 2.1.0 projection of the Findings model. + * + * Two groups: unit assertions over {@link buildSarifLog}'s shape, ordering, + * severity mapping, URI normalization, and determinism; and a schema-validation + * group that loads the vendored canonical SARIF 2.1.0 LOG schema + * (`test/sarif-schema-2.1.0.json`) and checks every emitted log against the + * required-property, enum, and type constraints that schema declares on the + * shapes this projection produces. ajv is not in the pnpm catalog, so the + * validator is hand-rolled but reads its constraints from the real schema file. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Findings } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import { + buildSarifLog, + SARIF_SCHEMA, + SARIF_TOOL_NAME, + SARIF_VERSION, + serializeSarifLog, + type SarifLog, +} from './sarif.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** The vendored canonical SARIF 2.1.0 LOG schema, read once. */ +interface JsonSchema { + $id: string; + required: string[]; + properties: Record; + definitions: Record< + string, + { required?: string[]; properties?: Record } + >; +} +const SCHEMA: JsonSchema = JSON.parse( + readFileSync(join(HERE, '..', 'test', 'sarif-schema-2.1.0.json'), 'utf8'), +) as JsonSchema; + +/** A findings value with one error and one warning across two files. */ +const SAMPLE: Findings = { + ok: false, + summary: { files: 2, requirements: 3, valid: 1, errors: 1, warnings: 1 }, + diagnostics: [ + { + id: 'EARS-E006', + severity: 'error', + file: 'specs/checkout/requirements.md', + line: 12, + col: 3, + message: 'An If clause is missing its required then boundary.', + requirementId: 'REQ-003', + }, + { + id: 'EARS-W016', + severity: 'warning', + file: 'specs/orders/requirements.md', + line: 20, + message: 'The response contains a configured vague term.', + }, + ], +}; + +/** A clean run: no findings. */ +const CLEAN: Findings = { + ok: true, + summary: { files: 1, requirements: 2, valid: 2, errors: 0, warnings: 0 }, + diagnostics: [], +}; + +describe('buildSarifLog — shape and metadata', () => { + it('emits a single run with the earsyntax driver and the log schema/version', () => { + const log = buildSarifLog(SAMPLE); + expect(log.version).toBe(SARIF_VERSION); + expect(log.$schema).toBe(SARIF_SCHEMA); + expect(log.runs).toHaveLength(1); + expect(log.runs[0]?.tool.driver.name).toBe(SARIF_TOOL_NAME); + }); + + it('uses the canonical LOG schema $id, not the external-property-file schema', () => { + expect(SARIF_SCHEMA).toBe( + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', + ); + // The vendored schema declares itself with the same $id, and is the log + // schema (its top-level required set is version + runs). + expect(SCHEMA.$id).toBe(SARIF_SCHEMA); + expect(SCHEMA.required).toEqual(['version', 'runs']); + }); + + it('declares one rule per DISTINCT id, sorted by id, with registry metadata', () => { + const rules = buildSarifLog(SAMPLE).runs[0]?.tool.driver.rules ?? []; + expect(rules.map((rule) => rule.id)).toEqual(['EARS-E006', 'EARS-W016']); + const e006 = rules[0]; + expect(e006.shortDescription.text).toBe('Malformed If/then unwanted-behaviour form'); + expect(e006.fullDescription.text).toBe('An If clause is missing its required then boundary.'); + expect(e006.helpUri).toBe('docs/diagnostics.md#ears-e006'); + expect(e006.defaultConfiguration.level).toBe('error'); + expect(rules[1]?.defaultConfiguration.level).toBe('warning'); + }); + + it('collapses repeated ids into a single rule and keeps result ruleIndex aligned', () => { + const repeated: Findings = { + ok: false, + summary: { files: 1, requirements: 2, valid: 0, errors: 2, warnings: 0 }, + diagnostics: [ + { id: 'EARS-W016', severity: 'warning', file: 'a.md', line: 1, message: 'm1' }, + { id: 'EARS-E006', severity: 'error', file: 'a.md', line: 2, message: 'm2' }, + { id: 'EARS-W016', severity: 'warning', file: 'a.md', line: 3, message: 'm3' }, + ], + }; + const run = buildSarifLog(repeated).runs[0]; + const rules = run.tool.driver.rules; + expect(rules.map((rule) => rule.id)).toEqual(['EARS-E006', 'EARS-W016']); + const results = run.results; + // Results preserve findings order; ruleIndex points into the sorted rules. + expect(results.map((result) => [result.ruleId, result.ruleIndex])).toEqual([ + ['EARS-W016', 1], + ['EARS-E006', 0], + ['EARS-W016', 1], + ]); + }); + + it('maps effective severity to the SARIF level; the result level, not the default', () => { + const upgraded: Findings = { + ok: false, + summary: { files: 1, requirements: 1, valid: 0, errors: 1, warnings: 0 }, + // A W-band id carrying effective severity error (as after --strict). + diagnostics: [{ id: 'EARS-W016', severity: 'error', file: 'a.md', line: 1, message: 'm' }], + }; + const run = buildSarifLog(upgraded).runs[0]; + expect(run.results[0]?.level).toBe('error'); + // The rule's default configuration still reflects the registry default. + expect(run.tool.driver.rules[0]?.defaultConfiguration.level).toBe('warning'); + }); + + it('carries startLine always and startColumn only when col is present', () => { + const results = buildSarifLog(SAMPLE).runs[0]?.results ?? []; + expect(results[0]?.locations[0]?.physicalLocation.region).toEqual({ + startLine: 12, + startColumn: 3, + }); + expect(results[1]?.locations[0]?.physicalLocation.region).toEqual({ startLine: 20 }); + }); + + it('carries the requirement id on result.properties when present, omits it otherwise', () => { + const results = buildSarifLog(SAMPLE).runs[0]?.results ?? []; + expect(results[0]?.properties).toEqual({ requirementId: 'REQ-003' }); + expect(results[1]?.properties).toBeUndefined(); + }); + + it('records tool.driver.version only when toolVersion is supplied', () => { + expect(buildSarifLog(CLEAN).runs[0]?.tool.driver.version).toBeUndefined(); + expect(buildSarifLog(CLEAN, { toolVersion: '1.2.3' }).runs[0]?.tool.driver.version).toBe( + '1.2.3', + ); + }); + + it('emits an empty, valid log for a clean run', () => { + const log = buildSarifLog(CLEAN); + expect(log.runs[0]?.results).toEqual([]); + expect(log.runs[0]?.tool.driver.rules).toEqual([]); + }); +}); + +describe('buildSarifLog — URI normalization', () => { + const uriFor = (file: string): string => { + const findings: Findings = { + ok: false, + summary: { files: 1, requirements: 1, valid: 0, errors: 1, warnings: 0 }, + diagnostics: [{ id: 'EARS-E006', severity: 'error', file, line: 1, message: 'm' }], + }; + return ( + buildSarifLog(findings).runs[0]?.results[0]?.locations[0]?.physicalLocation.artifactLocation + .uri ?? '' + ); + }; + + it('leaves a relative POSIX path as forward-slash segments', () => { + expect(uriFor('specs/checkout/requirements.md')).toBe('specs/checkout/requirements.md'); + }); + + it('drops a leading slash so an absolute path becomes relative', () => { + expect(uriFor('/work/specs/a.ears')).toBe('work/specs/a.ears'); + }); + + it('collapses Windows separators and drops the drive segment', () => { + expect(uriFor('C:\\work\\specs\\a.ears')).toBe('work/specs/a.ears'); + }); + + it('percent-encodes spaces and reserved characters per segment', () => { + expect(uriFor('my specs/a b.ears')).toBe('my%20specs/a%20b.ears'); + }); + + it('passes the stdin sentinel through as a single segment', () => { + expect(uriFor('-')).toBe('-'); + }); +}); + +describe('buildSarifLog — determinism', () => { + it('serializes byte-identically for identical findings', () => { + expect(serializeSarifLog(buildSarifLog(SAMPLE))).toBe(serializeSarifLog(buildSarifLog(SAMPLE))); + }); + + it('produces no timestamps or clock-derived fields', () => { + const text = serializeSarifLog(buildSarifLog(SAMPLE)); + expect(text).not.toMatch(/\d{4}-\d{2}-\d{2}T/); + }); + + it('round-trips through JSON unchanged', () => { + const log = buildSarifLog(SAMPLE); + expect(JSON.parse(JSON.stringify(log))).toEqual(log); + }); +}); + +/** + * Validate a SARIF log against the required-property, enum, and type constraints + * the vendored schema declares on the shapes this projection emits. Returns the + * list of violations (empty when the log conforms). This is not a full JSON + * Schema validator; it checks exactly the constraints relevant to our output, + * reading each constraint from the real schema so the assertions track the spec. + */ +function schemaViolations(log: SarifLog): string[] { + const problems: string[] = []; + const def = SCHEMA.definitions; + const has = (obj: object, key: string): boolean => Object.hasOwn(obj, key); + const requireKeys = (obj: object, keys: string[] | undefined, where: string): void => { + for (const key of keys ?? []) { + if (!has(obj, key)) { + problems.push(`${where}: missing required "${key}"`); + } + } + }; + + for (const key of SCHEMA.required) { + if (!has(log, key)) { + problems.push(`log: missing required "${key}"`); + } + } + if (!SCHEMA.properties.version.enum?.includes(log.version)) { + problems.push(`log.version "${log.version}" not in schema enum`); + } + if (typeof log.$schema !== 'string') { + problems.push('log.$schema is not a string'); + } + if (!Array.isArray(log.runs)) { + problems.push('log.runs is not an array'); + } + + const resultLevels = def.result.properties?.level.enum ?? []; + const configLevels = def.reportingConfiguration.properties?.level.enum ?? []; + const startLineMin = def.region.properties?.startLine.minimum ?? 1; + const startColumnMin = def.region.properties?.startColumn.minimum ?? 1; + + log.runs.forEach((run, ri) => { + requireKeys(run, def.run.required, `runs[${ri}]`); + requireKeys(run.tool, def.tool.required, `runs[${ri}].tool`); + requireKeys(run.tool.driver, def.toolComponent.required, `runs[${ri}].tool.driver`); + + run.tool.driver.rules.forEach((rule, di) => { + const at = `runs[${ri}].tool.driver.rules[${di}]`; + requireKeys(rule, def.reportingDescriptor.required, at); + if (typeof rule.id !== 'string') { + problems.push(`${at}.id is not a string`); + } + requireKeys( + rule.shortDescription, + def.multiformatMessageString.required, + `${at}.shortDescription`, + ); + if (!configLevels.includes(rule.defaultConfiguration.level)) { + problems.push( + `${at}.defaultConfiguration.level "${rule.defaultConfiguration.level}" not in enum`, + ); + } + }); + + run.results.forEach((result, si) => { + const at = `runs[${ri}].results[${si}]`; + requireKeys(result, def.result.required, at); + requireKeys(result.message, def.multiformatMessageString.required, `${at}.message`); + if (typeof result.ruleId !== 'string') { + problems.push(`${at}.ruleId is not a string`); + } + if (!Number.isInteger(result.ruleIndex)) { + problems.push(`${at}.ruleIndex is not an integer`); + } + if (!resultLevels.includes(result.level)) { + problems.push(`${at}.level "${result.level}" not in enum`); + } + result.locations.forEach((location, li) => { + const region = location.physicalLocation.region; + const rat = `${at}.locations[${li}].physicalLocation`; + const uri = location.physicalLocation.artifactLocation.uri; + if (typeof uri !== 'string') { + problems.push(`${rat}.artifactLocation.uri is not a string`); + } + if (uri.startsWith('/') || /^[A-Za-z]:/.test(uri) || uri.includes('\\')) { + problems.push( + `${rat}.artifactLocation.uri "${uri}" is not a relative forward-slash path`, + ); + } + if (!Number.isInteger(region.startLine) || region.startLine < startLineMin) { + problems.push( + `${rat}.region.startLine "${region.startLine}" violates minimum ${startLineMin}`, + ); + } + if (region.startColumn !== undefined && region.startColumn < startColumnMin) { + problems.push( + `${rat}.region.startColumn "${region.startColumn}" violates minimum ${startColumnMin}`, + ); + } + }); + }); + }); + + return problems; +} + +describe('buildSarifLog — schema conformance', () => { + it('conforms to the vendored SARIF 2.1.0 schema for a populated run', () => { + expect(schemaViolations(buildSarifLog(SAMPLE))).toEqual([]); + }); + + it('conforms to the vendored SARIF 2.1.0 schema for a clean run', () => { + expect(schemaViolations(buildSarifLog(CLEAN))).toEqual([]); + }); + + it('conforms with Windows and absolute input paths normalized away', () => { + const findings: Findings = { + ok: false, + summary: { files: 2, requirements: 2, valid: 0, errors: 2, warnings: 0 }, + diagnostics: [ + { + id: 'EARS-E006', + severity: 'error', + file: 'C:\\work\\a.ears', + line: 1, + col: 2, + message: 'm', + }, + { id: 'EARS-E007', severity: 'error', file: '/abs/b.ears', line: 3, message: 'n' }, + ], + }; + expect(schemaViolations(buildSarifLog(findings))).toEqual([]); + }); +}); diff --git a/packages/cli-contract/src/sarif.ts b/packages/cli-contract/src/sarif.ts new file mode 100644 index 0000000..29c75ff --- /dev/null +++ b/packages/cli-contract/src/sarif.ts @@ -0,0 +1,285 @@ +/** + * SARIF 2.1.0 projection of the canonical Findings model. + * + * SARIF (Static Analysis Results Interchange Format) is the OASIS standard that + * code-scanning tools (GitHub code scanning, editors, CI dashboards) consume. + * This module is a pure PROJECTION of a {@link Findings} value: it lints, + * parses, and re-derives nothing. One SARIF `result` is emitted per finding, and + * the run declares one rule per distinct diagnostic id present in the findings. + * Rule metadata (title, meaning, help anchor, default severity) is read from the + * diagnostic registry in `@earsyntax/core`, the single source of truth. + * + * Because SARIF is downstream of Findings, `--strict` and profile overrides are + * already baked into each `Diagnostic.severity` before projection; the SARIF + * `result.level` is the EFFECTIVE severity with no additional logic, while the + * rule's `defaultConfiguration.level` records the registry DEFAULT severity. + * + * Determinism contract: no I/O, no clock, no randomness, no timestamps. Rules + * are ordered by id (code-unit); results preserve the findings' stable order. The + * same {@link Findings} value always produces a byte-identical log. + * + * Schema reference (the canonical SARIF 2.1.0 LOG schema, whose own `$id` is the + * URL below): the vendored copy lives at + * `packages/cli-contract/test/sarif-schema-2.1.0.json`. + */ + +import { type FindingsDiagnostic, type Findings, getDiagnosticEntry } from '@earsyntax/core'; + +/** + * The canonical SARIF 2.1.0 LOG schema URL. + * + * This is the schema's own declared `$id`. The legacy emitter pointed `$schema` + * at a GitHub blob HTML page of the SARIF EXTERNAL PROPERTY FILE schema (a + * different, wrong schema); this is the log schema every SARIF consumer expects. + */ +export const SARIF_SCHEMA = + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json'; + +/** The SARIF version string this projection targets. */ +export const SARIF_VERSION = '2.1.0' as const; + +/** The tool driver name reported in every SARIF run. */ +export const SARIF_TOOL_NAME = 'earsyntax'; + +/** + * SARIF result levels this projection emits. + * + * The Findings model has no `info` severity, so `note` and `none` never appear; + * only `error` and `warning` are produced. + */ +export type SarifLevel = 'error' | 'warning'; + +/** A SARIF rule descriptor (`reportingDescriptor`) declared on the tool driver. */ +export interface SarifRule { + /** The rule id, equal to the diagnostic's `EARS-*` id. */ + id: string; + /** A short, one-line description of the rule (the registry title). */ + shortDescription: { text: string }; + /** A fuller description of the rule (the registry meaning). */ + fullDescription: { text: string }; + /** A stable docs anchor for the rule (`docs/diagnostics.md#ears-e001` style). */ + helpUri: string; + /** The registry DEFAULT severity, independent of a result's effective level. */ + defaultConfiguration: { level: SarifLevel }; +} + +/** + * A SARIF physical location region. + * + * `startLine` is always present (a {@link FindingsDiagnostic} always carries a + * line). `startColumn` is included only when the finding carries column data; + * no end coordinate exists in the Findings model, so `endColumn` is never set. + */ +export interface SarifRegion { + /** 1-based start line. Always present. */ + startLine: number; + /** 1-based start column, when the finding maps to a specific column. */ + startColumn?: number; +} + +/** A SARIF physical location. */ +export interface SarifPhysicalLocation { + /** The artifact (source file) the result points at, as a relative URI. */ + artifactLocation: { uri: string }; + /** The region within the artifact. */ + region: SarifRegion; +} + +/** A SARIF result location. */ +export interface SarifLocation { + physicalLocation: SarifPhysicalLocation; +} + +/** A single SARIF result (one finding). */ +export interface SarifResult { + /** The id of the rule this result violates (the `EARS-*` id). */ + ruleId: string; + /** The index of the rule in the driver's `rules` array. */ + ruleIndex: number; + /** The EFFECTIVE severity level. */ + level: SarifLevel; + /** The human-readable message. */ + message: { text: string }; + /** Where the result was found. Always exactly one location. */ + locations: SarifLocation[]; + /** The requirement's own id, when the extractor found one. */ + properties?: { requirementId: string }; +} + +/** A SARIF tool driver. */ +export interface SarifDriver { + /** The tool name. Always {@link SARIF_TOOL_NAME}. */ + name: string; + /** The tool version, when supplied via {@link BuildSarifOptions.toolVersion}. */ + version?: string; + /** The rules this run can report, one per distinct id, sorted by id. */ + rules: SarifRule[]; +} + +/** A single SARIF run. */ +export interface SarifRun { + /** The tool that produced the run. */ + tool: { driver: SarifDriver }; + /** The results the run produced, in findings order. */ + results: SarifResult[]; +} + +/** A complete SARIF log. */ +export interface SarifLog { + /** The SARIF LOG schema URL. Always {@link SARIF_SCHEMA}. */ + $schema: string; + /** The SARIF version. Always {@link SARIF_VERSION}. */ + version: typeof SARIF_VERSION; + /** The runs in this log. This projection always emits exactly one. */ + runs: SarifRun[]; +} + +/** Options that tune {@link buildSarifLog}. */ +export interface BuildSarifOptions { + /** When set, recorded as `tool.driver.version`. Omitted otherwise. */ + toolVersion?: string; +} + +/** + * Map a Findings effective severity to its SARIF level. + * + * The mapping is total and identity-like: `error` to `error`, `warning` to + * `warning`. The Findings model has no third severity, so no `note`/`none` + * branch is reachable. + */ +function toSarifLevel(severity: FindingsDiagnostic['severity']): SarifLevel { + return severity === 'error' ? 'error' : 'warning'; +} + +/** + * Normalize a Findings `file` into a relative, forward-slash, URI-encoded path. + * + * SARIF `artifactLocation.uri` must not carry a raw absolute or Windows path. + * This drops a leading slash or a `C:` drive segment (making the path relative), + * collapses `\` and `/` separators to `/`, discards `.` segments, and URI-encodes + * each remaining segment. The stdin sentinel `-` passes through as the single + * segment `-`. + * + * @param file The `Diagnostic.file` value (relative POSIX path, `-`, or, when the + * caller passed one, an absolute or Windows path). + * @returns A relative, forward-slash, percent-encoded URI reference. + */ +function toArtifactUri(file: string): string { + const segments = file + .split(/[/\\]/) + .filter((segment) => segment.length > 0 && segment !== '.' && !/^[A-Za-z]:$/.test(segment)); + return segments.map((segment) => encodeURIComponent(segment)).join('/'); +} + +/** Build the physical-location region for one finding. */ +function toRegion(diagnostic: FindingsDiagnostic): SarifRegion { + const region = {} as SarifRegion; + region.startLine = diagnostic.line; + if (diagnostic.col !== undefined) { + region.startColumn = diagnostic.col; + } + return region; +} + +/** The docs anchor a rule's `helpUri` points at: `docs/diagnostics.md#ears-e001`. */ +function helpUriForId(id: string): string { + return `docs/diagnostics.md#${id.toLowerCase()}`; +} + +/** Build one rule descriptor for a distinct diagnostic id, reading registry metadata. */ +function buildRule(id: string): SarifRule { + const entry = getDiagnosticEntry(id); + // Keys constructed in a fixed order for byte-stable output. An id with no + // registry entry (which should not occur for a resolved id) falls back to the + // id itself and to its band prefix for the default level. + const rule = {} as SarifRule; + rule.id = id; + rule.shortDescription = { text: entry ? entry.title : id }; + rule.fullDescription = { text: entry ? entry.meaning : id }; + rule.helpUri = helpUriForId(id); + rule.defaultConfiguration = { + level: entry ? entry.defaultSeverity : id.startsWith('EARS-E') ? 'error' : 'warning', + }; + return rule; +} + +/** Build one result for a finding, given the rule index for its id. */ +function buildResult(diagnostic: FindingsDiagnostic, ruleIndex: number): SarifResult { + // Keys constructed in a fixed order for byte-stable output. + const result = {} as SarifResult; + result.ruleId = diagnostic.id; + result.ruleIndex = ruleIndex; + result.level = toSarifLevel(diagnostic.severity); + result.message = { text: diagnostic.message }; + result.locations = [ + { + physicalLocation: { + artifactLocation: { uri: toArtifactUri(diagnostic.file) }, + region: toRegion(diagnostic), + }, + }, + ]; + if (diagnostic.requirementId !== undefined) { + result.properties = { requirementId: diagnostic.requirementId }; + } + return result; +} + +/** + * Project a {@link Findings} value to a SARIF 2.1.0 log. + * + * The single run declares one rule per DISTINCT diagnostic id present in the + * findings (sorted by id, code-unit order) rather than the full registry, so the + * log stays lean and its `rules` array carries only what its results reference. + * Each result is emitted in the findings' stable order and references its rule by + * `ruleId` and `ruleIndex`. `result.level` is the finding's effective severity; + * the rule's `defaultConfiguration.level` is the registry default. The + * requirement id, when present, is carried on `result.properties.requirementId`. + * + * @param findings The canonical findings to project. + * @param options Optional tuning; `toolVersion` is recorded on the driver. + * @returns A SARIF log ready to serialize with `JSON.stringify`. + */ +export function buildSarifLog(findings: Findings, options: BuildSarifOptions = {}): SarifLog { + const distinctIds = [...new Set(findings.diagnostics.map((diagnostic) => diagnostic.id))].sort( + (a, b) => (a < b ? -1 : a > b ? 1 : 0), + ); + const ruleIndexById = new Map(distinctIds.map((id, index): [string, number] => [id, index])); + const rules = distinctIds.map(buildRule); + + const results = findings.diagnostics.map((diagnostic) => { + const ruleIndex = ruleIndexById.get(diagnostic.id); + if (ruleIndex === undefined) { + // Unreachable: distinctIds is derived from these same diagnostics. + throw new Error(`No SARIF rule index for diagnostic id "${diagnostic.id}".`); + } + return buildResult(diagnostic, ruleIndex); + }); + + const driver = {} as SarifDriver; + driver.name = SARIF_TOOL_NAME; + if (options.toolVersion !== undefined) { + driver.version = options.toolVersion; + } + driver.rules = rules; + + return { + $schema: SARIF_SCHEMA, + version: SARIF_VERSION, + runs: [{ tool: { driver }, results }], + }; +} + +/** + * Serialize a SARIF log to a stable, 2-space-indented JSON string. + * + * A thin wrapper over `JSON.stringify`: the log's keys are already constructed in + * a fixed order, so the same {@link Findings} always yields a byte-identical + * string. No trailing newline is appended; the output layer owns final framing. + * + * @param log The SARIF log to serialize. + * @returns The canonical JSON string. + */ +export function serializeSarifLog(log: SarifLog): string { + return JSON.stringify(log, null, 2); +} diff --git a/packages/cli-contract/test/sarif-schema-2.1.0.json b/packages/cli-contract/test/sarif-schema-2.1.0.json new file mode 100644 index 0000000..9611789 --- /dev/null +++ b/packages/cli-contract/test/sarif-schema-2.1.0.json @@ -0,0 +1,3282 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema", + "$id": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "description": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema: a standard format for the output of static analysis tools.", + "additionalProperties": false, + "type": "object", + "properties": { + "$schema": { + "description": "The URI of the JSON schema corresponding to the version.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this log file.", + "enum": ["2.1.0"] + }, + + "runs": { + "description": "The set of runs contained in this log file.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/run" + } + }, + + "inlineExternalProperties": { + "description": "References to external property files that share data between runs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/externalProperties" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the log file.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["version", "runs"], + + "definitions": { + "address": { + "description": "A physical or virtual address, or a range of addresses, in an 'addressable region' (memory or a binary file).", + "additionalProperties": false, + "type": "object", + "properties": { + "absoluteAddress": { + "description": "The address expressed as a byte offset from the start of the addressable region.", + "type": "integer", + "minimum": -1, + "default": -1 + }, + + "relativeAddress": { + "description": "The address expressed as a byte offset from the absolute address of the top-most parent object.", + "type": "integer" + }, + + "length": { + "description": "The number of bytes in this range of addresses.", + "type": "integer" + }, + + "kind": { + "description": "An open-ended string that identifies the address kind. 'data', 'function', 'header','instruction', 'module', 'page', 'section', 'segment', 'stack', 'stackFrame', 'table' are well-known values.", + "type": "string" + }, + + "name": { + "description": "A name that is associated with the address, e.g., '.text'.", + "type": "string" + }, + + "fullyQualifiedName": { + "description": "A human-readable fully qualified name that is associated with the address.", + "type": "string" + }, + + "offsetFromParent": { + "description": "The byte offset of this address from the absolute or relative address of the parent object.", + "type": "integer" + }, + + "index": { + "description": "The index within run.addresses of the cached object for this address.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "parentIndex": { + "description": "The index within run.addresses of the parent object.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the address.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifact": { + "description": "A single artifact. In some cases, this artifact might be nested within another artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "description": { + "description": "A short description of the artifact.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the artifact, if this artifact is nested.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "offset": { + "description": "The offset in bytes of the artifact within its containing artifact.", + "type": "integer", + "minimum": 0 + }, + + "length": { + "description": "The length of the artifact in bytes.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "roles": { + "description": "The role or roles played by the artifact in the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "enum": [ + "analysisTarget", + "attachment", + "responseFile", + "resultFile", + "standardStream", + "tracedFile", + "unmodified", + "modified", + "added", + "deleted", + "renamed", + "uncontrolled", + "driver", + "extension", + "translation", + "taxonomy", + "policy", + "referencedOnCommandLine", + "memoryContents", + "directory", + "userSpecifiedConfiguration", + "toolSpecifiedConfiguration", + "debugOutputFile" + ] + } + }, + + "mimeType": { + "description": "The MIME type (RFC 2045) of the artifact.", + "type": "string", + "pattern": "[^/]+/.+" + }, + + "contents": { + "description": "The contents of the artifact.", + "$ref": "#/definitions/artifactContent" + }, + + "encoding": { + "description": "Specifies the encoding for an artifact object that refers to a text file.", + "type": "string" + }, + + "sourceLanguage": { + "description": "Specifies the source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "hashes": { + "description": "A dictionary, each of whose keys is the name of a hash function and each of whose values is the hashed value of the artifact produced by the specified hash function.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "lastModifiedTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the artifact was most recently modified. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactChange": { + "description": "A change to a single artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "artifactLocation": { + "description": "The location of the artifact to change.", + "$ref": "#/definitions/artifactLocation" + }, + + "replacements": { + "description": "An array of replacement objects, each of which represents the replacement of a single region in a single artifact specified by 'artifactLocation'.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/replacement" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the change.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["artifactLocation", "replacements"] + }, + + "artifactContent": { + "description": "Represents the contents of an artifact.", + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "description": "UTF-8-encoded content from a text artifact.", + "type": "string" + }, + + "binary": { + "description": "MIME Base64-encoded content from a binary artifact, or from a text artifact in its original encoding.", + "type": "string" + }, + + "rendered": { + "description": "An alternate rendered representation of the artifact (e.g., a decompiled representation of a binary region).", + "$ref": "#/definitions/multiformatMessageString" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact content.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactLocation": { + "description": "Specifies the location of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "uri": { + "description": "A string containing a valid relative or absolute URI.", + "type": "string", + "format": "uri-reference" + }, + + "uriBaseId": { + "description": "A string which indirectly specifies the absolute URI with respect to which a relative URI in the \"uri\" property is interpreted.", + "type": "string" + }, + + "index": { + "description": "The index within the run artifacts array of the artifact object associated with the artifact location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A short description of the artifact location.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "attachment": { + "description": "An artifact relevant to a result.", + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "description": "A message describing the role played by the attachment.", + "$ref": "#/definitions/message" + }, + + "artifactLocation": { + "description": "The location of the attachment.", + "$ref": "#/definitions/artifactLocation" + }, + + "regions": { + "description": "An array of regions of interest within the attachment.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "rectangles": { + "description": "An array of rectangles specifying areas of interest within the image.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/rectangle" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the attachment.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["artifactLocation"] + }, + + "codeFlow": { + "description": "A set of threadFlows which together describe a pattern of code execution relevant to detecting a result.", + "additionalProperties": false, + "type": "object", + "properties": { + "message": { + "description": "A message relevant to the code flow.", + "$ref": "#/definitions/message" + }, + + "threadFlows": { + "description": "An array of one or more unique threadFlow objects, each of which describes the progress of a program through a thread of execution.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlow" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the code flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["threadFlows"] + }, + + "configurationOverride": { + "description": "Information about how a specific rule or notification was reconfigured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + "configuration": { + "description": "Specifies how the rule or notification was configured during the scan.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor whose configuration was overridden.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the configuration override.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["configuration", "descriptor"] + }, + + "conversion": { + "description": "Describes how a converter transformed the output of a static analysis tool from the analysis tool's native output format into the SARIF format.", + "additionalProperties": false, + "type": "object", + "properties": { + "tool": { + "description": "A tool object that describes the converter.", + "$ref": "#/definitions/tool" + }, + + "invocation": { + "description": "An invocation object that describes the invocation of the converter.", + "$ref": "#/definitions/invocation" + }, + + "analysisToolLogFiles": { + "description": "The locations of the analysis tool's per-run log files.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the conversion.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["tool"] + }, + + "edge": { + "description": "Represents a directed edge in a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "description": "A string that uniquely identifies the edge within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the edge.", + "$ref": "#/definitions/message" + }, + + "sourceNodeId": { + "description": "Identifies the source node (the node at which the edge starts).", + "type": "string" + }, + + "targetNodeId": { + "description": "Identifies the target node (the node at which the edge ends).", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["id", "sourceNodeId", "targetNodeId"] + }, + + "edgeTraversal": { + "description": "Represents the traversal of a single edge during a graph traversal.", + "type": "object", + "additionalProperties": false, + "properties": { + "edgeId": { + "description": "Identifies the edge being traversed.", + "type": "string" + }, + + "message": { + "description": "A message to display to the user as the edge is traversed.", + "$ref": "#/definitions/message" + }, + + "finalState": { + "description": "The values of relevant expressions after the edge has been traversed.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "stepOverEdgeCount": { + "description": "The number of edge traversals necessary to return from a nested graph.", + "type": "integer", + "minimum": 0 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["edgeId"] + }, + + "exception": { + "description": "Describes a runtime exception encountered during the execution of an analysis tool.", + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "description": "A string that identifies the kind of exception, for example, the fully qualified type name of an object that was thrown, or the symbolic name of a signal." + }, + + "message": { + "description": "A message that describes the exception.", + "type": "string" + }, + + "stack": { + "description": "The sequence of function calls leading to the exception.", + "$ref": "#/definitions/stack" + }, + + "innerExceptions": { + "description": "An array of exception objects each of which is considered a cause of this exception.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/exception" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the exception.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalProperties": { + "description": "The top-level element of an external property file.", + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "description": "The URI of the JSON schema corresponding to the version of the external property file format.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this external properties object.", + "enum": ["2.1.0"] + }, + + "guid": { + "description": "A stable, unique identifer for this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "runGuid": { + "description": "A stable, unique identifer for the run associated with this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "conversion": { + "description": "A conversion object that will be merged with a separate run.", + "$ref": "#/definitions/conversion" + }, + + "graphs": { + "description": "An array of graph objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/graph" + } + }, + + "externalizedProperties": { + "description": "Key/value pairs that provide additional information that will be merged with a separate run.", + "$ref": "#/definitions/propertyBag" + }, + + "artifacts": { + "description": "An array of artifact objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "results": { + "description": "An array of result objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/result" + } + }, + + "taxonomies": { + "description": "Tool taxonomies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "driver": { + "description": "The analysis tool object that will be merged with a separate run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Tool policies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "translations": { + "description": "Tool translations that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "webRequests": { + "description": "Requests that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "Responses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external properties.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalPropertyFileReference": { + "description": "Contains information that enables a SARIF consumer to locate the external property file that contains the value of an externalized property associated with the run.", + "type": "object", + "additionalProperties": false, + "properties": { + "location": { + "description": "The location of the external property file.", + "$ref": "#/definitions/artifactLocation" + }, + + "guid": { + "description": "A stable, unique identifer for the external property file in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "itemCount": { + "description": "A non-negative integer specifying the number of items contained in the external property file.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property file.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [{ "required": ["location"] }, { "required": ["guid"] }] + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "additionalProperties": false, + "type": "object", + "properties": { + "conversion": { + "description": "An external property file containing a run.conversion object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "graphs": { + "description": "An array of external property files containing a run.graphs object to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "externalizedProperties": { + "description": "An external property file containing a run.properties object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "artifacts": { + "description": "An array of external property files containing run.artifacts arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "invocations": { + "description": "An array of external property files containing run.invocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "logicalLocations": { + "description": "An array of external property files containing run.logicalLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "threadFlowLocations": { + "description": "An array of external property files containing run.threadFlowLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "results": { + "description": "An array of external property files containing run.results arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "taxonomies": { + "description": "An array of external property files containing run.taxonomies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "addresses": { + "description": "An array of external property files containing run.addresses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "driver": { + "description": "An external property file containing a run.driver object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "extensions": { + "description": "An array of external property files containing run.extensions arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "policies": { + "description": "An array of external property files containing run.policies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "translations": { + "description": "An array of external property files containing run.translations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webRequests": { + "description": "An array of external property files containing run.requests arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webResponses": { + "description": "An array of external property files containing run.responses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property files.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "fix": { + "description": "A proposed fix for the problem represented by a result object. A fix specifies a set of artifacts to modify. For each artifact, it specifies a set of bytes to remove, and provides a set of new bytes to replace them.", + "additionalProperties": false, + "type": "object", + "properties": { + "description": { + "description": "A message that describes the proposed fix, enabling viewers to present the proposed change to an end user.", + "$ref": "#/definitions/message" + }, + + "artifactChanges": { + "description": "One or more artifact changes that comprise a fix for a result.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactChange" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the fix.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["artifactChanges"] + }, + + "graph": { + "description": "A network of nodes and directed edges that describes some aspect of the structure of the code (for example, a call graph).", + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "description": "A description of the graph.", + "$ref": "#/definitions/message" + }, + + "nodes": { + "description": "An array of node objects representing the nodes of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "edges": { + "description": "An array of edge objects representing the edges of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/edge" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "graphTraversal": { + "description": "Represents a path through a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + "runGraphIndex": { + "description": "The index within the run.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "resultGraphIndex": { + "description": "The index within the result.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A description of this graph traversal.", + "$ref": "#/definitions/message" + }, + + "initialState": { + "description": "Values of relevant expressions at the start of the graph traversal that may change during graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the graph traversal that remain constant for the graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "edgeTraversals": { + "description": "The sequences of edges traversed by this graph traversal.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/edgeTraversal" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + "oneOf": [{ "required": ["runGraphIndex"] }, { "required": ["resultGraphIndex"] }] + }, + + "invocation": { + "description": "The runtime environment of the analysis tool run.", + "additionalProperties": false, + "type": "object", + "properties": { + "commandLine": { + "description": "The command line used to invoke the tool.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings, containing in order the command line arguments passed to the tool from the operating system.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + + "responseFiles": { + "description": "The locations of any response files specified on the tool's command line.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "startTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation started. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "endTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation ended. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "exitCode": { + "description": "The process exit code.", + "type": "integer" + }, + + "ruleConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe rules related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "notificationConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe notifications related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "toolExecutionNotifications": { + "description": "A list of runtime conditions detected by the tool during the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "toolConfigurationNotifications": { + "description": "A list of conditions detected by the tool that are relevant to the tool's configuration.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "exitCodeDescription": { + "description": "The reason for the process exit.", + "type": "string" + }, + + "exitSignalName": { + "description": "The name of the signal that caused the process to exit.", + "type": "string" + }, + + "exitSignalNumber": { + "description": "The numeric value of the signal that caused the process to exit.", + "type": "integer" + }, + + "processStartFailureMessage": { + "description": "The reason given by the operating system that the process failed to start.", + "type": "string" + }, + + "executionSuccessful": { + "description": "Specifies whether the tool's execution completed successfully.", + "type": "boolean" + }, + + "machine": { + "description": "The machine on which the invocation occurred.", + "type": "string" + }, + + "account": { + "description": "The account under which the invocation occurred.", + "type": "string" + }, + + "processId": { + "description": "The id of the process in which the invocation occurred.", + "type": "integer" + }, + + "executableLocation": { + "description": "An absolute URI specifying the location of the executable that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "workingDirectory": { + "description": "The working directory for the invocation.", + "$ref": "#/definitions/artifactLocation" + }, + + "environmentVariables": { + "description": "The environment variables associated with the analysis tool process, expressed as key/value pairs.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stdin": { + "description": "A file containing the standard input stream to the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdout": { + "description": "A file containing the standard output stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stderr": { + "description": "A file containing the standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdoutStderr": { + "description": "A file containing the interleaved standard output and standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the invocation.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["executionSuccessful"] + }, + + "location": { + "description": "A location within a programming artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "description": "Value that distinguishes this location from all other locations within a single result object.", + "type": "integer", + "minimum": -1, + "default": -1 + }, + + "physicalLocation": { + "description": "Identifies the artifact and region.", + "$ref": "#/definitions/physicalLocation" + }, + + "logicalLocations": { + "description": "The logical locations associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "message": { + "description": "A message relevant to the location.", + "$ref": "#/definitions/message" + }, + + "annotations": { + "description": "A set of regions relevant to the location.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "relationships": { + "description": "An array of objects that describe relationships between this location and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/locationRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "locationRelationship": { + "description": "Information about the relation of one location to another.", + "type": "object", + "additionalProperties": false, + "properties": { + "target": { + "description": "A reference to the related location.", + "type": "integer", + "minimum": 0 + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'includes', 'isIncludedBy' and 'relevant'.", + "type": "array", + "default": ["relevant"], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the location relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location relationship.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["target"] + }, + + "logicalLocation": { + "description": "A logical location of a construct that produced a result.", + "additionalProperties": false, + "type": "object", + "properties": { + "name": { + "description": "Identifies the construct in which the result occurred. For example, this property might contain the name of a class or a method.", + "type": "string" + }, + + "index": { + "description": "The index within the logical locations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "fullyQualifiedName": { + "description": "The human-readable fully qualified name of the logical location.", + "type": "string" + }, + + "decoratedName": { + "description": "The machine-readable name for the logical location, such as a mangled function name provided by a C++ compiler that encodes calling convention, return type and other details along with the function name.", + "type": "string" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the construct in which the result was detected. For example, this property might point to a logical location that represents the namespace that holds a type.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "kind": { + "description": "The type of construct this logical location component refers to. Should be one of 'function', 'member', 'module', 'namespace', 'parameter', 'resource', 'returnType', 'type', 'variable', 'object', 'array', 'property', 'value', 'element', 'text', 'attribute', 'comment', 'declaration', 'dtd' or 'processingInstruction', if any of those accurately describe the construct.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the logical location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "message": { + "description": "Encapsulates a message intended to be read by the end user.", + "type": "object", + "additionalProperties": false, + + "properties": { + "text": { + "description": "A plain text message string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string.", + "type": "string" + }, + + "id": { + "description": "The identifier for this message.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings to substitute into the message string.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [{ "required": ["text"] }, { "required": ["id"] }] + }, + + "multiformatMessageString": { + "description": "A message string or message format string rendered in multiple formats.", + "type": "object", + "additionalProperties": false, + + "properties": { + "text": { + "description": "A plain text message string or format string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string or format string.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["text"] + }, + + "node": { + "description": "Represents a node in a graph.", + "type": "object", + "additionalProperties": false, + + "properties": { + "id": { + "description": "A string that uniquely identifies the node within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the node.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "A code location associated with the node.", + "$ref": "#/definitions/location" + }, + + "children": { + "description": "Array of child nodes.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the node.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["id"] + }, + + "notification": { + "description": "Describes a condition relevant to the tool itself, as opposed to being relevant to a target being analyzed by the tool.", + "type": "object", + "additionalProperties": false, + "properties": { + "locations": { + "description": "The locations relevant to this notification.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "message": { + "description": "A message that describes the condition that was encountered.", + "$ref": "#/definitions/message" + }, + + "level": { + "description": "A value specifying the severity level of the notification.", + "default": "warning", + "enum": ["none", "note", "warning", "error"] + }, + + "threadId": { + "description": "The thread identifier of the code that generated the notification.", + "type": "integer" + }, + + "timeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the analysis tool generated the notification.", + "type": "string", + "format": "date-time" + }, + + "exception": { + "description": "The runtime exception, if any, relevant to this notification.", + "$ref": "#/definitions/exception" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor relevant to this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "associatedRule": { + "description": "A reference used to locate the rule descriptor associated with this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the notification.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["message"] + }, + + "physicalLocation": { + "description": "A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "address": { + "description": "The address of the location.", + "$ref": "#/definitions/address" + }, + + "artifactLocation": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "region": { + "description": "Specifies a portion of the artifact.", + "$ref": "#/definitions/region" + }, + + "contextRegion": { + "description": "Specifies a portion of the artifact that encloses the region. Allows a viewer to display additional context around the region.", + "$ref": "#/definitions/region" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the physical location.", + "$ref": "#/definitions/propertyBag" + } + }, + + "anyOf": [ + { + "required": ["address"] + }, + { + "required": ["artifactLocation"] + } + ] + }, + + "propertyBag": { + "description": "Key/value pairs that provide additional information about the object.", + "type": "object", + "additionalProperties": true, + "properties": { + "tags": { + "description": "A set of distinct strings that provide additional information.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + } + } + }, + + "rectangle": { + "description": "An area within an image.", + "additionalProperties": false, + "type": "object", + "properties": { + "top": { + "description": "The Y coordinate of the top edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "left": { + "description": "The X coordinate of the left edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "bottom": { + "description": "The Y coordinate of the bottom edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "right": { + "description": "The X coordinate of the right edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "message": { + "description": "A message relevant to the rectangle.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the rectangle.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "region": { + "description": "A region within an artifact where a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + "startLine": { + "description": "The line number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "startColumn": { + "description": "The column number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endLine": { + "description": "The line number of the last character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endColumn": { + "description": "The column number of the character following the end of the region.", + "type": "integer", + "minimum": 1 + }, + + "charOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first character in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "charLength": { + "description": "The length of the region in characters.", + "type": "integer", + "minimum": 0 + }, + + "byteOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first byte in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "byteLength": { + "description": "The length of the region in bytes.", + "type": "integer", + "minimum": 0 + }, + + "snippet": { + "description": "The portion of the artifact contents within the specified region.", + "$ref": "#/definitions/artifactContent" + }, + + "message": { + "description": "A message relevant to the region.", + "$ref": "#/definitions/message" + }, + + "sourceLanguage": { + "description": "Specifies the source language, if any, of the portion of the artifact specified by the region object.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the region.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "replacement": { + "description": "The replacement of a single region of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + "deletedRegion": { + "description": "The region of the artifact to delete.", + "$ref": "#/definitions/region" + }, + + "insertedContent": { + "description": "The content to insert at the location specified by the 'deletedRegion' property.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the replacement.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["deletedRegion"] + }, + + "reportingDescriptor": { + "description": "Metadata that describes a specific report produced by the tool, as part of the analysis it provides or its runtime reporting.", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "description": "A stable, opaque identifier for the report.", + "type": "string" + }, + + "deprecatedIds": { + "description": "An array of stable, opaque identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "guid": { + "description": "A unique identifer for the reporting descriptor in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "deprecatedGuids": { + "description": "An array of unique identifies in the form of a GUID by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + } + }, + + "name": { + "description": "A report identifier that is understandable to an end user.", + "type": "string" + }, + + "deprecatedNames": { + "description": "An array of readable identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "shortDescription": { + "description": "A concise description of the report. Should be a single sentence that is understandable when visible space is limited to a single line of text.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A description of the report. Should, as far as possible, provide details sufficient to enable resolution of any problem indicated by the result.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "messageStrings": { + "description": "A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "defaultConfiguration": { + "description": "Default reporting configuration information.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "helpUri": { + "description": "A URI where the primary documentation for the report can be found.", + "type": "string", + "format": "uri" + }, + + "help": { + "description": "Provides the primary documentation for the report, useful when there is no online documentation.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "relationships": { + "description": "An array of objects that describe relationships between this reporting descriptor and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the report.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["id"] + }, + + "reportingConfiguration": { + "description": "Information about a rule or notification that can be configured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Specifies whether the report may be produced during the scan.", + "type": "boolean", + "default": true + }, + + "level": { + "description": "Specifies the failure level for the report.", + "default": "warning", + "enum": ["none", "note", "warning", "error"] + }, + + "rank": { + "description": "Specifies the relative priority of the report. Used for analysis output only.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "parameters": { + "description": "Contains configuration information specific to a report.", + "$ref": "#/definitions/propertyBag" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting configuration.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "reportingDescriptorReference": { + "description": "Information about how to locate a relevant reporting descriptor.", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "description": "The id of the descriptor.", + "type": "string" + }, + + "index": { + "description": "The index into an array of descriptors in toolComponent.ruleDescriptors, toolComponent.notificationDescriptors, or toolComponent.taxonomyDescriptors, depending on context.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "A guid that uniquely identifies the descriptor.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "toolComponent": { + "description": "A reference used to locate the toolComponent associated with the descriptor.", + "$ref": "#/definitions/toolComponentReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [{ "required": ["index"] }, { "required": ["guid"] }, { "required": ["id"] }] + }, + + "reportingDescriptorRelationship": { + "description": "Information about the relation of one reporting descriptor to another.", + "type": "object", + "additionalProperties": false, + "properties": { + "target": { + "description": "A reference to the related reporting descriptor.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'canPrecede', 'canFollow', 'willPrecede', 'willFollow', 'superset', 'subset', 'equal', 'disjoint', 'relevant', and 'incomparable'.", + "type": "array", + "default": ["relevant"], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the reporting descriptor relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["target"] + }, + + "result": { + "description": "A result produced by an analysis tool.", + "additionalProperties": false, + "type": "object", + "properties": { + "ruleId": { + "description": "The stable, unique identifier of the rule, if any, to which this result is relevant.", + "type": "string" + }, + + "ruleIndex": { + "description": "The index within the tool component rules array of the rule object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "rule": { + "description": "A reference used to locate the rule descriptor relevant to this result.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kind": { + "description": "A value that categorizes results by evaluation state.", + "default": "fail", + "enum": ["notApplicable", "pass", "fail", "review", "open", "informational"] + }, + + "level": { + "description": "A value specifying the severity level of the result.", + "default": "warning", + "enum": ["none", "note", "warning", "error"] + }, + + "message": { + "description": "A message that describes the result. The first sentence of the message only will be displayed when visible space is limited.", + "$ref": "#/definitions/message" + }, + + "analysisTarget": { + "description": "Identifies the artifact that the analysis tool was instructed to scan. This need not be the same as the artifact where the result actually occurred.", + "$ref": "#/definitions/artifactLocation" + }, + + "locations": { + "description": "The set of locations where the result was detected. Specify only one location unless the problem indicated by the result can only be corrected by making a change at every specified location.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "guid": { + "description": "A stable, unique identifer for the result in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of logically identical results to which this result belongs, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "occurrenceCount": { + "description": "A positive integer specifying the number of times this logically unique result was observed in this run.", + "type": "integer", + "minimum": 1 + }, + + "partialFingerprints": { + "description": "A set of strings that contribute to the stable, unique identity of the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "fingerprints": { + "description": "A set of strings each of which individually defines a stable, unique identity for the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stacks": { + "description": "An array of 'stack' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/stack" + } + }, + + "codeFlows": { + "description": "An array of 'codeFlow' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/codeFlow" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "graphTraversals": { + "description": "An array of one or more unique 'graphTraversal' objects.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graphTraversal" + } + }, + + "relatedLocations": { + "description": "A set of locations relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "suppressions": { + "description": "A set of suppressions relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/suppression" + } + }, + + "baselineState": { + "description": "The state of a result relative to a baseline of a previous run.", + "enum": ["new", "unchanged", "updated", "absent"] + }, + + "rank": { + "description": "A number representing the priority or importance of the result.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "attachments": { + "description": "A set of artifacts relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/attachment" + } + }, + + "hostedViewerUri": { + "description": "An absolute URI at which the result can be viewed.", + "type": "string", + "format": "uri" + }, + + "workItemUris": { + "description": "The URIs of the work items associated with this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uri" + } + }, + + "provenance": { + "description": "Information about how and when the result was detected.", + "$ref": "#/definitions/resultProvenance" + }, + + "fixes": { + "description": "An array of 'fix' objects, each of which represents a proposed fix to the problem indicated by the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/fix" + } + }, + + "taxa": { + "description": "An array of references to taxonomy reporting descriptors that are applicable to the result.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "webRequest": { + "description": "A web request associated with this result.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this result.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["message"] + }, + + "resultProvenance": { + "description": "Contains information about how and when a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + "firstDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was first detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "lastDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was most recently detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "firstDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was first detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "lastDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was most recently detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "invocationIndex": { + "description": "The index within the run.invocations array of the invocation object which describes the tool invocation that detected the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "conversionSources": { + "description": "An array of physicalLocation objects which specify the portions of an analysis tool's output that a converter transformed into the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/physicalLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "run": { + "description": "Describes a single run of an analysis tool, and contains the reported output of that run.", + "additionalProperties": false, + "type": "object", + "properties": { + "tool": { + "description": "Information about the tool or tool pipeline that generated the results in this run. A run can only contain results produced by a single tool or tool pipeline. A run can aggregate results from multiple log files, as long as context around the tool run (tool command-line arguments and the like) is identical for all aggregated files.", + "$ref": "#/definitions/tool" + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "conversion": { + "description": "A conversion object that describes how a converter transformed an analysis tool's native reporting format into the SARIF format.", + "$ref": "#/definitions/conversion" + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase culture code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}|^[a-zA-Z]{2}-[a-zA-Z]{2}]?$" + }, + + "versionControlProvenance": { + "description": "Specifies the revision in version control of the artifacts that were scanned.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/versionControlDetails" + } + }, + + "originalUriBaseIds": { + "description": "The artifact location specified by each uriBaseId symbol on the machine where the tool originally ran.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "artifacts": { + "description": "An array of artifact objects relevant to the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "results": { + "description": "The set of results contained in an SARIF log. The results array can be omitted when a run is solely exporting rules metadata. It must be present (but may be empty) if a log file represents an actual scan.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/result" + } + }, + + "automationDetails": { + "description": "Automation details that describe this run.", + "$ref": "#/definitions/runAutomationDetails" + }, + + "runAggregates": { + "description": "Automation details that describe the aggregate of runs to which this run belongs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/runAutomationDetails" + } + }, + + "baselineGuid": { + "description": "The 'guid' property of a previous SARIF 'run' that comprises the baseline that was used to compute result 'baselineState' properties for the run.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "redactionTokens": { + "description": "An array of strings used to replace sensitive information in a redaction-aware property.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "defaultEncoding": { + "description": "Specifies the default encoding for any artifact object that refers to a text file.", + "type": "string" + }, + + "defaultSourceLanguage": { + "description": "Specifies the default source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "newlineSequences": { + "description": "An ordered list of character sequences that were treated as line breaks when computing region information for the run.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "default": ["\r\n", "\n"], + "items": { + "type": "string" + } + }, + + "columnKind": { + "description": "Specifies the unit in which the tool measures columns.", + "enum": ["utf16CodeUnits", "unicodeCodePoints"] + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "$ref": "#/definitions/externalPropertyFileReferences" + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "taxonomies": { + "description": "An array of toolComponent objects relevant to a taxonomy in which results are categorized.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses associated with this run instance, if any.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "translations": { + "description": "The set of available translations of the localized data provided by the tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Contains configurations that may potentially override both reportingDescriptor.defaultConfiguration (the tool's default severities) and invocation.configurationOverrides (severities established at run-time from the command line).", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "webRequests": { + "description": "An array of request objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "An array of response objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "specialLocations": { + "description": "A specialLocations object that defines locations of special significance to SARIF consumers.", + "$ref": "#/definitions/specialLocations" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["tool"] + }, + + "runAutomationDetails": { + "description": "Information that describes a run's identity and role within an engineering system process.", + "additionalProperties": false, + "type": "object", + "properties": { + "description": { + "description": "A description of the identity and role played within the engineering system by this object's containing run object.", + "$ref": "#/definitions/message" + }, + + "id": { + "description": "A hierarchical string that uniquely identifies this object's containing run object.", + "type": "string" + }, + + "guid": { + "description": "A stable, unique identifer for this object's containing run object in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of runs to which this object's containing run object belongs in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run automation details.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "specialLocations": { + "description": "Defines locations of special significance to SARIF consumers.", + "type": "object", + "additionalProperties": false, + "properties": { + "displayBase": { + "description": "Provides a suggestion to SARIF consumers to display file paths relative to the specified location.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the special locations.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "stack": { + "description": "A call stack that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + "message": { + "description": "A message relevant to this call stack.", + "$ref": "#/definitions/message" + }, + + "frames": { + "description": "An array of stack frames that represents a sequence of calls, rendered in reverse chronological order, that comprise the call stack.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/stackFrame" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["frames"] + }, + + "stackFrame": { + "description": "A function call within a stack trace.", + "additionalProperties": false, + "type": "object", + "properties": { + "location": { + "description": "The location to which this stack frame refers.", + "$ref": "#/definitions/location" + }, + + "module": { + "description": "The name of the module that contains the code of this stack frame.", + "type": "string" + }, + + "threadId": { + "description": "The thread identifier of the stack frame.", + "type": "integer" + }, + + "parameters": { + "description": "The parameters of the call that is executing.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string", + "default": [] + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack frame.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "suppression": { + "description": "A suppression that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + "guid": { + "description": "A stable, unique identifer for the suprression in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "kind": { + "description": "A string that indicates where the suppression is persisted.", + "enum": ["inSource", "external"] + }, + + "status": { + "description": "A string that indicates the review status of the suppression.", + "enum": ["accepted", "underReview", "rejected"] + }, + + "justification": { + "description": "A string representing the justification for the suppression.", + "type": "string" + }, + + "location": { + "description": "Identifies the location associated with the suppression.", + "$ref": "#/definitions/location" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the suppression.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["kind"] + }, + + "threadFlow": { + "description": "Describes a sequence of code locations that specify a path through a single thread of execution such as an operating system or fiber.", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "description": "An string that uniquely identifies the threadFlow within the codeFlow in which it occurs.", + "type": "string" + }, + + "message": { + "description": "A message relevant to the thread flow.", + "$ref": "#/definitions/message" + }, + + "initialState": { + "description": "Values of relevant expressions at the start of the thread flow that may change during thread flow execution.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the thread flow that remain constant.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "locations": { + "description": "A temporally ordered array of 'threadFlowLocation' objects, each of which describes a location visited by the tool while producing the result.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the thread flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["locations"] + }, + + "threadFlowLocation": { + "description": "A location visited by an analysis tool while simulating or monitoring the execution of a program.", + "additionalProperties": false, + "type": "object", + "properties": { + "index": { + "description": "The index within the run threadFlowLocations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "location": { + "description": "The code location.", + "$ref": "#/definitions/location" + }, + + "stack": { + "description": "The call stack leading to this location.", + "$ref": "#/definitions/stack" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the thread flow location. Well-known kinds include 'acquire', 'release', 'enter', 'exit', 'call', 'return', 'branch', 'implicit', 'false', 'true', 'caution', 'danger', 'unknown', 'unreachable', 'taint', 'function', 'handler', 'lock', 'memory', 'resource', 'scope' and 'value'.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "taxa": { + "description": "An array of references to rule or taxonomy reporting descriptors that are applicable to the thread flow location.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "module": { + "description": "The name of the module that contains the code that is executing.", + "type": "string" + }, + + "state": { + "description": "A dictionary, each of whose keys specifies a variable or expression, the associated value of which represents the variable or expression value. For an annotation of kind 'continuation', for example, this dictionary might hold the current assumed values of a set of global variables.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "nestingLevel": { + "description": "An integer representing a containment hierarchy within the thread flow.", + "type": "integer", + "minimum": 0 + }, + + "executionOrder": { + "description": "An integer representing the temporal order in which execution reached this location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "executionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which this location was executed.", + "type": "string", + "format": "date-time" + }, + + "importance": { + "description": "Specifies the importance of this location in understanding the code flow in which it occurs. The order from most to least important is \"essential\", \"important\", \"unimportant\". Default: \"important\".", + "enum": ["important", "essential", "unimportant"], + "default": "important" + }, + + "webRequest": { + "description": "A web request associated with this thread flow location.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this thread flow location.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the threadflow location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "tool": { + "description": "The analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + "driver": { + "description": "The analysis tool that was run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that contributed to or reconfigured the analysis tool that was run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["driver"] + }, + + "toolComponent": { + "description": "A component, such as a plug-in or the driver, of the analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + "guid": { + "description": "A unique identifer for the tool component in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "name": { + "description": "The name of the tool component.", + "type": "string" + }, + + "organization": { + "description": "The organization or company that produced the tool component.", + "type": "string" + }, + + "product": { + "description": "A product suite to which the tool component belongs.", + "type": "string" + }, + + "productSuite": { + "description": "A localizable string containing the name of the suite of products to which the tool component belongs.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullName": { + "description": "The name of the tool component along with its version and any other useful identifying information, such as its locale.", + "type": "string" + }, + + "version": { + "description": "The tool component version, in whatever format the component natively provides.", + "type": "string" + }, + + "semanticVersion": { + "description": "The tool component version in the format specified by Semantic Versioning 2.0.", + "type": "string" + }, + + "dottedQuadFileVersion": { + "description": "The binary version of the tool component's primary executable file expressed as four non-negative integers separated by a period (for operating systems that express file versions in this way).", + "type": "string", + "pattern": "[0-9]+(\\.[0-9]+){3}" + }, + + "releaseDateUtc": { + "description": "A string specifying the UTC date (and optionally, the time) of the component's release.", + "type": "string" + }, + + "downloadUri": { + "description": "The absolute URI from which the tool component can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI at which information about this version of the tool component can be found.", + "type": "string", + "format": "uri" + }, + + "globalMessageStrings": { + "description": "A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "notifications": { + "description": "An array of reportingDescriptor objects relevant to the notifications related to the configuration and runtime execution of the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "rules": { + "description": "An array of reportingDescriptor objects relevant to the analysis performed by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "taxa": { + "description": "An array of reportingDescriptor objects relevant to the definitions of both standalone and tool-defined taxonomies.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "locations": { + "description": "An array of the artifactLocation objects associated with the tool component.", + "type": "array", + "minItems": 0, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase language code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}|^[a-zA-Z]{2}-[a-zA-Z]{2}]?$" + }, + + "contents": { + "description": "The kinds of data contained in this object.", + "type": "array", + "uniqueItems": true, + "default": ["localizedData", "nonLocalizedData"], + "items": { + "enum": ["localizedData", "nonLocalizedData"] + } + }, + + "isComprehensive": { + "description": "Specifies whether this object contains a complete definition of the localizable and/or non-localizable data for this component, as opposed to including only data that is relevant to the results persisted to this log file.", + "type": "boolean", + "default": false + }, + + "localizedDataSemanticVersion": { + "description": "The semantic version of the localized strings defined in this component; maintained by components that provide translations.", + "type": "string" + }, + + "minimumRequiredLocalizedDataSemanticVersion": { + "description": "The minimum value of localizedDataSemanticVersion required in translations consumed by this component; used by components that consume translations.", + "type": "string" + }, + + "associatedComponent": { + "description": "The component which is strongly associated with this component. For a translation, this refers to the component which has been translated. For an extension, this is the driver that provides the extension's plugin model.", + "$ref": "#/definitions/toolComponentReference" + }, + + "translationMetadata": { + "description": "Translation metadata, required for a translation, not populated by other component types.", + "$ref": "#/definitions/translationMetadata" + }, + + "supportedTaxonomies": { + "description": "An array of toolComponentReference objects to declare the taxonomies supported by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponentReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool component.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["name"] + }, + + "toolComponentReference": { + "description": "Identifies a particular toolComponent object, either the driver or an extension.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "The 'name' property of the referenced toolComponent.", + "type": "string" + }, + + "index": { + "description": "An index into the referenced toolComponent in tool.extensions.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "The 'guid' property of the referenced toolComponent.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the toolComponentReference.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "translationMetadata": { + "description": "Provides additional metadata related to translation.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "The name associated with the translation metadata.", + "type": "string" + }, + + "fullName": { + "description": "The full name associated with the translation metadata.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "downloadUri": { + "description": "The absolute URI from which the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI from which information related to the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the translation metadata.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": ["name"] + }, + + "versionControlDetails": { + "description": "Specifies the information necessary to retrieve a desired revision from a version control system.", + "type": "object", + "additionalProperties": false, + "properties": { + "repositoryUri": { + "description": "The absolute URI of the repository.", + "type": "string", + "format": "uri" + }, + + "revisionId": { + "description": "A string that uniquely and permanently identifies the revision within the repository.", + "type": "string" + }, + + "branch": { + "description": "The name of a branch containing the revision.", + "type": "string" + }, + + "revisionTag": { + "description": "A tag that has been applied to the revision.", + "type": "string" + }, + + "asOfTimeUtc": { + "description": "A Coordinated Universal Time (UTC) date and time that can be used to synchronize an enlistment to the state of the repository at that time.", + "type": "string", + "format": "date-time" + }, + + "mappedTo": { + "description": "The location in the local file system to which the root of the repository was mapped at the time of the analysis.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the version control details.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": ["repositoryUri"] + }, + + "webRequest": { + "description": "Describes an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "The index within the run.webRequests array of the request object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "protocol": { + "description": "The request protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The request version. Example: '1.1'.", + "type": "string" + }, + + "target": { + "description": "The target of the request.", + "type": "string" + }, + + "method": { + "description": "The HTTP method. Well-known values are 'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'.", + "type": "string" + }, + + "headers": { + "description": "The request headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "parameters": { + "description": "The request parameters.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the request.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the request.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "webResponse": { + "description": "Describes the response to an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "The index within the run.webResponses array of the response object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "protocol": { + "description": "The response protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The response version. Example: '1.1'.", + "type": "string" + }, + + "statusCode": { + "description": "The response status code. Example: 451.", + "type": "integer" + }, + + "reasonPhrase": { + "description": "The response reason. Example: 'Not found'.", + "type": "string" + }, + + "headers": { + "description": "The response headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the response.", + "$ref": "#/definitions/artifactContent" + }, + + "noResponseReceived": { + "description": "Specifies whether a response was received from the server.", + "type": "boolean", + "default": false + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the response.", + "$ref": "#/definitions/propertyBag" + } + } + } + } +} diff --git a/packages/cli-contract/tsconfig.build.json b/packages/cli-contract/tsconfig.build.json new file mode 100644 index 0000000..5483128 --- /dev/null +++ b/packages/cli-contract/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/__fixtures__/**", + "node_modules", + "dist" + ], + "references": [{ "path": "../core/tsconfig.build.json" }] +} diff --git a/packages/cli-contract/tsconfig.json b/packages/cli-contract/tsconfig.json new file mode 100644 index 0000000..1e9f126 --- /dev/null +++ b/packages/cli-contract/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli-contract/vitest.config.ts b/packages/cli-contract/vitest.config.ts new file mode 100644 index 0000000..104510b --- /dev/null +++ b/packages/cli-contract/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + globals: false, + passWithNoTests: true, + }, +}); diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100755 index 0000000..17f284b --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { run } from '../dist/cli.js'; +import process from 'node:process'; + +process.exit(run(process.argv.slice(2))); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..00d7385 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,52 @@ +{ + "name": "@earsyntax/cli", + "version": "0.0.1-alpha.0", + "type": "module", + "license": "Apache-2.0", + "author": "Suites", + "description": "The earsyntax CLI facade for validating and authoring EARS requirements.", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "earsyntax": "./bin/run.js" + }, + "files": [ + "dist", + "bin", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "predev": "pnpm rimraf dist tsconfig.build.tsbuildinfo", + "prepack": "pnpm rimraf dist tsconfig.build.tsbuildinfo && pnpm build", + "build": "pnpm tsc -p tsconfig.build.json", + "dev": "pnpm tsc -p tsconfig.build.json --watch --incremental", + "lint": "pnpm eslint \"src/**/*.ts\"", + "lint:fix": "pnpm eslint \"src/**/*.ts\" --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@earsyntax/cli-contract": "workspace:*", + "@earsyntax/core": "workspace:*", + "@earsyntax/extract": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts new file mode 100644 index 0000000..187bd7c --- /dev/null +++ b/packages/cli/src/args.test.ts @@ -0,0 +1,62 @@ +/** + * Tests for {@link parseArgs}: positional/flag separation, the value-flag + * `--flag value` and `--flag=value` forms, `--` as an end-of-flags marker, and + * the boolean-flag `--flag=value` rejection. + */ + +import { describe, expect, it } from 'vitest'; +import { parseArgs } from './args.js'; +import { CliError } from './errors.js'; + +describe('parseArgs', () => { + it('separates positionals from flags', () => { + const result = parseArgs(['a.md', '--json', 'b.md']); + expect(result.positionals).toEqual(['a.md', 'b.md']); + expect(result.booleans.has('json')).toBe(true); + }); + + it('reads a value flag in `--flag value` form', () => { + const result = parseArgs(['--profile', 'kiro']); + expect(result.values.get('profile')).toBe('kiro'); + }); + + it('reads a value flag in `--flag=value` form', () => { + const result = parseArgs(['--profile=kiro']); + expect(result.values.get('profile')).toBe('kiro'); + }); + + it('treats everything after `--` as positional', () => { + const result = parseArgs(['--json', '--', '--not-a-flag']); + expect(result.positionals).toEqual(['--not-a-flag']); + }); + + it('throws cli.missing_value when a value flag has no following token', () => { + expect(() => parseArgs(['--profile'])).toThrow(CliError); + try { + parseArgs(['--profile']); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).diagnostic.code).toBe('cli.missing_value'); + expect((error as CliError).exitCode).toBe(2); + } + }); + + it('rejects `--flag=value` on a boolean flag', () => { + try { + parseArgs(['--json=true']); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).diagnostic.code).toBe('cli.flag_takes_no_value'); + expect((error as CliError).exitCode).toBe(2); + } + }); + + it('keeps bare boolean tokens working', () => { + const result = parseArgs(['--json', '--quiet', '--strict']); + expect(result.booleans.has('json')).toBe(true); + expect(result.booleans.has('quiet')).toBe(true); + expect(result.booleans.has('strict')).toBe(true); + }); +}); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..891dc1f --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,105 @@ +/** + * A lean, hand-rolled argument parser. + * + * The facade JSON contract is exact and the command set is closed, so a tiny + * parser keeps the output shape under direct control rather than fitting a + * framework's conventions. It understands `--flag`, `--flag value`, + * `--flag=value`, and `--` (end of flags), and separates positionals from + * options. + * + * Value-taking flags are declared up front so `--flag value` consumes the next + * token only for those; every other `--flag` is a boolean. A repeated value + * flag takes the last value; a repeated boolean stays set. + */ + +import { usageError } from './errors.js'; + +/** Flags that take a value (`--flag value` or `--flag=value`), across all commands. */ +const VALUE_FLAGS = new Set(['cwd', 'profile', 'file', 'from', 'agent', 'host', 'tools']); + +/** The parsed result: positionals plus a flat flag map. */ +export interface ParsedArgs { + positionals: string[]; + booleans: Set; + values: Map; +} + +/** Parse an argv slice (without the command name) into {@link ParsedArgs}. */ +export function parseArgs(argv: string[]): ParsedArgs { + const positionals: string[] = []; + const booleans = new Set(); + const values = new Map(); + + for (let i = 0; i < argv.length; i++) { + const token = argv.at(i); + if (token === undefined) { + continue; + } + if (token === '--') { + // Everything after `--` is a positional. + positionals.push(...argv.slice(i + 1)); + break; + } + if (!token.startsWith('--')) { + positionals.push(token); + continue; + } + + const body = token.slice(2); + if (body === '') { + continue; + } + + // `--flag=value` form. Last assignment wins for a repeated flag. + const eq = body.indexOf('='); + if (eq !== -1) { + const name = body.slice(0, eq); + if (!VALUE_FLAGS.has(name)) { + throw usageError( + 'cli.flag_takes_no_value', + `Option --${name} does not take a value; use --${name} on its own.`, + ); + } + values.set(name, body.slice(eq + 1)); + continue; + } + + // `--flag value` for known value flags; otherwise a boolean. + if (VALUE_FLAGS.has(body)) { + const next = argv.at(i + 1); + if (next === undefined || (next.startsWith('--') && next !== '--')) { + throw usageError('cli.missing_value', `Option --${body} requires a value.`); + } + values.set(body, next); + i += 1; + continue; + } + + booleans.add(body); + } + + return { positionals, booleans, values }; +} + +/** Resolved global options shared by every command. */ +export interface GlobalOptions { + json: boolean; + sarif: boolean; + strict: boolean; + quiet: boolean; + /** The active profile name; defaults to `strict`. Commands validate it against the registry. */ + profile: string; + cwd?: string; +} + +/** Extract global options from parsed args. */ +export function resolveGlobals(args: ParsedArgs): GlobalOptions { + return { + json: args.booleans.has('json'), + sarif: args.booleans.has('sarif'), + strict: args.booleans.has('strict'), + quiet: args.booleans.has('quiet'), + profile: args.values.get('profile') ?? 'strict', + cwd: args.values.get('cwd'), + }; +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000..4a07ef8 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,214 @@ +/** + * Dispatcher-level tests for the host-native facade shell. + * + * These exercise the command surface `run()` owns: routing across the eight + * commands, flag validation against the closed surface, help and version text, + * and the response envelope. Command bodies are tested in their own suites; + * here we only prove the shell routes to them and enforces their flags. + */ + +import { describe, expect, it } from 'vitest'; +import { parseArgs } from './args.js'; +import { run } from './cli.js'; + +interface RunResult { + code: number; + out: string; + json: () => Record; +} + +function runCli(...argv: string[]): RunResult { + let out = ''; + const code = run(argv, { cwd: process.cwd(), stdout: (s) => (out += s) }); + return { code, out, json: () => JSON.parse(out) as Record }; +} + +const REMOVED_VERBS = ['new', 'list', 'status', 'show', 'accept', 'check']; +const FACADE_COMMANDS = [ + 'validate', + 'extract', + 'instructions', + 'explain', + 'profiles', + 'doctor', + 'init', + 'version', +]; + +describe('help', () => { + it('lists exactly the eight facade commands and none of the removed verbs', () => { + const res = runCli('--help'); + expect(res.code).toBe(0); + for (const command of FACADE_COMMANDS) { + expect(res.out).toContain(command); + } + for (const verb of REMOVED_VERBS) { + // Word-boundary check so "extract" does not count as containing "act", etc. + expect(new RegExp(`\\b${verb}\\b`).test(res.out)).toBe(false); + } + }); + + it('exits 2 on a bare invocation and prints usage', () => { + const res = runCli(); + expect(res.code).toBe(2); + expect(res.out).toContain('earsyntax '); + }); + + it('does not advertise --work, --source, --out, or --mode', () => { + const res = runCli('--help'); + for (const flag of ['--work', '--source', '--out', '--mode', '--config']) { + expect(res.out).not.toContain(flag); + } + }); +}); + +describe('version', () => { + it('reports the feature map as JSON with the closed surface', () => { + const res = runCli('version', '--features', '--json'); + expect(res.code).toBe(0); + const body = res.json(); + expect(body.version).toBeTypeOf('string'); + expect(body.command).toBe('version'); + expect(body.ok).toBe(true); + expect(body.next).toEqual([]); + expect('root' in body).toBe(false); + + const features = body.features as Record; + expect(features.facade).toBe(1); + expect(features.commands).toEqual(FACADE_COMMANDS); + expect(features.profiles).toEqual(['strict', 'ears-x', 'kiro', 'speckit', 'openspec']); + expect(features.instructions).toEqual(['author', 'convert', 'repair', 'review']); + expect(features.hosts).toEqual(['kiro', 'speckit', 'openspec']); + expect(features.agents).toEqual(['claude', 'codex', 'cursor', 'copilot', 'gemini', 'generic']); + expect(features.inputFormats).toEqual(['ears', 'text', 'markdown', 'yaml', 'json']); + expect(features.outputFormats).toEqual(['pretty', 'json', 'sarif']); + expect(features.sarif).toBe(true); + expect('workItems' in features).toBe(false); + }); + + it('does not report the removed work-item verbs in its command list', () => { + const features = runCli('version', '--features', '--json').json().features as { + commands: string[]; + }; + for (const verb of REMOVED_VERBS) { + expect(features.commands).not.toContain(verb); + } + }); + + it('maps --version and -v to the version command', () => { + for (const flag of ['--version', '-v']) { + const res = runCli(flag); + expect(res.code).toBe(0); + expect(res.out).toContain('earsyntax '); + } + }); + + it('emits pure JSON to stdout in --json mode', () => { + const res = runCli('version', '--json'); + expect(() => JSON.parse(res.out)).not.toThrow(); + }); +}); + +describe('envelope shape', () => { + it('orders base keys version, command, ok, then next last', () => { + const res = runCli('version', '--json'); + const keys = Object.keys(res.json()); + expect(keys[0]).toBe('version'); + expect(keys[1]).toBe('command'); + expect(keys[2]).toBe('ok'); + expect(keys.at(-1)).toBe('next'); + }); +}); + +describe('routing', () => { + it('routes all eight facade commands (never reports an unknown command)', () => { + // A bogus flag proves the command was recognized and reached flag checking, + // without executing an unfinished command body. + for (const command of FACADE_COMMANDS) { + const res = runCli(command, '--totally-unknown', '--json'); + const code = (res.json().diagnostics as { code: string }[])[0]?.code; + expect(code).not.toBe('cli.unknown_command'); + } + }); + + it('rejects an unknown command with exit 2', () => { + const res = runCli('frobnicate', '--json'); + expect(res.code).toBe(2); + const diags = res.json().diagnostics as { code: string }[]; + expect(diags[0]?.code).toBe('cli.unknown_command'); + expect(res.json().ok).toBe(false); + }); + + it('rejects each removed verb as an unknown command', () => { + for (const verb of REMOVED_VERBS) { + const res = runCli(verb, '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.unknown_command'); + } + }); +}); + +describe('flag validation', () => { + it('rejects an unknown flag with exit 2', () => { + const res = runCli('version', '--bogus', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.unknown_flag'); + }); + + it('rejects a known flag used on the wrong command with exit 2', () => { + const res = runCli('doctor', '--strict', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.flag_not_allowed'); + }); + + it('rejects --sarif on any command other than validate', () => { + for (const command of ['extract', 'doctor', 'init', 'explain', 'profiles', 'instructions']) { + const res = runCli(command, '--sarif', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.flag_not_allowed'); + } + }); + + it('accepts --sarif on validate (reaches the handler, not a flag error)', () => { + const res = runCli('validate', '--sarif', '--totally-unknown', '--json'); + // Routed past flag-allow for --sarif; the unknown flag is what trips it. + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.unknown_flag'); + }); + + it('rejects --json and --sarif together as mutually exclusive', () => { + const res = runCli('validate', '--json', '--sarif'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.exclusive_flags'); + }); + + it('rejects a value flag with no value with exit 2', () => { + const res = runCli('version', '--cwd', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.missing_value'); + }); +}); + +describe('parseArgs', () => { + it('takes the last value for a repeated value flag', () => { + const parsed = parseArgs(['--profile', 'kiro', '--profile', 'speckit']); + expect(parsed.values.get('profile')).toBe('speckit'); + }); + + it('treats everything after -- as positional', () => { + const parsed = parseArgs(['validate', '--', '--profile', '-x']); + expect(parsed.positionals).toEqual(['validate', '--profile', '-x']); + expect(parsed.values.has('profile')).toBe(false); + }); + + it('supports --flag=value form', () => { + const parsed = parseArgs(['--cwd=/tmp/x']); + expect(parsed.values.get('cwd')).toBe('/tmp/x'); + }); + + it('separates positionals from boolean flags', () => { + const parsed = parseArgs(['a.md', 'b.md', '--json', '--strict']); + expect(parsed.positionals).toEqual(['a.md', 'b.md']); + expect(parsed.booleans.has('json')).toBe(true); + expect(parsed.booleans.has('strict')).toBe(true); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..1b7d0a6 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,192 @@ +/** + * The `earsyntax` command dispatcher. + * + * A lean hand-rolled entry point: it validates flags against the closed command + * surface, resolves global options and the working directory, routes to one + * command handler, and performs the single write to stdout. Handlers return a + * {@link CommandResult}; the dispatcher decides between JSON, pretty, and raw + * SARIF output so `--json` stdout stays pure JSON. + * + * The surface is the eight facade commands and nothing else. There is no + * `new`/`list`/`status`/`show`/`accept`/`check`; the workspace is gone. + */ + +import process from 'node:process'; +import { parseArgs, resolveGlobals, type ParsedArgs } from './args.js'; +import { createPainter } from './color.js'; +import type { CommandContext, CommandHandler } from './context.js'; +import { CliError, usageError } from './errors.js'; +import { resolveInput } from './paths.js'; +import { emitResult, errorResponse } from './response.js'; +import { doctorCommand } from './commands/doctor.js'; +import { explainCommand } from './commands/explain.js'; +import { extractCommand } from './commands/extract.js'; +import { initCommand } from './commands/init.js'; +import { instructionsCommand } from './commands/instructions.js'; +import { profilesCommand } from './commands/profiles.js'; +import { validateCommand } from './commands/validate.js'; +import { versionCommand } from './commands/version.js'; + +/** Injection points for tests and the bin wrapper. */ +export interface RunOptions { + cwd?: string; + stdout?: (text: string) => void; +} + +/** A routed command: its handler and the flags it accepts beyond the universal set. */ +interface CommandSpec { + handler: CommandHandler; + flags: readonly string[]; +} + +/** Flags valid on every command. */ +const UNIVERSAL_FLAGS = ['json', 'quiet', 'cwd'] as const; + +const COMMANDS: Record = { + validate: { handler: validateCommand, flags: ['profile', 'strict', 'sarif'] }, + extract: { handler: extractCommand, flags: ['profile'] }, + instructions: { handler: instructionsCommand, flags: ['profile', 'strict', 'file', 'from'] }, + explain: { handler: explainCommand, flags: [] }, + profiles: { handler: profilesCommand, flags: [] }, + doctor: { handler: doctorCommand, flags: [] }, + init: { handler: initCommand, flags: ['agent', 'host', 'tools'] }, + version: { handler: versionCommand, flags: ['features'] }, +}; + +/** Every flag the surface understands, for distinguishing "unknown" from "not here". */ +const KNOWN_FLAGS = new Set([ + ...UNIVERSAL_FLAGS, + ...Object.values(COMMANDS).flatMap((spec) => spec.flags), +]); + +/** The command label used on responses (instructions carries its mode). */ +function commandLabel(command: string, positionals: string[]): string { + if (command === 'instructions' && positionals.length > 0) { + return `instructions ${positionals[0]}`; + } + return command; +} + +/** Reject any flag not valid for this command, distinguishing unknown from misplaced. */ +function checkFlags(command: string, spec: CommandSpec, args: ParsedArgs): void { + const allowed = new Set([...UNIVERSAL_FLAGS, ...spec.flags]); + const provided = [...args.booleans, ...args.values.keys()]; + for (const name of provided) { + if (allowed.has(name)) { + continue; + } + if (KNOWN_FLAGS.has(name)) { + throw usageError( + 'cli.flag_not_allowed', + `The --${name} flag is not valid for the ${command} command.`, + ); + } + throw usageError('cli.unknown_flag', `Unknown option --${name}. Run \`earsyntax --help\`.`); + } +} + +/** + * Run the CLI with an argv slice (no node/script prefix). Returns the process + * exit code. Never calls `process.exit`; the bin wrapper does that. + */ +export function run(argv: string[], options: RunOptions = {}): number { + const write = options.stdout ?? ((text: string): void => void process.stdout.write(text)); + const baseCwd = options.cwd ?? process.cwd(); + // Color only when writing to a real terminal; injected stdout (tests) stays plain. + const color = options.stdout === undefined && process.stdout.isTTY; + + const command = argv.at(0); + const rest = argv.slice(1); + + if (command === undefined || command === '--help' || command === '-h') { + write(`${usageText()}\n`); + return command === undefined ? 2 : 0; + } + if (command === '--version' || command === '-v') { + return dispatch('version', [], baseCwd, color, write); + } + + return dispatch(command, rest, baseCwd, color, write); +} + +function dispatch( + command: string, + rest: string[], + baseCwd: string, + color: boolean, + write: (text: string) => void, +): number { + const emitter = { json: rest.includes('--json'), painter: createPainter(color), write }; + + try { + if (!Object.hasOwn(COMMANDS, command)) { + throw usageError( + 'cli.unknown_command', + `Unknown command "${command}". Run \`earsyntax --help\`.`, + ); + } + const spec = COMMANDS[command]; + const args = parseArgs(rest); + checkFlags(command, spec, args); + + const global = resolveGlobals(args); + if (global.json && global.sarif) { + throw usageError( + 'cli.exclusive_flags', + 'The --json and --sarif flags are mutually exclusive.', + ); + } + + const cwd = global.cwd ? resolveInput(baseCwd, global.cwd) : baseCwd; + const context: CommandContext = { args, global, cwd, emitter }; + const result = spec.handler(context); + emitResult(emitter, result.response, result.pretty, result.raw); + return result.exitCode; + } catch (error) { + const cliError = + error instanceof CliError + ? error + : new CliError(2, { + code: 'cli.internal_error', + severity: 'error', + message: error instanceof Error ? error.message : String(error), + }); + const label = commandLabel(command, parsePositionalsSafely(rest)); + const response = errorResponse(label, undefined, cliError); + const pretty = `error ${cliError.diagnostic.code}: ${cliError.diagnostic.message}`; + emitResult(emitter, response, pretty); + return cliError.exitCode; + } +} + +/** Best-effort positional extraction for the error label; never throws. */ +function parsePositionalsSafely(rest: string[]): string[] { + const positionals: string[] = []; + for (const token of rest) { + if (token === '--') { + break; + } + if (!token.startsWith('--')) { + positionals.push(token); + } + } + return positionals; +} + +function usageText(): string { + return [ + 'earsyntax [options]', + '', + 'Commands:', + ' validate Validate EARS in host files and emit findings', + ' extract Print the requirement candidates a profile locates', + ' instructions Return the rules an agent follows for one loop step', + ' explain Explain one diagnostic id', + ' profiles List the built-in profiles', + ' doctor Detect hosts and agents and recommend commands', + ' init Render managed agent-wrapper and host-integration files', + ' version Version and feature discovery', + '', + 'Global options: --profile --json --sarif (validate only) --strict --quiet --cwd ', + ].join('\n'); +} diff --git a/packages/cli/src/color.ts b/packages/cli/src/color.ts new file mode 100644 index 0000000..757a719 --- /dev/null +++ b/packages/cli/src/color.ts @@ -0,0 +1,38 @@ +/** + * Minimal ANSI color helpers. + * + * Color is applied only when enabled (the dispatcher enables it for a real + * terminal and disables it otherwise; there is no user-facing flag), so + * stripping ANSI is a matter of not emitting it in the first place. Pretty + * output is the only consumer; JSON output never carries color. + */ + +const ESC = ''; + +const CODES = { + reset: `${ESC}[0m`, + bold: `${ESC}[1m`, + dim: `${ESC}[2m`, + red: `${ESC}[31m`, + yellow: `${ESC}[33m`, + cyan: `${ESC}[36m`, +} as const; + +type ColorName = keyof typeof CODES; + +/** A colorizer bound to whether color is enabled. */ +export interface Painter { + paint(name: ColorName, text: string): string; +} + +/** Build a {@link Painter}. When `enabled` is false every call returns the text unchanged. */ +export function createPainter(enabled: boolean): Painter { + return { + paint(name, text) { + if (!enabled) { + return text; + } + return `${CODES[name]}${text}${CODES.reset}`; + }, + }; +} diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts new file mode 100644 index 0000000..85228c5 --- /dev/null +++ b/packages/cli/src/commands/doctor.test.ts @@ -0,0 +1,286 @@ +/** + * Tests for `earsyntax doctor`. + * + * These drive {@link doctorCommand} directly with a hand-built + * {@link CommandContext} and assert against the returned {@link CommandResult}, + * separately from dispatcher routing. + * + * Detection runs against real fixture trees under `fixtures/host-repos/*`, plus a + * few `mkdtemp` scratch repos for marker-precedence cases the fixtures do not + * cover (Kiro steering/hooks fallback, Spec Kit `.specify/` precedence, an + * unreadable `--cwd`). + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { createPainter } from '../color.js'; +import type { CommandContext, CommandResult } from '../context.js'; +import { CliError } from '../errors.js'; +import { serialize, type Emitter } from '../response.js'; +import { doctorCommand } from './doctor.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const HOST_REPOS = resolve(REPO_ROOT, 'fixtures', 'host-repos'); + +/** Scratch directories created during the run, removed in `afterAll`. */ +const scratch: string[] = []; + +afterAll(() => { + for (const dir of scratch) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Build a context and invoke {@link doctorCommand}, returning its result. */ +function runDoctor(opts: { cwd: string; json?: boolean; quiet?: boolean }): CommandResult { + const args: ParsedArgs = { positionals: [], booleans: new Set(), values: new Map() }; + const global: GlobalOptions = { + json: opts.json ?? false, + sarif: false, + strict: false, + quiet: opts.quiet ?? false, + profile: 'strict', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + const context: CommandContext = { args, global, cwd: opts.cwd, emitter }; + return doctorCommand(context); +} + +/** The `detected` payload from a doctor result. */ +function detected(result: CommandResult): { + hosts: { host: string; evidence: string; profile: string }[]; + agents: { agent: string; evidence: string }[]; +} { + return result.response.detected as { + hosts: { host: string; evidence: string; profile: string }[]; + agents: { agent: string; evidence: string }[]; + }; +} + +/** Create a scratch repo directory registered for cleanup, and return its path. */ +function makeRepo(): string { + const dir = mkdtempSync(resolve(tmpdir(), 'earsyntax-doctor-')); + scratch.push(dir); + return dir; +} + +describe('doctor: host detection', () => { + it('detects Kiro from .kiro/specs/', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'kiro') }); + expect(result.exitCode).toBe(0); + expect(result.response.ok).toBe(true); + expect(detected(result).hosts).toEqual([ + { host: 'kiro', evidence: '.kiro/specs/', profile: 'kiro' }, + ]); + expect(detected(result).agents).toEqual([]); + }); + + it('detects Spec Kit from a matched specs/**/spec.md', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'speckit') }); + expect(detected(result).hosts).toEqual([ + { host: 'speckit', evidence: 'specs/checkout/spec.md', profile: 'speckit' }, + ]); + }); + + it('detects OpenSpec from openspec/', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'openspec') }); + expect(detected(result).hosts).toEqual([ + { host: 'openspec', evidence: 'openspec/', profile: 'openspec' }, + ]); + }); + + it('falls back to .kiro/steering/ then .kiro/hooks/ when specs are absent', () => { + const steeringRepo = makeRepo(); + mkdirSync(resolve(steeringRepo, '.kiro', 'steering'), { recursive: true }); + writeFileSync(resolve(steeringRepo, '.kiro', 'steering', 'product.md'), '# steering\n'); + expect(detected(runDoctor({ cwd: steeringRepo })).hosts).toEqual([ + { host: 'kiro', evidence: '.kiro/steering/', profile: 'kiro' }, + ]); + + const hooksRepo = makeRepo(); + mkdirSync(resolve(hooksRepo, '.kiro', 'hooks'), { recursive: true }); + writeFileSync(resolve(hooksRepo, '.kiro', 'hooks', 'on-save.json'), '{}\n'); + expect(detected(runDoctor({ cwd: hooksRepo })).hosts).toEqual([ + { host: 'kiro', evidence: '.kiro/hooks/', profile: 'kiro' }, + ]); + }); + + it('prefers .specify/ over the spec glob for Spec Kit evidence', () => { + const repo = makeRepo(); + mkdirSync(resolve(repo, '.specify'), { recursive: true }); + writeFileSync(resolve(repo, '.specify', 'config.yml'), 'name: demo\n'); + mkdirSync(resolve(repo, 'specs', 'checkout'), { recursive: true }); + writeFileSync(resolve(repo, 'specs', 'checkout', 'spec.md'), '# spec\n'); + expect(detected(runDoctor({ cwd: repo })).hosts).toEqual([ + { host: 'speckit', evidence: '.specify/', profile: 'speckit' }, + ]); + }); +}); + +describe('doctor: agent detection', () => { + it('detects every agent marker in the multi repo, in fixed order', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'multi') }); + expect(detected(result).agents).toEqual([ + { agent: 'claude', evidence: '.claude/' }, + { agent: 'codex', evidence: 'AGENTS.md' }, + { agent: 'cursor', evidence: '.cursor/' }, + { agent: 'copilot', evidence: '.github/prompts/' }, + { agent: 'gemini', evidence: 'GEMINI.md' }, + ]); + }); +}); + +describe('doctor: multi-host repo', () => { + it('lists all three hosts in fixed order', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'multi') }); + expect(detected(result).hosts).toEqual([ + { host: 'kiro', evidence: '.kiro/specs/', profile: 'kiro' }, + { host: 'speckit', evidence: '.specify/', profile: 'speckit' }, + { host: 'openspec', evidence: 'openspec/', profile: 'openspec' }, + ]); + }); + + it('recommends a validate command per host, then one init covering all', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'multi') }); + expect(result.response.next).toEqual([ + { + command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + reason: 'Validate Kiro requirements with the Kiro profile.', + forAgent: true, + }, + { + command: 'earsyntax validate "specs/**/spec.md" --profile speckit', + reason: 'Validate Spec Kit specs with the Spec Kit profile.', + forAgent: true, + }, + { + command: 'earsyntax validate "openspec/specs/**" "openspec/changes/**" --profile openspec', + reason: 'Validate OpenSpec specs and changes with the OpenSpec profile.', + forAgent: true, + }, + { + command: + 'earsyntax init --agent claude,codex,cursor,copilot,gemini --host kiro,speckit,openspec', + reason: 'Render integration files for the detected hosts and agents.', + forAgent: true, + }, + ]); + }); +}); + +describe('doctor: recommendation matrix', () => { + it('gives the exact Kiro validate and init commands', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'kiro') }); + expect(result.response.next).toEqual([ + { + command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + reason: 'Validate Kiro requirements with the Kiro profile.', + forAgent: true, + }, + { + command: 'earsyntax init --agent claude --host kiro', + reason: 'Render integration files for the detected hosts and agents.', + forAgent: true, + }, + ]); + }); + + it('defaults the init agent to claude when a host but no agent is detected', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'speckit') }); + const init = result.response.next.find((action) => action.command.startsWith('earsyntax init')); + expect(init?.command).toBe('earsyntax init --agent claude --host speckit'); + }); +}); + +describe('doctor: empty repo', () => { + it('is a clean exit 0 with no detections and an init recommendation', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'empty') }); + expect(result.exitCode).toBe(0); + expect(result.response.ok).toBe(true); + expect(detected(result)).toEqual({ hosts: [], agents: [] }); + expect(result.response.next).toEqual([ + { + command: 'earsyntax init --agent claude --host kiro', + reason: 'No SDD host detected. Run init with the agents and hosts you use.', + forAgent: true, + }, + ]); + }); +}); + +describe('doctor: envelope and cwd', () => { + it('reports the scanned root as the resolved cwd', () => { + const cwd = resolve(HOST_REPOS, 'kiro'); + expect(runDoctor({ cwd }).response.root).toBe(cwd); + }); + + it('respects --cwd: the same command sees different repos', () => { + const kiro = detected(runDoctor({ cwd: resolve(HOST_REPOS, 'kiro') })); + const openspec = detected(runDoctor({ cwd: resolve(HOST_REPOS, 'openspec') })); + expect(kiro.hosts[0]?.host).toBe('kiro'); + expect(openspec.hosts[0]?.host).toBe('openspec'); + }); + + it('rejects a --cwd that is not a directory with exit 2', () => { + const missing = resolve(HOST_REPOS, 'does-not-exist'); + try { + runDoctor({ cwd: missing }); + expect.unreachable('doctor should throw for a missing --cwd'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).exitCode).toBe(2); + expect((error as CliError).diagnostic.code).toBe('doctor.bad_cwd'); + } + }); + + it('keeps --json stdout pure JSON with no findings key', () => { + const result = runDoctor({ cwd: resolve(HOST_REPOS, 'multi'), json: true }); + const roundTrip = JSON.parse(serialize(result.response)) as Record; + expect(roundTrip.command).toBe('doctor'); + expect(roundTrip.ok).toBe(true); + expect(roundTrip).not.toHaveProperty('findings'); + expect(roundTrip).not.toHaveProperty('diagnostics'); + expect(roundTrip).toHaveProperty('detected'); + expect(Array.isArray(roundTrip.next)).toBe(true); + }); +}); + +describe('doctor: pretty output', () => { + it('renders a readable table with hosts, agents, and recommendations', () => { + const pretty = runDoctor({ cwd: resolve(HOST_REPOS, 'multi') }).pretty; + expect(pretty).toContain('Hosts:'); + expect(pretty).toContain('kiro'); + expect(pretty).toContain('.kiro/specs/'); + expect(pretty).toContain('Agents:'); + expect(pretty).toContain('claude'); + expect(pretty).toContain('Recommended commands:'); + expect(pretty).toContain( + 'earsyntax init --agent claude,codex,cursor,copilot,gemini --host kiro,speckit,openspec', + ); + }); + + it('reports no detections plainly for an empty repo', () => { + const pretty = runDoctor({ cwd: resolve(HOST_REPOS, 'empty') }).pretty; + expect(pretty).toContain('No hosts or agents detected.'); + expect(pretty).toContain('earsyntax init --agent claude --host kiro'); + }); + + it('under --quiet, emits only the recommended command lines', () => { + const pretty = runDoctor({ cwd: resolve(HOST_REPOS, 'kiro'), quiet: true }).pretty; + expect(pretty).toBe( + [ + 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + 'earsyntax init --agent claude --host kiro', + ].join('\n'), + ); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..e9e9d8e --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,255 @@ +/** + * `earsyntax doctor` — detect host frameworks and agent integrations in the repo + * at `--cwd`, and recommend exact commands. + * + * Doctor is stateless and read-only. It scans the directory named by `--cwd` + * (defaulting to the process working directory), reports the SDD hosts and agent + * integrations it finds by their on-disk markers, and returns runnable + * `earsyntax` commands in `next`. It never reads document content, never writes + * or deletes, never resolves a workspace, and never calls an LLM. It works in any + * directory, including an empty one. + * + * Exit codes: `0` always for a successful scan (an empty repo is a clean exit + * `0`, not a failure); `2` only for the usage failure of a `--cwd` that does not + * name a directory. There are no lint findings here, so exit `1` never occurs. + * + * Detection is anchored at the scan root (the resolved `--cwd`), which is also the + * `root` reported in the envelope. Evidence paths are recorded relative to that + * root, with a trailing slash for directory markers. + */ + +import { existsSync, globSync, statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { NextAction } from '../facade-types.js'; +import { usageError } from '../errors.js'; +import { buildResponse } from '../response.js'; + +/** A detected SDD host: which host, the marker that proved it, and the profile to lint it with. */ +interface DetectedHost { + host: string; + evidence: string; + profile: string; +} + +/** A detected agent integration: which agent, and the marker that proved it. */ +interface DetectedAgent { + agent: string; + evidence: string; +} + +/** The validate recommendation for each detectable host, keyed by host name. */ +const HOST_VALIDATE: Record = { + kiro: { + command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + reason: 'Validate Kiro requirements with the Kiro profile.', + }, + speckit: { + command: 'earsyntax validate "specs/**/spec.md" --profile speckit', + reason: 'Validate Spec Kit specs with the Spec Kit profile.', + }, + openspec: { + command: 'earsyntax validate "openspec/specs/**" "openspec/changes/**" --profile openspec', + reason: 'Validate OpenSpec specs and changes with the OpenSpec profile.', + }, +}; + +/** + * A directory marker. Present when `rel` resolves to a directory under `root`. + * Evidence is the relative path with a trailing slash (`.kiro/specs/`). + */ +function detectDir(root: string, rel: string): string | undefined { + const abs = resolve(root, rel); + if (existsSync(abs) && statSync(abs).isDirectory()) { + return `${rel}/`; + } + return undefined; +} + +/** A file marker. Present when `rel` resolves to a file under `root`; evidence is `rel`. */ +function detectFile(root: string, rel: string): string | undefined { + const abs = resolve(root, rel); + if (existsSync(abs) && statSync(abs).isFile()) { + return rel; + } + return undefined; +} + +/** + * A glob marker. Present when `pattern` matches at least one path under `root`; + * evidence is the first match in sorted order, as a POSIX-relative path. + */ +function detectGlob(root: string, pattern: string): string | undefined { + const matches = globSync(pattern, { cwd: root }).sort((a, b) => a.localeCompare(b)); + const first = matches.at(0); + return first === undefined ? undefined : first.split(sep).join('/'); +} + +/** + * Detect SDD hosts under `root`, in the fixed order `kiro`, `speckit`, + * `openspec`. Each host reports a single primary evidence marker chosen by + * precedence: for Kiro, the specs directory before steering before hooks; for + * Spec Kit, the `.specify/` config directory before a matched `specs/**\/spec.md`. + */ +function detectHosts(root: string): DetectedHost[] { + const hosts: DetectedHost[] = []; + + const kiro = + detectDir(root, '.kiro/specs') ?? + detectDir(root, '.kiro/steering') ?? + detectDir(root, '.kiro/hooks'); + if (kiro !== undefined) { + hosts.push({ host: 'kiro', evidence: kiro, profile: 'kiro' }); + } + + const speckit = detectDir(root, '.specify') ?? detectGlob(root, 'specs/**/spec.md'); + if (speckit !== undefined) { + hosts.push({ host: 'speckit', evidence: speckit, profile: 'speckit' }); + } + + const openspec = detectDir(root, 'openspec'); + if (openspec !== undefined) { + hosts.push({ host: 'openspec', evidence: openspec, profile: 'openspec' }); + } + + return hosts; +} + +/** + * Detect agent integrations under `root`, in the fixed order `claude`, `codex`, + * `cursor`, `copilot`, `gemini`. `AGENTS.md` maps to `codex`, the canonical agent + * for that shared convention; `init --agent codex` renders it. + */ +function detectAgents(root: string): DetectedAgent[] { + const agents: DetectedAgent[] = []; + + const claude = detectDir(root, '.claude'); + if (claude !== undefined) { + agents.push({ agent: 'claude', evidence: claude }); + } + + const codex = detectFile(root, 'AGENTS.md'); + if (codex !== undefined) { + agents.push({ agent: 'codex', evidence: codex }); + } + + const cursor = detectDir(root, '.cursor'); + if (cursor !== undefined) { + agents.push({ agent: 'cursor', evidence: cursor }); + } + + const copilot = detectDir(root, '.github/prompts'); + if (copilot !== undefined) { + agents.push({ agent: 'copilot', evidence: copilot }); + } + + const gemini = detectFile(root, 'GEMINI.md'); + if (gemini !== undefined) { + agents.push({ agent: 'gemini', evidence: gemini }); + } + + return agents; +} + +/** + * Build the recommended commands. Each detected host contributes its validate + * command; when at least one host is detected, a single `init` action renders the + * detected hosts against the detected agents (or `claude` when no agent marker was + * found). An empty scan recommends running `init` with explicit flags. + */ +function buildNext(hosts: DetectedHost[], agents: DetectedAgent[]): NextAction[] { + const next: NextAction[] = []; + + for (const host of hosts) { + const rec = HOST_VALIDATE[host.host]; + next.push({ command: rec.command, reason: rec.reason, forAgent: true }); + } + + if (hosts.length > 0) { + const agentList = agents.length > 0 ? agents.map((agent) => agent.agent).join(',') : 'claude'; + const hostList = hosts.map((host) => host.host).join(','); + next.push({ + command: `earsyntax init --agent ${agentList} --host ${hostList}`, + reason: 'Render integration files for the detected hosts and agents.', + forAgent: true, + }); + return next; + } + + next.push({ + command: 'earsyntax init --agent claude --host kiro', + reason: 'No SDD host detected. Run init with the agents and hosts you use.', + forAgent: true, + }); + return next; +} + +/** Render the human listing: the scan root, detected hosts and agents, then recommendations. */ +function renderPretty( + root: string, + hosts: DetectedHost[], + agents: DetectedAgent[], + next: NextAction[], +): string { + const lines = [`Repo: ${root}`, '']; + + if (hosts.length === 0 && agents.length === 0) { + lines.push('No hosts or agents detected.'); + } else { + if (hosts.length > 0) { + lines.push('Hosts:'); + for (const host of hosts) { + lines.push( + ` ${host.host.padEnd(10)} ${host.evidence.padEnd(24)} (profile ${host.profile})`, + ); + } + } + if (agents.length > 0) { + if (hosts.length > 0) { + lines.push(''); + } + lines.push('Agents:'); + for (const agent of agents) { + lines.push(` ${agent.agent.padEnd(10)} ${agent.evidence}`); + } + } + } + + lines.push('', 'Recommended commands:'); + for (const action of next) { + lines.push(` ${action.command}`); + } + return lines.join('\n'); +} + +/** Render the quiet form: only the recommended command lines, the actionable core. */ +function renderQuiet(next: NextAction[]): string { + return next.map((action) => action.command).join('\n'); +} + +/** + * Run the `doctor` command: scan the resolved `--cwd`, report detected hosts and + * agents, and recommend commands. + * + * @param context The command context (parsed args, globals, cwd, emitter). + * @returns The {@link CommandResult}; the dispatcher performs the single write. + * @throws {@link CliError} exit 2 when `--cwd` does not name a directory. + */ +export function doctorCommand(context: CommandContext): CommandResult { + const root = context.cwd; + if (!existsSync(root) || !statSync(root).isDirectory()) { + throw usageError('doctor.bad_cwd', `Directory not found: ${root}.`); + } + + const hosts = detectHosts(root); + const agents = detectAgents(root); + const next = buildNext(hosts, agents); + + const response = buildResponse( + { command: 'doctor', ok: true, root, next }, + { detected: { hosts, agents } }, + ); + + const pretty = context.global.quiet ? renderQuiet(next) : renderPretty(root, hosts, agents, next); + return { response, pretty, exitCode: 0 }; +} diff --git a/packages/cli/src/commands/explain.test.ts b/packages/cli/src/commands/explain.test.ts new file mode 100644 index 0000000..1c4ea85 --- /dev/null +++ b/packages/cli/src/commands/explain.test.ts @@ -0,0 +1,471 @@ +/** + * Tests for the `explain` command. + * + * Four concerns, each a describe block: + * + * 1. Coverage — every id in {@link DIAGNOSTIC_REGISTRY} explains successfully, + * with a non-empty meaning, rationale, and both examples, and echoes the + * registry's severity. The registry is enumerated directly, so a newly + * appended id is covered automatically. + * 2. Alias resolution — every entry's deprecated old code resolves to its + * current id with `alias: true` and the exact deprecation note. + * 3. Executable examples — every registry example is run through the real + * `@earsyntax/core` linter and asserted to behave as documented: the bad + * example emits the diagnostic's own id, the good example does not. Structural + * codes run through `lintEars`; catalog codes run with a minimal catalog + * derived from the example (the linter never emits catalog codes without a + * catalog); coverage codes run through `lintCatalogCoverage`, whose input is a + * requirement set plus its catalog. One code, EARS-W014, is emitted only by + * the legacy guided mode the host-native CLI never selects; it is documented + * in {@link NON_EXECUTABLE} with a guard pinning that behavior rather than + * executed. Every id lands in exactly one of these buckets, so the executable + * check has no silent gaps. + * 4. Errors and the envelope — unknown id, missing id, and extra args each exit + * 2 with the facade envelope; JSON output is pure; the dispatcher wires it end + * to end. + * + * The command renders from the registry, so the coverage and alias assertions + * compare against registry fields, not string literals: no explanation text is + * duplicated here. + */ + +import { + type Catalog, + DIAGNOSTIC_REGISTRY, + idForCode, + lintCatalogCoverage, + lintEars, +} from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { run } from '../cli.js'; +import { createPainter } from '../color.js'; +import type { CommandContext } from '../context.js'; +import type { Emitter } from '../response.js'; +import { explainCommand } from './explain.js'; + +/** Build a {@link CommandContext} for the handler with the given positional ids. */ +function makeContext( + positionals: string[], + options: { json?: boolean; quiet?: boolean } = {}, +): CommandContext { + const args: ParsedArgs = { positionals, booleans: new Set(), values: new Map() }; + const global: GlobalOptions = { + json: options.json ?? false, + sarif: false, + strict: false, + quiet: options.quiet ?? false, + profile: 'strict', + cwd: '/work', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + return { args, global, cwd: '/work', emitter }; +} + +/** Explain one id through the handler and return the JSON payload as a record. */ +function explain(id: string): Record { + const result = explainCommand(makeContext([id], { json: true })); + return result.response; +} + +describe('explain — coverage of every registry id', () => { + it('explains every current id with complete, non-empty metadata', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const result = explainCommand(makeContext([entry.id], { json: true })); + expect(result.exitCode).toBe(0); + const payload = result.response; + expect(payload.ok).toBe(true); + expect(payload.id).toBe(entry.id); + expect(payload.requestedId).toBe(entry.id); + // A current id is not an alias. + expect(payload.alias).toBeUndefined(); + expect(payload.deprecationNote).toBeUndefined(); + // Severity and text project straight from the registry entry. + expect(payload.severity).toBe(entry.defaultSeverity); + expect(payload.title).toBe(entry.title); + expect(payload.meaning).toBe(entry.meaning); + expect(payload.rationale).toBe(entry.rationale); + expect(payload.badExample).toBe(entry.badExample); + expect(payload.goodExample).toBe(entry.goodExample); + expect(payload.profileNotes).toBe(entry.profileNotes); + // The documented fields carry real content. + expect((payload.meaning as string).length).toBeGreaterThan(0); + expect((payload.rationale as string).length).toBeGreaterThan(0); + expect((payload.badExample as string).length).toBeGreaterThan(0); + expect((payload.goodExample as string).length).toBeGreaterThan(0); + expect(payload.next).toEqual([]); + } + }); + + it('renders each id in pretty text with its title and both examples', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const result = explainCommand(makeContext([entry.id])); + expect(result.pretty).toContain(entry.id); + expect(result.pretty).toContain(entry.title); + expect(result.pretty).toContain(entry.meaning); + expect(result.pretty).toContain(entry.badExample); + expect(result.pretty).toContain(entry.goodExample); + } + }); +}); + +describe('explain — deprecated alias resolution', () => { + it('resolves every old code to its current id with the deprecation note', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const result = explainCommand(makeContext([entry.oldCode], { json: true })); + expect(result.exitCode).toBe(0); + const payload = result.response; + expect(payload.id).toBe(entry.id); + expect(payload.requestedId).toBe(entry.oldCode); + expect(payload.alias).toBe(true); + expect(payload.deprecationNote).toBe( + `${entry.oldCode} is a deprecated alias for ${entry.id}.`, + ); + // The resolved content is the same entry as the current-id path. + expect(payload.meaning).toBe(entry.meaning); + } + }); + + it('names the deprecated alias in the pretty rendering', () => { + const entry = DIAGNOSTIC_REGISTRY[5]; // EARS-E006 / ears.invalid_if_then_form. + const result = explainCommand(makeContext([entry.oldCode])); + expect(result.pretty).toContain(entry.oldCode); + expect(result.pretty).toContain('deprecated alias'); + }); + + it('resolves ids case-insensitively without marking them as aliases', () => { + const payload = explain('ears-e006'); + expect(payload.id).toBe('EARS-E006'); + expect(payload.alias).toBeUndefined(); + }); +}); + +// --- Executable examples --------------------------------------------------- + +/** A system entry so the literal "the system" in an example resolves and does not add noise. */ +const SYSTEM: Catalog['systems'] = [{ id: 'SYS', name: 'system' }]; + +/** + * Per-entry linter context for the executable-examples check. `badCatalog` and + * `goodCatalog` are supplied only for catalog diagnostics, which cannot fire + * from text alone. A structural diagnostic carries neither and runs against the + * default strict dialect, which is exactly the dialect its example is written + * for. + */ +interface ExampleContext { + badCatalog?: Catalog; + goodCatalog?: Catalog; +} + +/** + * The linter context for every non-divergent registry id. Structural entries map + * to an empty context (text-only, strict dialect). Catalog entries carry the + * minimal catalog that makes the documented example resolve as claimed. The + * catalogs live here, in the test, not in the registry: they are test scaffolding + * for executing the examples, not explanation content. + */ +const EXAMPLE_CONTEXT: Record = { + // Structural diagnostics: the example text alone determines the outcome. + 'EARS-E003': {}, + 'EARS-E004': {}, + 'EARS-E005': {}, + 'EARS-E006': {}, + 'EARS-E007': {}, + 'EARS-E008': {}, + 'EARS-E009': {}, + 'EARS-E010': {}, + 'EARS-E011': {}, + 'EARS-E012': {}, + 'EARS-E013': {}, + 'EARS-E014': {}, + 'EARS-E015': {}, + 'EARS-E016': {}, + 'EARS-W010': {}, + 'EARS-W013': {}, + 'EARS-W015': {}, + 'EARS-W016': {}, + // Catalog diagnostics: a minimal catalog makes the example resolve as claimed. + 'EARS-E001': { + badCatalog: { + systems: [ + { id: 'S1', name: 'controller' }, + { id: 'S2', name: 'controller' }, + ], + }, + goodCatalog: { systems: [{ id: 'S3', name: 'brake controller' }] }, + }, + 'EARS-E002': { + badCatalog: { systems: [{ id: 'S1', name: 'billing service' }] }, + goodCatalog: { systems: [{ id: 'S1', name: 'billing service' }] }, + }, + 'EARS-W001': { + badCatalog: { + systems: SYSTEM, + events: [ + { id: 'E1', name: 'the request arrives' }, + { id: 'E2', name: 'the request arrives' }, + ], + }, + goodCatalog: { systems: SYSTEM, events: [{ id: 'E3', name: 'the payment webhook arrives' }] }, + }, + 'EARS-W002': { + badCatalog: { systems: SYSTEM, events: [{ id: 'E1', name: 'a refund event occurs' }] }, + goodCatalog: { systems: SYSTEM, events: [{ id: 'E1', name: 'a refund event occurs' }] }, + }, + 'EARS-W003': { + badCatalog: { + systems: SYSTEM, + features: [ + { id: 'F1', name: 'retries are enabled' }, + { id: 'F2', name: 'retries are enabled' }, + ], + }, + goodCatalog: { + systems: SYSTEM, + features: [{ id: 'F3', name: 'automatic retries are enabled' }], + }, + }, + 'EARS-W004': { + badCatalog: { systems: SYSTEM, features: [{ id: 'F1', name: 'the premium tier is enabled' }] }, + goodCatalog: { systems: SYSTEM, features: [{ id: 'F1', name: 'the premium tier is enabled' }] }, + }, + 'EARS-W005': { + badCatalog: { + systems: SYSTEM, + states: [ + { id: 'T1', name: 'draining' }, + { id: 'T2', name: 'draining' }, + ], + }, + goodCatalog: { systems: SYSTEM, states: [{ id: 'T3', name: 'the queue is draining' }] }, + }, + 'EARS-W006': { + badCatalog: { systems: SYSTEM, states: [{ id: 'T1', name: 'the queue is full' }] }, + goodCatalog: { systems: SYSTEM, states: [{ id: 'T1', name: 'the queue is full' }] }, + }, + 'EARS-W008': { + badCatalog: { + systems: SYSTEM, + events: [ + { id: 'E1', name: 'the reset is triggered' }, + { id: 'E2', name: 'the reset is triggered' }, + ], + }, + goodCatalog: { + systems: SYSTEM, + events: [{ id: 'E3', name: 'the watchdog reset is triggered' }], + }, + }, + 'EARS-W009': { + badCatalog: { systems: SYSTEM, states: [{ id: 'T1', name: 'A' }] }, + goodCatalog: { + systems: SYSTEM, + states: [ + { id: 'T1', name: 'A' }, + { id: 'T2', name: 'C' }, + ], + }, + }, + 'EARS-W011': { + badCatalog: { systems: SYSTEM, events: [{ id: 'E1', name: 'something else' }] }, + goodCatalog: { systems: SYSTEM, events: [{ id: 'E1', name: 'the entry sensor triggers' }] }, + }, + 'EARS-W012': { + badCatalog: { + systems: SYSTEM, + events: [{ id: 'E1', name: 'Postgres is unavailable', aliases: ['db is unavailable'] }], + }, + goodCatalog: { + systems: SYSTEM, + events: [{ id: 'E1', name: 'Postgres is unavailable', aliases: ['db is unavailable'] }], + }, + }, +}; + +/** + * Coverage diagnostics: their example is a requirement evaluated by + * `lintCatalogCoverage` against a catalog, not by linting a single requirement. + * The bad catalog holds a term the bad requirement leaves unreferenced (so the + * code fires); the good requirement references that term (so it does not). The + * catalogs live here as test scaffolding, the same as {@link EXAMPLE_CONTEXT}. + */ +const COVERAGE_CONTEXT: Record = { + 'EARS-W007': { + badCatalog: { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-PAY', name: 'a payment webhook is received' }], + }, + goodCatalog: { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-PAY', name: 'a payment webhook is received' }], + }, + }, +}; + +/** + * Registry entries no host-native code path emits, documented rather than + * executed. EARS-W014 is produced only by the legacy guided mode, which the + * facade never selects; its guard pins that the strict default reports EARS-E010 + * for the same text and guided mode adds EARS-W014, so a future change to either + * path fails here instead of passing silently. + */ +const NON_EXECUTABLE: Record void }> = { + 'EARS-W014': { + reason: + 'suspicious_text_shape is emitted only by lintEars in guided mode; the host-native CLI never selects guided mode. Under the strict default the badExample reports EARS-E010, and guided mode adds EARS-W014 alongside it.', + guard: () => { + const strict = codesFor('timer reset maybe when idle'); + expect(strict).not.toContain('EARS-W014'); + expect(strict).toContain('EARS-E010'); + const guided = lintEars('timer reset maybe when idle', undefined, { + mode: 'guided', + }).diagnostics.map((diagnostic) => idForCode(diagnostic.code)); + expect(guided).toContain('EARS-W014'); + }, + }, +}; + +/** Run the linter and return the emitted findings as current registry ids. */ +function codesFor(text: string, catalog?: Catalog): string[] { + return lintEars(text, catalog).diagnostics.map((diagnostic) => idForCode(diagnostic.code)); +} + +/** Run the coverage pass over one requirement and return the ids it emits. */ +function coverageCodesFor(text: string, catalog: Catalog): string[] { + return lintCatalogCoverage([{ text }], catalog).map((diagnostic) => idForCode(diagnostic.code)); +} + +describe('explain — the registry examples execute as documented', () => { + it('partitions every registry id into exactly one execution bucket', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const buckets = [ + entry.id in EXAMPLE_CONTEXT, + entry.id in COVERAGE_CONTEXT, + entry.id in NON_EXECUTABLE, + ].filter(Boolean).length; + expect(buckets, `id ${entry.id} must belong to exactly one execution bucket`).toBe(1); + } + }); + + it('emits the diagnostic id for the bad example and not for the good example', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + if (!(entry.id in EXAMPLE_CONTEXT)) { + continue; + } + const context = EXAMPLE_CONTEXT[entry.id]; + const badCodes = codesFor(entry.badExample, context.badCatalog); + const goodCodes = codesFor(entry.goodExample, context.goodCatalog); + expect(badCodes, `${entry.id} bad example should emit ${entry.id}`).toContain(entry.id); + expect(goodCodes, `${entry.id} good example should not emit ${entry.id}`).not.toContain( + entry.id, + ); + } + }); + + it('emits the coverage diagnostic for the bad example and not the good example', () => { + for (const [id, context] of Object.entries(COVERAGE_CONTEXT)) { + const entry = DIAGNOSTIC_REGISTRY.find((candidate) => candidate.id === id); + expect(entry, `${id} is not in the registry`).toBeDefined(); + const badCodes = coverageCodesFor(entry!.badExample, context.badCatalog); + const goodCodes = coverageCodesFor(entry!.goodExample, context.goodCatalog); + expect(badCodes, `${id} bad example should emit ${id}`).toContain(id); + expect(goodCodes, `${id} good example should not emit ${id}`).not.toContain(id); + } + }); + + it('documents the non-executable entries with their observed behavior', () => { + // Codes no host-native path emits, pinned rather than executed. Each guard + // fixes the current behavior so a later change to the emitting path surfaces + // here instead of passing silently. + expect(Object.keys(NON_EXECUTABLE).sort()).toEqual(['EARS-W014']); + for (const { guard } of Object.values(NON_EXECUTABLE)) { + guard(); + } + }); +}); + +describe('explain — errors, envelope, and purity', () => { + it('exits 2 with a suggestion for an unknown id', () => { + const result = explainCommand(makeContext(['EARS-E999'], { json: true })); + expect(result.exitCode).toBe(2); + const payload = result.response; + expect(payload.command).toBe('explain'); + expect(payload.ok).toBe(false); + const diagnostics = payload.diagnostics as { code: string; message: string }[]; + expect(diagnostics[0].code).toBe('explain.unknown_id'); + // The nearest ids are offered; EARS-E### codes are closest to EARS-E999. + expect(diagnostics[0].message).toMatch(/EARS-E0\d\d/); + expect(payload.next).toEqual([]); + }); + + it('exits 2 when no id is given', () => { + const result = explainCommand(makeContext([], { json: true })); + expect(result.exitCode).toBe(2); + expect((result.response.diagnostics as { code: string }[])[0].code).toBe('explain.missing_id'); + }); + + it('exits 2 when more than one id is given', () => { + const result = explainCommand(makeContext(['EARS-E001', 'EARS-E002'], { json: true })); + expect(result.exitCode).toBe(2); + expect((result.response.diagnostics as { code: string }[])[0].code).toBe( + 'explain.too_many_args', + ); + }); + + it('is pure JSON: the payload round-trips with no undefined or functions', () => { + const result = explainCommand(makeContext(['ears.missing_shall'], { json: true })); + const roundTripped = JSON.parse(JSON.stringify(result.response)); + expect(roundTripped.id).toBe('EARS-E007'); + expect(roundTripped.alias).toBe(true); + expect(roundTripped.deprecationNote).toBe( + 'ears.missing_shall is a deprecated alias for EARS-E007.', + ); + }); + + it('keeps the base envelope key order: version, command, ok, then payload, then next', () => { + const keys = Object.keys(explain('EARS-E006')); + expect(keys.slice(0, 3)).toEqual(['version', 'command', 'ok']); + expect(keys.at(-1)).toBe('next'); + }); +}); + +describe('explain — through the dispatcher', () => { + function capture(argv: string[]): { out: string; code: number } { + let out = ''; + const code = run(argv, { stdout: (text) => (out += text), cwd: '/work' }); + return { out, code }; + } + + it('exits 0 and emits the JSON envelope for a known id', () => { + const { out, code } = capture(['explain', 'EARS-E006', '--json']); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.command).toBe('explain'); + expect(parsed.id).toBe('EARS-E006'); + }); + + it('exits 0 and emits pretty text without --json', () => { + const { out, code } = capture(['explain', 'EARS-E006']); + expect(code).toBe(0); + expect(out).toContain('EARS-E006'); + expect(() => JSON.parse(out)).toThrow(); + }); + + it('resolves a deprecated alias end to end', () => { + const { out, code } = capture(['explain', 'ears.invalid_if_then_form', '--json']); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.id).toBe('EARS-E006'); + expect(parsed.alias).toBe(true); + }); + + it('exits 2 for an unknown id through the dispatcher', () => { + const { out, code } = capture(['explain', 'nope', '--json']); + expect(code).toBe(2); + expect(JSON.parse(out).diagnostics[0].code).toBe('explain.unknown_id'); + }); +}); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts new file mode 100644 index 0000000..a575339 --- /dev/null +++ b/packages/cli/src/commands/explain.ts @@ -0,0 +1,177 @@ +/** + * `earsyntax explain ` — full write-up of one diagnostic. + * + * Resolves a current `EARS-*` id or a deprecated dotted alias against the frozen + * {@link DIAGNOSTIC_REGISTRY} in `@earsyntax/core` and renders that entry's + * metadata. The registry is the single source of truth: this module never holds + * its own copy of any explanation text, it only projects registry fields onto + * the JSON envelope and a pretty rendering. When the requested id resolves via a + * deprecated alias, the response carries `alias: true` and a `deprecationNote`. + * + * No profile is needed and no repo is resolved. Exit `0` on a known id, `2` on a + * usage error (no id, more than one id, or an unknown id). An unknown id carries + * the nearest known ids as a suggestion, computed with a dependency-free edit + * distance over the registry ids and their aliases. + */ + +import { DIAGNOSTIC_REGISTRY, type DiagnosticRegistryEntry } from '@earsyntax/core'; +import type { Painter } from '../color.js'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { FacadeDiagnostic } from '../facade-types.js'; +import { buildResponse } from '../response.js'; + +/** A registry entry resolved from user input, with whether the match was via a deprecated alias. */ +interface ResolvedEntry { + entry: DiagnosticRegistryEntry; + /** `true` when the requested string matched the entry's deprecated old code, not its current id. */ + viaAlias: boolean; +} + +/** + * Case-insensitive lookup from a requested id or alias to its registry entry. + * Built once from the frozen registry: each entry is indexed under both its + * current id and its deprecated old code (upper-cased so `ears-e006` and + * `EARS.MISSING_SHALL` still resolve). Ids and dotted old codes never collide + * under upper-casing, so one flat map is unambiguous. + */ +const INDEX: ReadonlyMap = (() => { + const index = new Map(); + for (const entry of DIAGNOSTIC_REGISTRY) { + index.set(entry.id.toUpperCase(), { entry, viaAlias: false }); + index.set(entry.oldCode.toUpperCase(), { entry, viaAlias: true }); + } + return index; +})(); + +/** Resolve a requested id or deprecated alias to its entry, or `undefined` when nothing matches. */ +function resolveEntry(requested: string): ResolvedEntry | undefined { + return INDEX.get(requested.toUpperCase()); +} + +/** + * Levenshtein edit distance between two strings, computed with a single rolling + * row so it allocates O(n) not O(n*m). Dependency-free; used only to rank + * suggestions for an unknown id. + */ +function editDistance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i += 1) { + let prev = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j += 1) { + const temp = row[j]; + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost); + prev = temp; + } + } + return row[b.length]; +} + +/** + * The nearest known current ids to an unrecognized request, best first. Each + * entry is scored by the smaller edit distance between the request and either + * its current id or its old code, so `ears.missing_shal` still points at + * `EARS-E007`. Deterministic: ties break by ascending id. Returns at most three. + */ +function suggestIds(requested: string): string[] { + const needle = requested.toUpperCase(); + const scored = DIAGNOSTIC_REGISTRY.map((entry) => ({ + id: entry.id, + distance: Math.min( + editDistance(needle, entry.id.toUpperCase()), + editDistance(needle, entry.oldCode.toUpperCase()), + ), + })); + scored.sort((left, right) => left.distance - right.distance || left.id.localeCompare(right.id)); + return scored.slice(0, 3).map((candidate) => candidate.id); +} + +/** Build an exit-2 usage result carrying one facade diagnostic and a matching pretty line. */ +function usageResult(code: string, message: string): CommandResult { + const diagnostic: FacadeDiagnostic = { code, severity: 'error', message }; + const response = buildResponse({ + command: 'explain', + ok: false, + diagnostics: [diagnostic], + next: [], + }); + return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; +} + +/** The deprecation note shown when a request resolved through a deprecated alias. */ +function deprecationNote(requestedId: string, currentId: string): string { + return `${requestedId} is a deprecated alias for ${currentId}.`; +} + +/** Render the explanation as human text, labels apart from data, colored via the painter. */ +function prettyExplain( + entry: DiagnosticRegistryEntry, + requestedId: string, + viaAlias: boolean, + painter: Painter, +): string { + const severityColor = entry.defaultSeverity === 'error' ? 'red' : 'yellow'; + const heading = `${painter.paint('bold', entry.id)} ${painter.paint(severityColor, entry.defaultSeverity)} ${entry.title}`; + const lines = [heading]; + if (viaAlias) { + lines.push(painter.paint('dim', `resolved from deprecated alias ${requestedId}`)); + } + const section = (label: string, body: string): void => { + lines.push('', painter.paint('cyan', label), ` ${body}`); + }; + section('Meaning', entry.meaning); + section('Rationale', entry.rationale); + section('Bad', entry.badExample); + section('Good', entry.goodExample); + section('Profiles', entry.profileNotes); + return lines.join('\n'); +} + +/** + * The `explain` command handler. Reads the single positional diagnostic id, + * resolves it against the registry (current id or deprecated alias), and returns + * the write-up as a {@link CommandResult}; the dispatcher performs the single + * write. `--quiet` blanks the pretty rendering, leaving JSON output untouched. + */ +export function explainCommand(context: CommandContext): CommandResult { + const { positionals } = context.args; + if (positionals.length === 0 || positionals[0].trim() === '') { + return usageResult('explain.missing_id', 'Provide a diagnostic id, for example EARS-E006.'); + } + if (positionals.length > 1) { + return usageResult('explain.too_many_args', 'Explain takes exactly one diagnostic id.'); + } + + const requestedId = positionals[0].trim(); + const resolved = resolveEntry(requestedId); + if (resolved === undefined) { + const suggestions = suggestIds(requestedId); + return usageResult( + 'explain.unknown_id', + `Unknown diagnostic id "${requestedId}". Did you mean ${suggestions.join(', ')}?`, + ); + } + + const { entry, viaAlias } = resolved; + const response = buildResponse( + { command: 'explain', ok: true, next: [] }, + { + id: entry.id, + requestedId, + ...(viaAlias ? { alias: true, deprecationNote: deprecationNote(requestedId, entry.id) } : {}), + severity: entry.defaultSeverity, + title: entry.title, + meaning: entry.meaning, + rationale: entry.rationale, + badExample: entry.badExample, + goodExample: entry.goodExample, + profileNotes: entry.profileNotes, + }, + ); + + const pretty = context.global.quiet + ? '' + : prettyExplain(entry, requestedId, viaAlias, context.emitter.painter); + return { response, pretty, exitCode: 0 }; +} diff --git a/packages/cli/src/commands/extract.test.ts b/packages/cli/src/commands/extract.test.ts new file mode 100644 index 0000000..a8e9319 --- /dev/null +++ b/packages/cli/src/commands/extract.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for `earsyntax extract`. + * + * These drive {@link extractCommand} directly with a hand-built + * {@link CommandContext} and assert against the returned {@link CommandResult}, + * separately from dispatcher routing. + * + * Snapshot coverage diffs the projected facade candidates against the profile + * fixture sidecars (`fixtures/profiles/*.candidates.json`). Every agreeing + * fixture, including `openspec/spec` and `openspec/change`, matches real pipeline + * output: the openspec locator emits one requirement statement per + * `### Requirement:` block and skips the `#### Scenario:` gherkin steps. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { createPainter } from '../color.js'; +import type { CommandContext, CommandResult } from '../context.js'; +import { CliError } from '../errors.js'; +import { serialize, type Emitter } from '../response.js'; +import { extractCommand } from './extract.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const FIXTURES = resolve(REPO_ROOT, 'fixtures', 'profiles'); + +/** Build a context and invoke {@link extractCommand}, returning its result. */ +function runExtract(opts: { + paths: string[]; + profile?: string; + json?: boolean; + quiet?: boolean; + cwd?: string; +}): CommandResult { + const args: ParsedArgs = { + positionals: opts.paths, + booleans: new Set(), + values: new Map(), + }; + const global: GlobalOptions = { + json: opts.json ?? false, + sarif: false, + strict: false, + quiet: opts.quiet ?? false, + profile: opts.profile ?? 'strict', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + const context: CommandContext = { args, global, cwd: opts.cwd ?? FIXTURES, emitter }; + return extractCommand(context); +} + +/** Read the `candidates` array from a profile fixture sidecar. */ +function sidecarCandidates(relPath: string): Record[] { + const raw = readFileSync(resolve(FIXTURES, relPath), 'utf8'); + return (JSON.parse(raw) as { candidates: Record[] }).candidates; +} + +/** + * Drop the `file` field for a position/text comparison. + * + * The sidecars are inconsistent about how they record `file` (some + * profile-relative like `kiro/requirements.md`, speckit repo-relative like + * `fixtures/profiles/speckit/spec.md`), and `file` is just the input path echoed + * back. The load-bearing snapshot is line/col/text/profile/locatorRuleId/ + * requirementId; `file` is asserted separately as the echoed path. + */ +function withoutFile(candidates: readonly Record[]): Record[] { + return candidates.map(({ file: _file, ...rest }) => rest); +} + +// Fixtures whose sidecar matches real pipeline output once the command projects +// the pipeline Candidate into the frozen facade shape. Each carries the cwd that +// reproduces the sidecar's `file` convention so full-object equality holds. +const AGREEING: { + name: string; + doc: string; + profile: string; + sidecar: string; +}[] = [ + { + name: 'kiro requirements (numbered acceptance-criteria items)', + doc: 'kiro/requirements.md', + profile: 'kiro', + sidecar: 'kiro/requirements.candidates.json', + }, + { + name: 'strict valid (every-line)', + doc: 'strict/valid.ears', + profile: 'strict', + sidecar: 'strict/valid.candidates.json', + }, + { + name: 'strict invalid (every-line, locate-only ignores validity)', + doc: 'strict/invalid.ears', + profile: 'strict', + sidecar: 'strict/invalid.candidates.json', + }, + { + name: 'ears-x prohibition (every-line)', + doc: 'ears-x/prohibition.ears', + profile: 'ears-x', + sidecar: 'ears-x/prohibition.candidates.json', + }, + { + name: 'ears-x frame-metadata (every-line, frame ids captured)', + doc: 'ears-x/frame-metadata.ears', + profile: 'ears-x', + sidecar: 'ears-x/frame-metadata.candidates.json', + }, + { + name: 'speckit spec (requirements section, bold FR label stripped to requirementId)', + doc: 'speckit/spec.md', + profile: 'speckit', + sidecar: 'speckit/spec.candidates.json', + }, + { + name: 'openspec spec (requirement statement per ### Requirement:, no scenario steps)', + doc: 'openspec/spec.md', + profile: 'openspec', + sidecar: 'openspec/spec.candidates.json', + }, + { + name: 'openspec change (requirement statement per delta block, no scenario steps)', + doc: 'openspec/change.md', + profile: 'openspec', + sidecar: 'openspec/change.candidates.json', + }, +]; + +// Zero-candidate false-positive guards. +const GUARDS: { name: string; doc: string; profile: string }[] = [ + { name: 'kiro design (no Acceptance Criteria heading)', doc: 'kiro/design.md', profile: 'kiro' }, + { name: 'speckit plan (no Requirements heading)', doc: 'speckit/plan.md', profile: 'speckit' }, + { + name: 'openspec project (no Requirement/Scenario block)', + doc: 'openspec/project.md', + profile: 'openspec', + }, +]; + +describe('extract snapshots against profile fixtures', () => { + for (const fixture of AGREEING) { + it(`matches the sidecar for ${fixture.name}`, () => { + const result = runExtract({ paths: [fixture.doc], profile: fixture.profile, json: true }); + expect(result.exitCode).toBe(0); + expect(result.response.ok).toBe(true); + const expected = sidecarCandidates(fixture.sidecar); + const actual = result.response.candidates as Record[]; + expect(withoutFile(actual)).toEqual(withoutFile(expected)); + // `file` echoes the input path, profile-relative under the fixtures cwd. + expect(actual.every((candidate) => candidate.file === fixture.doc)).toBe(true); + expect(result.response.summary).toEqual({ files: 1, candidates: expected.length }); + }); + } + + for (const guard of GUARDS) { + it(`reports zero candidates and exit 0 for ${guard.name}`, () => { + const result = runExtract({ paths: [guard.doc], profile: guard.profile, json: true }); + expect(result.exitCode).toBe(0); + expect(result.response.ok).toBe(true); + expect(result.response.candidates).toEqual([]); + expect(result.response.summary).toEqual({ files: 1, candidates: 0 }); + }); + } +}); + +describe('extract candidate projection', () => { + it('emits facade field order: file, line, col, text, profile, locatorRuleId', () => { + const result = runExtract({ paths: ['strict/valid.ears'], profile: 'strict', json: true }); + const first = JSON.stringify((result.response.candidates as unknown[])[0]); + expect(first).toBe( + '{"file":"strict/valid.ears","line":1,"col":1,' + + '"text":"The billing service shall verify the HMAC signature of every incoming webhook.",' + + '"profile":"strict","locatorRuleId":"strict.every-line"}', + ); + }); + + it('places requirementId last in the field order when the locator finds one', () => { + const result = runExtract({ + paths: ['ears-x/frame-metadata.ears'], + profile: 'ears-x', + json: true, + }); + const candidates = result.response.candidates as { requirementId?: string }[]; + expect(candidates.map((candidate) => candidate.requirementId)).toEqual([ + 'REQ-001', + 'REQ-002', + 'REQ-003', + ]); + const serialized = JSON.stringify(candidates[0]); + expect(serialized.indexOf('"locatorRuleId"')).toBeLessThan( + serialized.indexOf('"requirementId"'), + ); + }); +}); + +describe('extract envelope and exit codes', () => { + it('carries the base envelope keys in order with an empty next', () => { + const result = runExtract({ paths: ['strict/valid.ears'], profile: 'strict', json: true }); + expect(Object.keys(result.response)).toEqual([ + 'version', + 'command', + 'ok', + 'summary', + 'candidates', + 'next', + ]); + expect(result.response.command).toBe('extract'); + expect(result.response.next).toEqual([]); + }); + + it('defaults to the strict profile', () => { + const result = runExtract({ paths: ['strict/valid.ears'], json: true }); + expect(result.exitCode).toBe(0); + const candidates = result.response.candidates as { profile: string }[]; + expect(candidates.length).toBeGreaterThan(0); + expect(candidates.every((candidate) => candidate.profile === 'strict')).toBe(true); + }); + + it('serializes to valid JSON with no undefined leakage', () => { + const result = runExtract({ paths: ['strict/valid.ears'], profile: 'strict', json: true }); + const text = serialize(result.response); + expect(text).not.toContain('undefined'); + expect(() => JSON.parse(text)).not.toThrow(); + }); + + it('exits 2 with a usage error when no paths are given', () => { + try { + runExtract({ paths: [], profile: 'strict', json: true }); + throw new Error('expected a CliError'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).exitCode).toBe(2); + expect((error as CliError).diagnostic.code).toBe('extract.no_paths'); + } + }); + + it('exits 2 on an unknown profile', () => { + try { + runExtract({ paths: ['strict/valid.ears'], profile: 'nope', json: true }); + throw new Error('expected a CliError'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).exitCode).toBe(2); + expect((error as CliError).diagnostic.code).toBe('cli.unknown_profile'); + } + }); + + it('exits 2 on a missing file', () => { + try { + runExtract({ paths: ['strict/does-not-exist.ears'], profile: 'strict', json: true }); + throw new Error('expected a CliError'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).exitCode).toBe(2); + expect((error as CliError).diagnostic.code).toBe('extract.missing_file'); + } + }); +}); + +describe('extract human output', () => { + it('renders lines as file:line:col [rule] text with a summary footer', () => { + const result = runExtract({ paths: ['strict/valid.ears'], profile: 'strict' }); + const lines = result.pretty.split('\n'); + expect(lines[0]).toBe( + 'strict/valid.ears:1:1 [strict.every-line] The billing service shall verify the HMAC signature of every incoming webhook.', + ); + expect(result.pretty).toContain('7 candidates'); + }); + + it('prefixes the requirementId in human lines when present', () => { + const result = runExtract({ paths: ['ears-x/frame-metadata.ears'], profile: 'ears-x' }); + expect(result.pretty.split('\n')[0]).toContain('[ears-x.every-line] REQ-001 '); + }); + + it('omits the summary footer under --quiet but keeps the candidate lines', () => { + const result = runExtract({ paths: ['strict/valid.ears'], profile: 'strict', quiet: true }); + expect(result.pretty).not.toContain('7 candidates'); + expect(result.pretty).toContain('strict/valid.ears:1:1 [strict.every-line]'); + }); + + it('prints a friendly note for zero candidates in human mode', () => { + const result = runExtract({ paths: ['kiro/design.md'], profile: 'kiro' }); + expect(result.exitCode).toBe(0); + expect(result.pretty).toBe('No candidates found.'); + }); +}); diff --git a/packages/cli/src/commands/extract.ts b/packages/cli/src/commands/extract.ts new file mode 100644 index 0000000..9456288 --- /dev/null +++ b/packages/cli/src/commands/extract.ts @@ -0,0 +1,178 @@ +/** + * `earsyntax extract ` — print the requirement candidates the active + * profile's locator finds, with source positions and the matching locator rule. + * + * This is the debugging surface for profiles. It is stateless: no workspace, no + * manifest, no config. It never lints, so it carries no findings and never + * returns exit 1; extraction alone decides the output. Exit codes: + * + * - `0` success, including a clean run with zero candidates. + * - `2` a usage or environment failure: no paths, an unknown profile, a missing + * or unreadable file, or a malformed structured document (surfaced as an + * error-severity notice in the facade `diagnostics` channel). + * + * The command reads each file, hands the in-memory content to + * `@earsyntax/extract`'s {@link extractCandidates}, then projects each pipeline + * {@link Candidate} into the frozen facade candidate shape + * `{ file, line, col?, text, profile, locatorRuleId, requirementId? }` + * (see `docs/refactor/host-native-facade.md`). It never calls an LLM, never + * mutates or deletes any file, and keeps stdout pure JSON in `--json` mode. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs'; +import { type Candidate, resolveProfile } from '@earsyntax/core'; +import { extractCandidates, type PipelineFile } from '@earsyntax/extract'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { FacadeDiagnostic } from '../facade-types.js'; +import { usageError } from '../errors.js'; +import { resolveInput, toRelative } from '../paths.js'; +import { buildResponse } from '../response.js'; + +const GLOB_CHARS = /[*?[\]{}]/; +const STDIN = '-'; + +/** The facade candidate shape, in the frozen field order the JSON contract fixes. */ +interface FacadeCandidate { + file: string; + line: number; + col?: number; + text: string; + profile: string; + locatorRuleId: string; + requirementId?: string; +} + +/** One resolved input source: a real file, or stdin under the sentinel path `-`. */ +interface Source { + /** The path recorded on every candidate (cwd-relative POSIX, or `-`). */ + path: string; + /** The absolute path on disk, or `undefined` for stdin. */ + abs?: string; +} + +/** + * Expand the positional path arguments into ordered {@link Source}s. + * + * A literal path must exist (else exit `2`); a glob expands to its sorted + * matches; the sentinel `-` names stdin. Paths are recorded relative to the + * resolved cwd with POSIX separators so candidate `file` fields are stable. + */ +function resolveSources(cwd: string, patterns: readonly string[]): Source[] { + const sources: Source[] = []; + for (const pattern of patterns) { + if (pattern === STDIN) { + sources.push({ path: STDIN }); + continue; + } + if (GLOB_CHARS.test(pattern)) { + const matches = globSync(pattern, { cwd }); + for (const match of matches.sort((a, b) => a.localeCompare(b))) { + const abs = resolveInput(cwd, match); + sources.push({ path: toRelative(cwd, abs), abs }); + } + continue; + } + const abs = resolveInput(cwd, pattern); + if (!existsSync(abs)) { + throw usageError('extract.missing_file', `File not found: ${pattern}.`); + } + sources.push({ path: toRelative(cwd, abs), abs }); + } + return sources; +} + +/** Read a source's content, throwing a usage error (exit `2`) on an unreadable file. */ +function readSource(source: Source): string { + try { + // fd 0 is stdin; `readFileSync(0, ...)` drains it synchronously for `-`. + return source.abs === undefined ? readFileSync(0, 'utf8') : readFileSync(source.abs, 'utf8'); + } catch { + const label = source.abs === undefined ? 'standard input' : source.path; + throw usageError('extract.unreadable', `Could not read ${label}.`); + } +} + +/** Project a pipeline {@link Candidate} into the frozen facade candidate order. */ +function toFacadeCandidate(candidate: Candidate): FacadeCandidate { + return { + file: candidate.file, + line: candidate.line, + ...(candidate.col === undefined ? {} : { col: candidate.col }), + text: candidate.text, + profile: candidate.profile, + locatorRuleId: candidate.locatorRuleId, + ...(candidate.requirementId === undefined ? {} : { requirementId: candidate.requirementId }), + }; +} + +/** Render the human-readable listing: one `file:line:col [rule] text` line per candidate. */ +function renderPretty(candidates: readonly FacadeCandidate[], quiet: boolean): string { + const lines = candidates.map((candidate) => { + const position = + candidate.col === undefined + ? `${candidate.file}:${candidate.line}` + : `${candidate.file}:${candidate.line}:${candidate.col}`; + const id = candidate.requirementId === undefined ? '' : `${candidate.requirementId} `; + return `${position} [${candidate.locatorRuleId}] ${id}${candidate.text}`; + }); + if (quiet) { + return lines.join('\n'); + } + if (candidates.length === 0) { + return 'No candidates found.'; + } + lines.push(`\n${candidates.length} candidate${candidates.length === 1 ? '' : 's'}`); + return lines.join('\n'); +} + +/** + * Run the `extract` command: locate candidates and build the result. + * + * @param context The command context (parsed args, globals, cwd, emitter). + * @returns The {@link CommandResult}; the dispatcher performs the single write. + */ +export function extractCommand(context: CommandContext): CommandResult { + const patterns = context.args.positionals; + if (patterns.length === 0) { + throw usageError( + 'extract.no_paths', + 'Provide one or more files (or `-` for stdin) to extract.', + ); + } + + const resolved = resolveProfile(context.global.profile); + if (!resolved.ok) { + throw usageError('cli.unknown_profile', resolved.error.message); + } + const profile = resolved.profile; + + const sources = resolveSources(context.cwd, patterns); + const files: PipelineFile[] = sources.map((source) => ({ + path: source.path, + content: readSource(source), + })); + + const { candidates, notices } = extractCandidates({ files, profile }); + const facadeCandidates = candidates.map(toFacadeCandidate); + + // Notices are the environment/usage channel, never lint findings. An + // error-severity notice (a malformed structured document) is an environment + // failure: surface every notice in `diagnostics` and exit 2 while still + // showing whatever candidates were located. + const diagnostics: FacadeDiagnostic[] = notices.map((notice) => ({ + code: notice.code, + severity: notice.severity, + message: notice.message, + ...(notice.file === undefined ? {} : { path: notice.file }), + ...(notice.line === undefined ? {} : { line: notice.line }), + })); + const ok = !notices.some((notice) => notice.severity === 'error'); + + const summary = { files: files.length, candidates: facadeCandidates.length }; + const response = buildResponse( + { command: 'extract', ok, diagnostics, next: [] }, + { summary, candidates: facadeCandidates }, + ); + const pretty = renderPretty(facadeCandidates, context.global.quiet); + return { response, pretty, exitCode: ok ? 0 : 2 }; +} diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts new file mode 100644 index 0000000..53736c0 --- /dev/null +++ b/packages/cli/src/commands/init.test.ts @@ -0,0 +1,336 @@ +/** + * Tests for the `init` command. + * + * The logic tests drive {@link runInit} with an in-memory {@link InitDeps} so no + * disk is touched: they cover the agent x host render matrix, idempotency, + * managed-section preservation, the `--tools` deprecation, name validation, and + * the exact JSON envelope shape. A second group drives the {@link initCommand} + * handler and the dispatcher against real temp directories to prove the files + * land on disk and a second run produces byte-identical output. + */ + +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { run } from '../cli.js'; +import { AGENTS, HOSTS } from '../renderers/index.js'; +import { type InitDeps, type InitResult, runInit } from './init.js'; + +const CWD = '/repo'; + +/** An in-memory {@link InitDeps} over a path->content map. Keys are absolute. */ +function memFs(seed: Record = {}): { files: Map; deps: InitDeps } { + const files = new Map(Object.entries(seed)); + const deps: InitDeps = { + exists: (absPath) => files.has(absPath), + readFile: (absPath) => { + const value = files.get(absPath); + if (value === undefined) { + throw new Error(`no such file: ${absPath}`); + } + return value; + }, + writeFile: (absPath, content) => void files.set(absPath, content), + }; + return { files, deps }; +} + +/** The response payload keys under test, typed for convenience. */ +interface InitPayload { + ok: boolean; + root: string; + agents: string[]; + hosts: string[]; + written: string[]; + updated: string[]; + skipped: string[]; + warnings: string[]; + next: { command: string; reason: string }[]; + diagnostics?: { code: string; message: string }[]; +} + +function payload(result: InitResult): InitPayload { + const r = result.response; + return { + ok: r.ok, + root: r.root!, + agents: r.agents as string[], + hosts: r.hosts as string[], + written: r.written as string[], + updated: r.updated as string[], + skipped: r.skipped as string[], + warnings: r.warnings as string[], + next: r.next, + diagnostics: r.diagnostics, + }; +} + +describe('runInit — render matrix', () => { + it('renders the expected files for every agent alone', () => { + const expected: Record = { + claude: [ + '.claude/commands/earsyntax-author.md', + '.claude/commands/earsyntax-convert.md', + '.claude/commands/earsyntax-repair.md', + '.claude/commands/earsyntax-review.md', + ], + codex: ['AGENTS.md'], + cursor: ['.cursor/rules/earsyntax.mdc'], + copilot: ['.github/prompts/earsyntax.prompt.md'], + gemini: ['GEMINI.md'], + generic: ['AGENTS.md'], + }; + for (const agent of AGENTS) { + const { deps } = memFs(); + const result = runInit({ agents: agent, cwd: CWD }, deps); + expect(result.exitCode).toBe(0); + expect(payload(result).written).toEqual(expected[agent]); + } + }); + + it('renders the expected files for every host alone', () => { + const expected: Record = { + kiro: ['.kiro/hooks/ears-validate.yaml', '.kiro/steering/earsyntax.md'], + speckit: ['.specify/extensions/earsyntax.md'], + openspec: ['AGENTS.md'], + }; + for (const host of HOSTS) { + const { deps } = memFs(); + const result = runInit({ hosts: host, cwd: CWD }, deps); + expect(result.exitCode).toBe(0); + expect(payload(result).written).toEqual(expected[host]); + } + }); + + it('covers every agent x host pair without error', () => { + for (const agent of AGENTS) { + for (const host of HOSTS) { + const { deps } = memFs(); + const result = runInit({ agents: agent, hosts: host, cwd: CWD }, deps); + expect(result.exitCode).toBe(0); + expect(payload(result).ok).toBe(true); + expect(payload(result).written.length).toBeGreaterThan(0); + } + } + }); + + it('never writes under .earsyntax/', () => { + const { files, deps } = memFs(); + runInit({ agents: [...AGENTS].join(','), hosts: [...HOSTS].join(','), cwd: CWD }, deps); + for (const path of files.keys()) { + expect(path).not.toContain('.earsyntax'); + } + }); +}); + +describe('runInit — idempotency', () => { + it('reports every file skipped on a second identical run, byte for byte', () => { + const { files, deps } = memFs(); + const first = runInit({ agents: 'claude,codex', hosts: 'kiro,openspec', cwd: CWD }, deps); + const snapshot = new Map(files); + + const second = runInit({ agents: 'claude,codex', hosts: 'kiro,openspec', cwd: CWD }, deps); + expect(payload(second).written).toEqual([]); + expect(payload(second).updated).toEqual([]); + expect(payload(second).skipped).toEqual([...payload(first).written].sort()); + + expect(files.size).toBe(snapshot.size); + for (const [path, content] of files) { + expect(content).toBe(snapshot.get(path)); + } + }); +}); + +describe('runInit — managed sections', () => { + it('preserves pre-existing AGENTS.md content around the managed block', () => { + const seed = { [`${CWD}/AGENTS.md`]: '# House rules\n\nKeep it tidy.\n' }; + const { files, deps } = memFs(seed); + const result = runInit({ agents: 'codex', cwd: CWD }, deps); + + expect(payload(result).updated).toEqual(['AGENTS.md']); + const content = files.get(`${CWD}/AGENTS.md`) ?? ''; + expect(content.startsWith('# House rules\n\nKeep it tidy.\n')).toBe(true); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('dedupes the codex and generic agent-loop block into one section', () => { + const { files, deps } = memFs(); + runInit({ agents: 'codex,generic', cwd: CWD }, deps); + const content = files.get(`${CWD}/AGENTS.md`) ?? ''; + expect(content.match(/## earsyntax\b/g)?.length).toBe(1); + }); + + it('composes the agent-loop and openspec blocks in one AGENTS.md section', () => { + const { files, deps } = memFs(); + const result = runInit({ agents: 'codex', hosts: 'openspec', cwd: CWD }, deps); + expect(payload(result).written).toEqual(['AGENTS.md']); + const content = files.get(`${CWD}/AGENTS.md`) ?? ''; + expect(content).toContain('## earsyntax\n'); + expect(content).toContain('## earsyntax with OpenSpec'); + expect(content.match(/earsyntax:begin/g)?.length).toBe(1); + }); +}); + +describe('runInit — profile token', () => { + it('pins the profile when exactly one host is configured', () => { + const { files, deps } = memFs(); + runInit({ agents: 'cursor', hosts: 'kiro', cwd: CWD }, deps); + const content = files.get(`${CWD}/.cursor/rules/earsyntax.mdc`) ?? ''; + expect(content).toContain('--profile kiro --json'); + expect(content).not.toContain(''); + }); + + it('uses the placeholder and a note with several hosts', () => { + const { files, deps } = memFs(); + runInit({ agents: 'cursor', hosts: 'kiro,openspec', cwd: CWD }, deps); + const content = files.get(`${CWD}/.cursor/rules/earsyntax.mdc`) ?? ''; + expect(content).toContain('--profile --json'); + expect(content).toContain('kiro, openspec'); + }); +}); + +describe('runInit — thin wrappers', () => { + it('carries the loop protocol and no lifecycle verbs', () => { + const { files, deps } = memFs(); + runInit({ agents: 'claude', hosts: 'kiro', cwd: CWD }, deps); + const content = files.get(`${CWD}/.claude/commands/earsyntax-repair.md`) ?? ''; + expect(content).toContain('earsyntax instructions repair'); + expect(content).toContain('earsyntax validate'); + expect(content).toContain('Do not approve, accept, or merge.'); + for (const verb of ['plan', 'tasks', 'design', 'implement']) { + expect(new RegExp(`\\b${verb}\\b`).test(content)).toBe(false); + } + }); + + it('renders no em dashes in any wrapper', () => { + const { files, deps } = memFs(); + runInit({ agents: [...AGENTS].join(','), hosts: [...HOSTS].join(','), cwd: CWD }, deps); + for (const content of files.values()) { + expect(content.includes('—')).toBe(false); + } + }); +}); + +describe('runInit — --tools deprecation', () => { + it('maps --tools to agents and warns', () => { + const { deps } = memFs(); + const result = runInit({ tools: 'claude', cwd: CWD }, deps); + expect(result.exitCode).toBe(0); + expect(payload(result).agents).toEqual(['claude']); + expect(payload(result).warnings).toEqual([ + 'The --tools flag is deprecated; use --agent instead.', + ]); + }); + + it('folds --tools agents in beside --agent', () => { + const { deps } = memFs(); + const result = runInit({ agents: 'cursor', tools: 'claude', cwd: CWD }, deps); + expect(payload(result).agents).toEqual(['claude', 'cursor']); + expect(payload(result).warnings.length).toBe(1); + }); +}); + +describe('runInit — validation', () => { + it('exits 2 on an unknown agent', () => { + const { deps } = memFs(); + const result = runInit({ agents: 'bogus', hosts: 'kiro', cwd: CWD }, deps); + expect(result.exitCode).toBe(2); + expect(payload(result).diagnostics?.[0].code).toBe('init.unknown_agent'); + }); + + it('exits 2 on an unknown host', () => { + const { deps } = memFs(); + const result = runInit({ agents: 'claude', hosts: 'bogus', cwd: CWD }, deps); + expect(result.exitCode).toBe(2); + expect(payload(result).diagnostics?.[0].code).toBe('init.unknown_host'); + }); + + it('exits 2 when no agent or host is requested', () => { + const { deps } = memFs(); + const result = runInit({ cwd: CWD }, deps); + expect(result.exitCode).toBe(2); + expect(payload(result).diagnostics?.[0].code).toBe('init.no_targets'); + }); +}); + +describe('runInit — envelope shape', () => { + it('matches the facade key order and next actions for claude + kiro', () => { + const { deps } = memFs(); + const result = runInit({ agents: 'claude', hosts: 'kiro', cwd: CWD }, deps); + expect(Object.keys(result.response)).toEqual([ + 'version', + 'command', + 'ok', + 'root', + 'agents', + 'hosts', + 'written', + 'updated', + 'skipped', + 'warnings', + 'next', + ]); + expect(payload(result).next).toEqual([ + { + command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + reason: 'Validate kiro requirements with the kiro profile.', + }, + ]); + }); + + it('carries no next actions when only agents are requested', () => { + const { deps } = memFs(); + const result = runInit({ agents: 'claude', cwd: CWD }, deps); + expect(payload(result).next).toEqual([]); + }); +}); + +describe('initCommand and dispatcher — real disk', () => { + it('writes files and reports them skipped on a second run, byte-identical', () => { + const dir = mkdtempSync(join(tmpdir(), 'earsyntax-init-')); + const first = capture((out) => + run(['init', '--agent', 'claude', '--host', 'kiro', '--cwd', dir, '--json'], { + cwd: dir, + stdout: out, + }), + ); + expect(first.code).toBe(0); + const authored = readFileSync(resolve(dir, '.claude/commands/earsyntax-author.md'), 'utf8'); + + const second = capture((out) => + run(['init', '--agent', 'claude', '--host', 'kiro', '--cwd', dir, '--json'], { + cwd: dir, + stdout: out, + }), + ); + const body = JSON.parse(second.out) as { written: string[]; skipped: string[] }; + expect(body.written).toEqual([]); + expect(body.skipped.length).toBeGreaterThan(0); + expect(readFileSync(resolve(dir, '.claude/commands/earsyntax-author.md'), 'utf8')).toBe( + authored, + ); + }); + + it('blanks pretty output under --quiet but still writes', () => { + const dir = mkdtempSync(join(tmpdir(), 'earsyntax-init-')); + const res = capture((out) => + run(['init', '--agent', 'cursor', '--cwd', dir, '--quiet'], { cwd: dir, stdout: out }), + ); + expect(res.code).toBe(0); + expect(res.out.trim()).toBe(''); + expect(readFileSync(resolve(dir, '.cursor/rules/earsyntax.mdc'), 'utf8')).toContain( + 'earsyntax', + ); + }); +}); + +/** Run `fn` with a stdout collector, returning the exit code and captured text. */ +function capture(fn: (out: (text: string) => void) => number): { code: number; out: string } { + let out = ''; + const code = fn((text) => { + out += text; + }); + return { code, out }; +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..1ea5c3e --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,281 @@ +/** + * `earsyntax init --agent --host ` — render managed agent-wrapper + * and host-integration files. + * + * The command is pure orchestration over the pure renderers in + * `../renderers`: it resolves the requested agents and hosts, detects the repo + * root, renders every file, and writes only what changed. It is idempotent, so a + * second run with the same arguments reports every file as `skipped` and touches + * no bytes. It never creates `.earsyntax/`, never edits requirement or spec + * documents, never validates as a side effect, and never calls an LLM. + * + * `--tools` is a deprecated alias for `--agent`: it works, folds its agents into + * the agent set, and adds a warning to `warnings`. It is absent from help. + * + * Exit codes: `0` on a successful render (including an all-skipped no-op), `2` + * for a usage failure (unknown agent or host, no targets requested). Conflicts + * over already-present files are reported in `skipped` and `warnings`, never via + * a nonzero exit. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { FacadeDiagnostic, FacadeResponse, NextAction } from '../facade-types.js'; +import { detectRoot } from '../paths.js'; +import { buildResponse } from '../response.js'; +import { + AGENTS, + type Agent, + type Contribution, + HOST_VALIDATE, + HOSTS, + type Host, + renderAgent, + renderHost, + spliceManaged, +} from '../renderers/index.js'; + +/** The disk access init needs, injectable so tests stay hermetic. */ +export interface InitDeps { + exists(absPath: string): boolean; + readFile(absPath: string): string; + /** Write `content` to `absPath`, creating parent directories as needed. */ + writeFile(absPath: string, content: string): void; +} + +/** The default deps: real disk access with recursive directory creation. */ +const DEFAULT_DEPS: InitDeps = { + exists: (absPath) => existsSync(absPath), + readFile: (absPath) => readFileSync(absPath, 'utf8'), + writeFile: (absPath, content) => { + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, content, 'utf8'); + }, +}; + +/** The resolved inputs {@link runInit} works from. */ +export interface InitInputs { + /** The `--agent` value, a comma list, if present. */ + agents?: string; + /** The deprecated `--tools` value, a comma list, if present. */ + tools?: string; + /** The `--host` value, a comma list, if present. */ + hosts?: string; + /** The resolved working directory the repo root is detected from. */ + cwd: string; +} + +/** The framed outcome of an init run: response, pretty text, exit code. */ +export interface InitResult { + response: FacadeResponse; + pretty: string; + exitCode: number; +} + +/** One planned file: its absolute path, its repo-relative path, and its target content. */ +interface PlannedFile { + abs: string; + rel: string; + content: string; +} + +/** + * Parse a comma list into canonical members of `order`, deduped and returned in + * `order`'s sequence. Returns the offending token on the first unknown member. + */ +function resolveNames( + raw: string | undefined, + order: readonly T[], +): { ok: true; values: T[] } | { ok: false; unknown: string } { + if (raw === undefined) { + return { ok: true, values: [] }; + } + const requested = new Set(); + for (const token of raw.split(',')) { + const name = token.trim(); + if (name === '') { + continue; + } + if (!(order as readonly string[]).includes(name)) { + return { ok: false, unknown: name }; + } + requested.add(name); + } + return { ok: true, values: order.filter((name) => requested.has(name)) }; +} + +/** + * Run an init render and frame the result. Never throws for user error: unknown + * agent or host and an empty target set are returned in-band as exit-2 results. + */ +export function runInit(inputs: InitInputs, deps: InitDeps = DEFAULT_DEPS): InitResult { + const warnings: string[] = []; + + // `--tools` is a deprecated alias for `--agent`; fold its agents in and warn. + const agentSource = + inputs.tools === undefined + ? inputs.agents + : [inputs.agents, inputs.tools].filter((value) => value !== undefined).join(','); + if (inputs.tools !== undefined) { + warnings.push('The --tools flag is deprecated; use --agent instead.'); + } + + const agents = resolveNames(agentSource, AGENTS); + if (!agents.ok) { + return usageResult('init.unknown_agent', `Unknown agent "${agents.unknown}".`); + } + const hosts = resolveNames(inputs.hosts, HOSTS); + if (!hosts.ok) { + return usageResult('init.unknown_host', `Unknown host "${hosts.unknown}".`); + } + + if (agents.values.length === 0 && hosts.values.length === 0) { + return usageResult('init.no_targets', 'Request at least one --agent or --host to render.'); + } + + const root = detectRoot(inputs.cwd) ?? inputs.cwd; + const contributions: Contribution[] = [ + ...agents.values.map((agent: Agent) => renderAgent(agent, hosts.values)), + ...hosts.values.map((host: Host) => renderHost(host)), + ]; + + const planned = planFiles(contributions, root, deps); + return apply(planned, root, agents.values, hosts.values, warnings, deps); +} + +/** + * Turn contributions into a deterministic list of planned files. Owned files + * pass through by path (later contributions for the same path win, which does + * not happen across the built-in agents and hosts). Managed contributions are + * grouped by shared file, deduped by id, ordered by id, and spliced into the + * file's current content. + */ +function planFiles(contributions: Contribution[], root: string, deps: InitDeps): PlannedFile[] { + const owned = new Map(); + const managed = new Map>(); + + for (const contribution of contributions) { + for (const file of contribution.owned) { + owned.set(file.path, file.content); + } + for (const block of contribution.managed) { + const byId = managed.get(block.file) ?? new Map(); + byId.set(block.id, block.block); + managed.set(block.file, byId); + } + } + + const planned: PlannedFile[] = []; + for (const [rel, content] of owned) { + planned.push({ abs: resolve(root, rel), rel, content }); + } + for (const [rel, byId] of managed) { + const abs = resolve(root, rel); + const blocks = [...byId.keys()].sort().map((id) => byId.get(id) ?? ''); + const existing = deps.exists(abs) ? deps.readFile(abs) : undefined; + planned.push({ abs, rel, content: spliceManaged(existing, blocks.join('\n\n')) }); + } + return planned.sort((a, b) => a.rel.localeCompare(b.rel)); +} + +/** Write each planned file that changed and frame the response. */ +function apply( + planned: PlannedFile[], + root: string, + agents: Agent[], + hosts: Host[], + warnings: string[], + deps: InitDeps, +): InitResult { + const written: string[] = []; + const updated: string[] = []; + const skipped: string[] = []; + + for (const file of planned) { + if (!deps.exists(file.abs)) { + deps.writeFile(file.abs, file.content); + written.push(file.rel); + continue; + } + if (deps.readFile(file.abs) === file.content) { + skipped.push(file.rel); + continue; + } + deps.writeFile(file.abs, file.content); + updated.push(file.rel); + } + + const next: NextAction[] = hosts.map((host) => ({ + command: HOST_VALIDATE[host].command, + reason: `Validate ${host} requirements with the ${host} profile.`, + })); + + const response = buildResponse( + { command: 'init', ok: true, root, next }, + { + agents, + hosts, + written: written.sort(), + updated: updated.sort(), + skipped: skipped.sort(), + warnings, + }, + ); + return { response, pretty: prettyInit(root, written, updated, skipped, warnings), exitCode: 0 }; +} + +/** Pretty output: the root, then one line per file bucket, then any warnings. */ +function prettyInit( + root: string, + written: string[], + updated: string[], + skipped: string[], + warnings: string[], +): string { + const lines = [ + `root ${root}`, + `written ${written.length}, updated ${updated.length}, skipped ${skipped.length}`, + ]; + for (const rel of [...written].sort()) { + lines.push(` + ${rel}`); + } + for (const rel of [...updated].sort()) { + lines.push(` ~ ${rel}`); + } + for (const rel of [...skipped].sort()) { + lines.push(` = ${rel}`); + } + for (const warning of warnings) { + lines.push(`warning ${warning}`); + } + return lines.join('\n'); +} + +/** Build an exit-2 usage result carrying a single facade diagnostic. */ +function usageResult(code: string, message: string): InitResult { + const diagnostic: FacadeDiagnostic = { code, severity: 'error', message }; + const response = buildResponse({ + command: 'init', + ok: false, + diagnostics: [diagnostic], + next: [], + }); + return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; +} + +/** + * The `init` command handler. Reads the raw `--agent`, `--tools`, and `--host` + * values and the resolved working directory from the context, runs the render, + * and returns the {@link CommandResult}. `--quiet` blanks the pretty rendering; + * JSON output is unaffected. + */ +export function initCommand(context: CommandContext): CommandResult { + const result = runInit({ + agents: context.args.values.get('agent'), + tools: context.args.values.get('tools'), + hosts: context.args.values.get('host'), + cwd: context.cwd, + }); + return context.global.quiet && !context.global.json ? { ...result, pretty: '' } : result; +} diff --git a/packages/cli/src/commands/instructions.test.ts b/packages/cli/src/commands/instructions.test.ts new file mode 100644 index 0000000..26a00eb --- /dev/null +++ b/packages/cli/src/commands/instructions.test.ts @@ -0,0 +1,474 @@ +/** + * Tests for `earsyntax instructions `. + * + * These drive {@link instructionsCommand} directly with a hand-built + * {@link CommandContext} and assert against the returned {@link CommandResult}, + * mirroring `extract.test.ts`. Dispatcher routing (cli.ts/args.ts) is covered + * separately. + * + * The command is deterministic data: no clock, no LLM, no host-file mutation. It + * returns rules, the profile locator and dialect, an edit policy, and (for + * `repair` and `review`) the findings the validate pipeline reports. The + * repair-with-findings case seeds a broken host file in a temp directory so the + * embedded findings and per-id fix rules are real pipeline output. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { createPainter } from '../color.js'; +import type { CommandContext, CommandResult } from '../context.js'; +import { CliError } from '../errors.js'; +import { type Emitter, serialize } from '../response.js'; +import { instructionsCommand } from './instructions.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const FIXTURES = resolve(REPO_ROOT, 'fixtures', 'profiles'); + +const KIRO_DOC = 'kiro/requirements.md'; + +/** A broken Kiro host file seeded in a temp dir, for real repair/review findings. */ +const BROKEN_DOC = [ + '## Requirements', + '', + '### Requirement 1', + '', + '#### Acceptance Criteria', + '', + '1. WHEN a user submits valid credentials THE SYSTEM SHALL establish a session.', + '2. IF five sign-in attempts fail THE SYSTEM SHALL lock the account.', + '', +].join('\n'); + +let tmpDir: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'earsyntax-instr-')); + writeFileSync(join(tmpDir, 'broken.md'), BROKEN_DOC, 'utf8'); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +/** Build a context and invoke {@link instructionsCommand}, returning its result. */ +function runInstructions(opts: { + mode?: string; + file?: string; + from?: string; + profile?: string; + strict?: boolean; + json?: boolean; + quiet?: boolean; + cwd?: string; +}): CommandResult { + const values = new Map(); + if (opts.file !== undefined) { + values.set('file', opts.file); + } + if (opts.from !== undefined) { + values.set('from', opts.from); + } + const args: ParsedArgs = { + positionals: opts.mode === undefined ? [] : [opts.mode], + booleans: new Set(), + values, + }; + const global: GlobalOptions = { + json: opts.json ?? false, + sarif: false, + strict: opts.strict ?? false, + quiet: opts.quiet ?? false, + profile: opts.profile ?? 'strict', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + const context: CommandContext = { args, global, cwd: opts.cwd ?? FIXTURES, emitter }; + return instructionsCommand(context); +} + +/** Assert a thrown {@link CliError} with exit 2 and the given diagnostic code. */ +function expectUsageError(run: () => void, code: string): void { + try { + run(); + throw new Error('expected a CliError'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).exitCode).toBe(2); + expect((error as CliError).diagnostic.code).toBe(code); + } +} + +describe('instructions payload shape per mode', () => { + const WRITING_MODES = ['author', 'convert'] as const; + const FINDINGS_MODES = ['repair', 'review'] as const; + + for (const mode of [...WRITING_MODES, ...FINDINGS_MODES]) { + it(`frames ${mode} with the core payload keys, exit 0, ok true`, () => { + const file = mode === 'author' || mode === 'convert' ? KIRO_DOC : 'broken.md'; + const cwd = mode === 'author' || mode === 'convert' ? FIXTURES : tmpDir; + const result = runInstructions({ mode, file, profile: 'kiro', json: true, cwd }); + expect(result.exitCode).toBe(0); + expect(result.response.ok).toBe(true); + expect(result.response.command).toBe(`instructions ${mode}`); + expect(result.response.mode).toBe(mode); + expect(result.response.profile).toBe('kiro'); + expect(result.response.outputPolicy).toBe('edit-in-place'); + expect(result.response.editPolicy).toEqual({ editableFile: file, preserveStructure: true }); + expect(Array.isArray(result.response.rules)).toBe(true); + expect((result.response.rules as string[]).length).toBeGreaterThan(0); + }); + } + + for (const mode of WRITING_MODES) { + it(`omits findings for ${mode}`, () => { + const result = runInstructions({ mode, file: KIRO_DOC, profile: 'kiro', json: true }); + expect('findings' in result.response).toBe(false); + }); + } + + for (const mode of FINDINGS_MODES) { + it(`embeds findings for ${mode}`, () => { + const result = runInstructions({ + mode, + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + expect('findings' in result.response).toBe(true); + const findings = result.response.findings as { summary: { requirements: number } }; + expect(findings.summary.requirements).toBe(2); + }); + } + + it('carries the instructions payload keys in the frozen order (repair)', () => { + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + expect(Object.keys(result.response)).toEqual([ + 'version', + 'command', + 'ok', + 'mode', + 'file', + 'profile', + 'locator', + 'dialect', + 'rules', + 'editPolicy', + 'outputPolicy', + 'findings', + 'next', + ]); + }); + + it('places sourceFile and sourcePolicy right after profile when --from is given', () => { + const result = runInstructions({ + mode: 'author', + file: 'docs/new-requirements.md', + from: KIRO_DOC, + profile: 'kiro', + json: true, + }); + expect(Object.keys(result.response)).toEqual([ + 'version', + 'command', + 'ok', + 'mode', + 'file', + 'profile', + 'sourceFile', + 'sourcePolicy', + 'locator', + 'dialect', + 'rules', + 'editPolicy', + 'outputPolicy', + 'next', + ]); + expect(result.response.sourceFile).toBe(KIRO_DOC); + expect(result.response.sourcePolicy).toBe('read-only'); + }); +}); + +describe('instructions --from validity matrix', () => { + for (const mode of ['author', 'convert'] as const) { + it(`accepts --from for ${mode} and adds a read-the-source rule`, () => { + const result = runInstructions({ + mode, + file: 'x.md', + from: KIRO_DOC, + profile: 'kiro', + json: true, + }); + expect(result.exitCode).toBe(0); + expect(result.response.sourceFile).toBe(KIRO_DOC); + expect(result.response.editPolicy).toEqual({ editableFile: 'x.md', preserveStructure: true }); + const rules = result.response.rules as string[]; + expect(rules.some((rule) => rule.includes('source file'))).toBe(true); + expect(rules.some((rule) => rule.includes('Leave the source file unchanged.'))).toBe(true); + }); + } + + for (const mode of ['repair', 'review'] as const) { + it(`rejects --from for ${mode} with exit 2`, () => { + expectUsageError( + () => + runInstructions({ + mode, + file: 'broken.md', + from: KIRO_DOC, + profile: 'kiro', + json: true, + cwd: tmpDir, + }), + 'instructions.from_not_allowed', + ); + }); + } +}); + +describe('instructions repair embeds real findings and keyed fix rules', () => { + it('reports the seeded diagnostics and their per-id fix rules', () => { + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + const findings = result.response.findings as { + ok: boolean; + summary: { errors: number }; + diagnostics: { id: string }[]; + }; + expect(findings.ok).toBe(false); + expect(findings.summary.errors).toBe(2); + const ids = findings.diagnostics.map((diagnostic) => diagnostic.id); + expect(ids).toContain('EARS-E006'); + expect(ids).toContain('EARS-E008'); + + const rules = result.response.rules as string[]; + expect(rules.some((rule) => rule.startsWith('EARS-E006:'))).toBe(true); + expect(rules.some((rule) => rule.startsWith('EARS-E008:'))).toBe(true); + // The command never returns exit 1; only validate does. + expect(result.exitCode).toBe(0); + }); + + it('carries --strict into the findings pipeline', () => { + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + strict: true, + json: true, + cwd: tmpDir, + }); + expect('findings' in result.response).toBe(true); + }); +}); + +describe('instructions never emits accept-style language', () => { + const ACCEPT_LANGUAGE = /\b(approve|accept|merge)\b/i; + + for (const mode of ['author', 'convert', 'repair', 'review'] as const) { + it(`keeps the serialized ${mode} payload free of approve/accept/merge`, () => { + const file = mode === 'repair' || mode === 'review' ? 'broken.md' : KIRO_DOC; + const cwd = mode === 'repair' || mode === 'review' ? tmpDir : FIXTURES; + const result = runInstructions({ mode, file, profile: 'kiro', json: true, cwd }); + expect(serialize(result.response)).not.toMatch(ACCEPT_LANGUAGE); + }); + } + + it('review states the assessment is read-only and defers to a human', () => { + const result = runInstructions({ + mode: 'review', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + const rules = result.response.rules as string[]; + expect(rules.some((rule) => rule.includes('read-only'))).toBe(true); + expect(rules.some((rule) => rule.includes('leave that decision to the human'))).toBe(true); + // Review embeds findings but never edits: the edit policy still names the file + // editable only for the writing modes; review rules make no edit claim. + expect(rules.some((rule) => rule.includes('make no change to the host file'))).toBe(true); + }); +}); + +describe('instructions locator and dialect are derived from profile data', () => { + it('renders the kiro locator summary from the profile note', () => { + const result = runInstructions({ mode: 'author', file: KIRO_DOC, profile: 'kiro', json: true }); + expect(result.response.locator).toEqual({ + documentKinds: ['markdown'], + summary: + 'Bullet and numbered items under #### Acceptance Criteria headings in requirements.md.', + }); + }); + + it('renders a fallback every-line summary for strict', () => { + const result = runInstructions({ + mode: 'author', + file: 'strict/valid.ears', + profile: 'strict', + json: true, + }); + expect(result.response.locator).toEqual({ + documentKinds: ['ears', 'text'], + summary: 'Every non-empty line of ears, text files.', + }); + }); + + it('projects the dialect in the facade key order', () => { + const result = runInstructions({ mode: 'author', file: KIRO_DOC, profile: 'kiro', json: true }); + expect(Object.keys(result.response.dialect as object)).toEqual([ + 'keywordCase', + 'commaAfterLeadingClause', + 'allowLiteralSystemName', + 'allowStoryWrapper', + 'allowFrameMetadata', + 'allowProhibition', + ]); + expect(result.response.dialect).toEqual({ + keywordCase: 'case-insensitive', + commaAfterLeadingClause: 'optional', + allowLiteralSystemName: ['THE SYSTEM'], + allowStoryWrapper: true, + allowFrameMetadata: false, + allowProhibition: false, + }); + }); +}); + +describe('instructions next action', () => { + it('points at validate with the matching profile and --json', () => { + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + expect(result.response.next).toEqual([ + { + command: 'earsyntax validate broken.md --profile kiro --json', + reason: + 'Validate the host file after editing and repeat until no error-severity finding remains.', + forAgent: true, + }, + ]); + }); +}); + +describe('instructions file-existence policy by mode', () => { + it('requires the host file for repair (missing => exit 2)', () => { + expectUsageError( + () => runInstructions({ mode: 'repair', file: 'nope.md', profile: 'kiro', json: true }), + 'instructions.missing_file', + ); + }); + + it('requires the host file for review (missing => exit 2)', () => { + expectUsageError( + () => runInstructions({ mode: 'review', file: 'nope.md', profile: 'kiro', json: true }), + 'instructions.missing_file', + ); + }); + + it('requires the host file for convert without a source (missing => exit 2)', () => { + expectUsageError( + () => runInstructions({ mode: 'convert', file: 'nope.md', profile: 'kiro', json: true }), + 'instructions.missing_file', + ); + }); + + it('allows a not-yet-existing host file for author', () => { + const result = runInstructions({ + mode: 'author', + file: 'brand/new.md', + profile: 'kiro', + json: true, + }); + expect(result.exitCode).toBe(0); + expect(result.response.file).toBe('brand/new.md'); + }); + + it('allows a not-yet-existing host file for convert with a source', () => { + const result = runInstructions({ + mode: 'convert', + file: 'brand/new.md', + from: KIRO_DOC, + profile: 'kiro', + json: true, + }); + expect(result.exitCode).toBe(0); + expect(result.response.sourceFile).toBe(KIRO_DOC); + }); +}); + +describe('instructions usage and envelope errors', () => { + it('exits 2 when the mode is missing', () => { + expectUsageError( + () => runInstructions({ file: 'x.md', json: true }), + 'instructions.missing_mode', + ); + }); + + it('exits 2 on an unknown mode', () => { + expectUsageError( + () => runInstructions({ mode: 'frobnicate', file: 'x.md', json: true }), + 'instructions.unknown_mode', + ); + }); + + it('exits 2 when --file is absent', () => { + expectUsageError( + () => runInstructions({ mode: 'author', json: true }), + 'instructions.missing_file_flag', + ); + }); + + it('exits 2 on an unknown profile', () => { + expectUsageError( + () => runInstructions({ mode: 'author', file: 'x.md', profile: 'nope', json: true }), + 'cli.unknown_profile', + ); + }); +}); + +describe('instructions output purity and pretty rendering', () => { + it('serializes to valid JSON with no undefined leakage', () => { + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); + const text = serialize(result.response); + expect(text).not.toContain('undefined'); + expect(() => JSON.parse(text)).not.toThrow(); + }); + + it('renders a human summary in pretty mode and blanks it under --quiet', () => { + const pretty = runInstructions({ mode: 'author', file: KIRO_DOC, profile: 'kiro' }); + expect(pretty.pretty).toContain('instructions author for kiro/requirements.md (profile kiro)'); + expect(pretty.pretty).toContain('locator:'); + + const quiet = runInstructions({ mode: 'author', file: KIRO_DOC, profile: 'kiro', quiet: true }); + expect(quiet.pretty).toBe(''); + }); +}); diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts new file mode 100644 index 0000000..8a89f2a --- /dev/null +++ b/packages/cli/src/commands/instructions.ts @@ -0,0 +1,248 @@ +/** + * `earsyntax instructions --file [--from ]` + * — return the deterministic rules an agent follows for one loop step against a + * host file. + * + * The command is read-only. It returns the rules, the active profile locator and + * dialect, an edit policy, and (for `repair` and `review`) the findings the + * validate pipeline reports on `--file`. It never edits the host file, never + * converts content semantically, never reads or transforms a `--from` source, + * and never calls an LLM. The instruction body never tells an agent to approve, + * accept, or merge, and never references a workspace, work item, or manifest. + * + * Modes: + * - `author`: write new EARS requirements into the host file requirements region. + * - `convert`: rewrite natural-language requirements already in the host file. + * - `repair`: fix the reported findings; the findings are embedded. + * - `review`: read-only assessment; the findings are embedded, no edits. + * + * `--from ` is valid only with `author` and `convert`; it names an input + * spec the agent reads while writing EARS into `--file`. The CLI only points at + * it. + * + * Exit codes: `0` on success (this command never returns `1`; only `validate` + * does), `2` on a usage or environment failure (unknown or missing mode, missing + * `--file` flag, unknown profile, `--from` on a non-author/convert mode, or a + * required `--file`/convert source that is missing or unreadable). + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { isAbsolute, relative, sep } from 'node:path'; +import { type Profile, resolveProfile } from '@earsyntax/core'; +import { type PipelineFile, runPipeline } from '@earsyntax/extract'; +import { canonicalizeFindings, type Findings } from '@earsyntax/cli-contract'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { NextAction } from '../facade-types.js'; +import { usageError } from '../errors.js'; +import { resolveInput } from '../paths.js'; +import { buildResponse } from '../response.js'; +import { + buildDialect, + buildLocator, + buildRules, + type DialectPayload, + type InstructionMode, + isInstructionMode, + type LocatorPayload, + modeAcceptsSource, +} from '../rules.js'; + +/** The edit policy: which file the agent may change, and that structure is preserved. */ +interface EditPolicy { + editableFile: string; + preserveStructure: true; +} + +/** The output policy is always in-place editing of the host file. */ +const OUTPUT_POLICY = 'edit-in-place'; + +/** + * The path recorded on the payload and findings for a file. Absolute when the + * caller passed an absolute path, otherwise a cwd-relative POSIX path, matching + * the `validate` command so a follow-up validate targets the same string. + */ +function displayPath(userPath: string, cwd: string): string { + if (isAbsolute(userPath)) { + return userPath; + } + const abs = resolveInput(cwd, userPath); + return relative(cwd, abs).split(sep).join('/'); +} + +/** Resolve the mode positional, or throw an exit-2 usage error. */ +function resolveMode(positionals: string[]): InstructionMode { + const raw = positionals.at(0); + if (raw === undefined) { + throw usageError( + 'instructions.missing_mode', + 'Provide a mode: author, convert, repair, or review.', + ); + } + if (!isInstructionMode(raw)) { + throw usageError( + 'instructions.unknown_mode', + `Unknown mode "${raw}". Use author, convert, repair, or review.`, + ); + } + return raw; +} + +/** Read a required host file for a mode that needs its content, or throw exit 2. */ +function readHostFile(userPath: string, cwd: string, code: string): string { + const abs = resolveInput(cwd, userPath); + if (!existsSync(abs)) { + throw usageError(code, `File not found: ${userPath}.`, { path: userPath }); + } + try { + return readFileSync(abs, 'utf8'); + } catch { + throw usageError('instructions.unreadable', `Could not read file: ${userPath}.`, { + path: userPath, + }); + } +} + +/** Run the validate pipeline on the host file and return the canonicalized findings. */ +function collectFindings( + displayFile: string, + content: string, + profile: Profile, + strict: boolean, +): Findings { + const file: PipelineFile = { path: displayFile, content }; + const { findings } = runPipeline({ files: [file], profile, strict }); + return canonicalizeFindings(findings); +} + +/** The `next` action: re-run validation on the same file with the matching profile. */ +function buildNext(displayFile: string, profile: Profile, mode: InstructionMode): NextAction[] { + const reason = + mode === 'review' + ? 'Reproduce the validation status this review reports.' + : 'Validate the host file after editing and repeat until no error-severity finding remains.'; + return [ + { + command: `earsyntax validate ${displayFile} --profile ${profile.name} --json`, + reason, + forAgent: true, + }, + ]; +} + +/** Render the human-readable instruction summary. */ +function renderPretty(payload: { + mode: InstructionMode; + file: string; + profileName: string; + sourceFile?: string; + locator: LocatorPayload; + rules: string[]; + findings?: Findings; + next: NextAction[]; +}): string { + const lines: string[] = []; + lines.push(`instructions ${payload.mode} for ${payload.file} (profile ${payload.profileName})`); + if (payload.sourceFile !== undefined) { + lines.push(`source (read-only): ${payload.sourceFile}`); + } + lines.push(`locator: ${payload.locator.summary}`); + lines.push('rules:'); + for (const rule of payload.rules) { + lines.push(` - ${rule}`); + } + if (payload.findings !== undefined) { + const { requirements, errors, warnings } = payload.findings.summary; + lines.push( + `findings: ${requirements} requirement(s), ${errors} error(s), ${warnings} warning(s)`, + ); + } + const next = payload.next.at(0); + if (next !== undefined) { + lines.push(`next: ${next.command}`); + } + return lines.join('\n'); +} + +/** + * Run the `instructions` command: validate flags, gather findings for the + * findings-bearing modes, assemble the payload, and frame the result. + * + * @param context The command context (parsed args, globals, cwd, emitter). + * @returns The {@link CommandResult}; the dispatcher performs the single write. + */ +export function instructionsCommand(context: CommandContext): CommandResult { + const { args, global, cwd } = context; + const mode = resolveMode(args.positionals); + + const fileArg = args.values.get('file'); + if (fileArg === undefined) { + throw usageError('instructions.missing_file_flag', 'The --file option is required.'); + } + + const sourceArg = args.values.get('from'); + if (sourceArg !== undefined && !modeAcceptsSource(mode)) { + throw usageError( + 'instructions.from_not_allowed', + `The --from source is only valid with author and convert, not ${mode}.`, + ); + } + + const resolved = resolveProfile(global.profile); + if (!resolved.ok) { + throw usageError('cli.unknown_profile', resolved.error.message); + } + const profile = resolved.profile; + + const file = displayPath(fileArg, cwd); + const sourceFile = sourceArg === undefined ? undefined : displayPath(sourceArg, cwd); + + // File-existence policy by mode. `repair` and `review` read the host file to + // run the pipeline, so it must exist. `convert` without a source transforms + // content already in the host file, so it must exist too. `author`, and any + // mode given a `--from` source, may target a host file that does not exist + // yet; the rules tell the agent to create the requirements region. + let findings: Findings | undefined; + if (mode === 'repair' || mode === 'review') { + const content = readHostFile(fileArg, cwd, 'instructions.missing_file'); + findings = collectFindings(file, content, profile, global.strict); + } else if (mode === 'convert' && sourceArg === undefined) { + readHostFile(fileArg, cwd, 'instructions.missing_file'); + } + + const locator = buildLocator(profile); + const dialect: DialectPayload = buildDialect(profile); + const rules = buildRules({ mode, hasSource: sourceArg !== undefined, findings }); + const editPolicy: EditPolicy = { editableFile: file, preserveStructure: true }; + const next = buildNext(file, profile, mode); + + const response = buildResponse( + { command: `instructions ${mode}`, ok: true, next }, + { + mode, + file, + profile: profile.name, + ...(sourceFile === undefined ? {} : { sourceFile, sourcePolicy: 'read-only' }), + locator, + dialect, + rules, + editPolicy, + outputPolicy: OUTPUT_POLICY, + ...(findings === undefined ? {} : { findings }), + }, + ); + + const pretty = renderPretty({ + mode, + file, + profileName: profile.name, + sourceFile, + locator, + rules, + findings, + next, + }); + + return global.quiet && !global.json + ? { response, pretty: '', exitCode: 0 } + : { response, pretty, exitCode: 0 }; +} diff --git a/packages/cli/src/commands/profiles.test.ts b/packages/cli/src/commands/profiles.test.ts new file mode 100644 index 0000000..3ff527c --- /dev/null +++ b/packages/cli/src/commands/profiles.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the `profiles` command. + * + * The command is a pure projection of `@earsyntax/core`'s + * {@link summarizeProfiles}, so the drift-proof assertions compare the response + * `profiles` payload against that function's output directly rather than against + * string literals: if the profile data changes, the command and the assertion + * move together and no per-profile prose can rot in the CLI. A second group + * covers the envelope shape, purity, exit code, and the fixed render order, and + * a third drives {@link profilesCommand} through the CLI dispatcher end to end. + */ + +import { BUILTIN_PROFILE_NAMES, summarizeProfiles } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { run } from '../cli.js'; +import { createPainter } from '../color.js'; +import type { CommandContext } from '../context.js'; +import type { Emitter } from '../response.js'; +import { profilesCommand } from './profiles.js'; + +/** Build a bare {@link CommandContext} for the handler; it reads no args or flags. */ +function makeContext( + options: { json?: boolean; quiet?: boolean; profile?: string } = {}, +): CommandContext { + const args: ParsedArgs = { positionals: [], booleans: new Set(), values: new Map() }; + const global: GlobalOptions = { + json: options.json ?? false, + sarif: false, + strict: false, + quiet: options.quiet ?? false, + profile: options.profile ?? 'strict', + cwd: '/work', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + return { args, global, cwd: '/work', emitter }; +} + +describe('profilesCommand — payload is a drift-proof projection of profile data', () => { + it('emits exactly summarizeProfiles() in the profiles payload', () => { + const result = profilesCommand(makeContext({ json: true })); + // Compared against the core function, not literals: the assertion cannot + // drift from the profile data because both sides read the same source. + expect(result.response.profiles).toEqual(summarizeProfiles()); + }); + + it('lists all five built-ins in the frozen render order', () => { + const result = profilesCommand(makeContext({ json: true })); + const names = (result.response.profiles as { name: string }[]).map((p) => p.name); + expect(names).toEqual([...BUILTIN_PROFILE_NAMES]); + expect(names).toEqual(['strict', 'ears-x', 'kiro', 'speckit', 'openspec']); + }); + + it('carries the ProfileSummary keys and nothing else per entry', () => { + const result = profilesCommand(makeContext({ json: true })); + for (const profile of result.response.profiles as Record[]) { + expect(Object.keys(profile).sort()).toEqual( + ['adds', 'locates', 'name', 'relaxes', 'severityOverrides'].sort(), + ); + } + }); + + it('shows strict as the baseline: empty relaxes, adds, and overrides', () => { + const result = profilesCommand(makeContext({ json: true })); + const strict = (result.response.profiles as ProfileSummary[]).find((p) => p.name === 'strict'); + expect(strict?.relaxes).toEqual([]); + expect(strict?.adds).toEqual([]); + expect(strict?.severityOverrides).toEqual({}); + }); +}); + +interface ProfileSummary { + name: string; + locates: string; + relaxes: string[]; + adds: string[]; + severityOverrides: Record; +} + +describe('profilesCommand — envelope, purity, and exit code', () => { + it('returns a well-formed envelope: ok true, empty next, exit 0', () => { + const result = profilesCommand(makeContext({ json: true })); + expect(result.response.command).toBe('profiles'); + expect(result.response.ok).toBe(true); + expect(result.response.next).toEqual([]); + expect(result.exitCode).toBe(0); + expect(result.response.diagnostics).toBeUndefined(); + }); + + it('is pure JSON: the payload round-trips with no undefined or functions', () => { + const result = profilesCommand(makeContext({ json: true })); + const roundTripped = JSON.parse(JSON.stringify(result.response)); + expect(roundTripped.profiles).toEqual(summarizeProfiles()); + }); + + it('ignores the global --profile value: output does not depend on it', () => { + const withStrict = profilesCommand(makeContext({ json: true, profile: 'strict' })); + const withKiro = profilesCommand(makeContext({ json: true, profile: 'kiro' })); + expect(withStrict.response.profiles).toEqual(withKiro.response.profiles); + }); +}); + +describe('profilesCommand — pretty rendering derives from the same data', () => { + it('names every profile and never invents prose beyond the data', () => { + const result = profilesCommand(makeContext()); + for (const profile of summarizeProfiles()) { + expect(result.pretty).toContain(profile.name); + expect(result.pretty).toContain(profile.locates); + for (const relaxed of profile.relaxes) { + expect(result.pretty).toContain(relaxed); + } + for (const added of profile.adds) { + expect(result.pretty).toContain(added); + } + } + }); + + it('renders severity overrides as id=level pairs', () => { + const result = profilesCommand(makeContext()); + const kiro = summarizeProfiles().find((p) => p.name === 'kiro'); + for (const [id, level] of Object.entries(kiro?.severityOverrides ?? {})) { + expect(result.pretty).toContain(`${id}=${level}`); + } + }); +}); + +describe('profilesCommand — --quiet suppresses pretty output', () => { + it('empties pretty under --quiet without --json', () => { + const result = profilesCommand(makeContext({ quiet: true })); + expect(result.pretty).toBe(''); + expect(result.exitCode).toBe(0); + }); + + it('leaves the JSON payload untouched when --quiet is combined with --json', () => { + const result = profilesCommand(makeContext({ json: true, quiet: true })); + expect(result.response.profiles).toEqual(summarizeProfiles()); + }); +}); + +describe('profiles — through the dispatcher', () => { + function capture(argv: string[]): { out: string; code: number } { + let out = ''; + const code = run(argv, { stdout: (text) => (out += text), cwd: '/work' }); + return { out, code }; + } + + it('exits 0 and emits the JSON envelope under --json', () => { + const { out, code } = capture(['profiles', '--json']); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.command).toBe('profiles'); + expect(parsed.profiles).toEqual(summarizeProfiles()); + }); + + it('exits 0 and emits pretty text without --json', () => { + const { out, code } = capture(['profiles']); + expect(code).toBe(0); + expect(out).toContain('strict'); + expect(out).toContain('openspec'); + expect(() => JSON.parse(out)).toThrow(); + }); + + it('rejects an unknown flag with exit 2', () => { + const { out, code } = capture(['profiles', '--bogus']); + expect(code).toBe(2); + expect(out).toContain('cli.unknown_flag'); + }); + + it('rejects the misplaced --profile flag with exit 2 and does not distort output', () => { + const { out, code } = capture(['profiles', '--profile', 'kiro']); + expect(code).toBe(2); + expect(out).toContain('cli.flag_not_allowed'); + }); + + it('emits nothing under --quiet without --json', () => { + const { out, code } = capture(['profiles', '--quiet']); + expect(code).toBe(0); + expect(out).toBe('\n'); + }); + + it('leaves --json output unaffected by --quiet', () => { + const { out, code } = capture(['profiles', '--json', '--quiet']); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.profiles).toEqual(summarizeProfiles()); + }); +}); diff --git a/packages/cli/src/commands/profiles.ts b/packages/cli/src/commands/profiles.ts new file mode 100644 index 0000000..13e16bb --- /dev/null +++ b/packages/cli/src/commands/profiles.ts @@ -0,0 +1,53 @@ +/** + * `earsyntax profiles` — list the built-in profiles. + * + * The five built-ins are rendered straight from {@link summarizeProfiles} in + * `@earsyntax/core`, which derives each profile's locator summary, relaxations, + * additions, and severity overrides from the profile data objects. There is no + * hand-written per-profile prose in this module: both the JSON payload and the + * pretty text are projections of the same `ProfileDiff[]`, so nothing here can + * drift from the profile data. `strict` is the baseline, so it carries empty + * `relaxes`, `adds`, and `severityOverrides`. + * + * The `profiles` key of the `--json` envelope is exactly the `ProfileDiff[]` + * array in the frozen order `strict`, `ears-x`, `kiro`, `speckit`, `openspec`. + * The command resolves no repo and never fails: exit `0`. `--quiet` suppresses + * the pretty text like every other command; it has no effect on `--json`. + */ + +import { type ProfileDiff, summarizeProfiles } from '@earsyntax/core'; +import type { CommandContext, CommandResult } from '../context.js'; +import { buildResponse } from '../response.js'; + +/** Render one profile diff as a compact pretty block, labels apart from data. */ +function prettyProfile(diff: ProfileDiff): string { + const lines = [diff.name, ` locates: ${diff.locates}`]; + if (diff.relaxes.length > 0) { + lines.push(` relaxes: ${diff.relaxes.join(', ')}`); + } + if (diff.adds.length > 0) { + lines.push(` adds: ${diff.adds.join(', ')}`); + } + const overrides = Object.entries(diff.severityOverrides); + if (overrides.length > 0) { + lines.push(` severity: ${overrides.map(([id, level]) => `${id}=${level}`).join(', ')}`); + } + if (diff.relaxes.length === 0 && diff.adds.length === 0 && overrides.length === 0) { + lines.push(' no differences from strict.'); + } + return lines.join('\n'); +} + +/** + * The `profiles` command handler. Lists the built-in profiles from profile data + * and returns the {@link CommandResult}; the dispatcher performs the single + * write. Takes no positionals or flags beyond the universal set, resolves no + * repo, and always exits `0`. + */ +export function profilesCommand(context: CommandContext): CommandResult { + const profiles = summarizeProfiles(); + const response = buildResponse({ command: 'profiles', ok: true, next: [] }, { profiles }); + const pretty = profiles.map(prettyProfile).join('\n\n'); + const result = { response, pretty, exitCode: 0 }; + return context.global.quiet && !context.global.json ? { ...result, pretty: '' } : result; +} diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts new file mode 100644 index 0000000..8eaa252 --- /dev/null +++ b/packages/cli/src/commands/validate.test.ts @@ -0,0 +1,457 @@ +/** + * Tests for the stateless `validate` command. + * + * The logic tests drive {@link runValidate} with injected {@link ValidateDeps} + * so no disk or stdin is touched: they cover the profile, input-resolution, and + * findings framing. A second group drives the {@link validateCommand} handler + * against real temp files and repo fixtures, covering emission (`--json`, + * `--quiet`, pretty), the exit-code matrix end to end, and the kiro/strict + * profile split. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { createPainter } from '../color.js'; +import type { CommandContext } from '../context.js'; +import type { Emitter } from '../response.js'; +import { runValidate, type ValidateDeps, validateCommand } from './validate.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const KIRO_REQUIREMENTS = 'fixtures/profiles/kiro/requirements.md'; + +const CWD = '/work'; + +const CLEAN_LINE = 'When the user clicks the button, the system shall do it.\n'; +const WARN_LINE = 'When the user submits the form, the system shall handle it appropriately.\n'; +const KIRO_LINES = [ + 'WHEN a user submits valid credentials THE SYSTEM SHALL establish an authenticated session.', + 'THE SYSTEM SHALL persist the working draft every thirty seconds.', + '', +].join('\n'); + +/** A hermetic {@link ValidateDeps} over an in-memory file map. Keys are absolute. */ +function fakeDeps( + files: Record, + overrides: Partial = {}, +): ValidateDeps { + return { + exists: (absPath) => Object.hasOwn(files, absPath), + readFile: (absPath) => { + if (!Object.hasOwn(files, absPath)) { + throw new Error(`no such file: ${absPath}`); + } + return files[absPath]; + }, + glob: () => [], + readStdin: () => '', + ...overrides, + }; +} + +/** Run `validate` over a single in-memory file under a profile. */ +function runOne( + name: string, + content: string, + profileName = 'strict', + strict = false, +): ReturnType { + return runValidate( + { paths: [name], profileName, strict, sarif: false, json: true, cwd: CWD }, + fakeDeps({ [`${CWD}/${name}`]: content }), + ); +} + +describe('runValidate — inputs and exit codes', () => { + it('exits 0 on a clean file with no findings', () => { + const r = runOne('clean.ears', CLEAN_LINE); + expect(r.exitCode).toBe(0); + expect(r.response.ok).toBe(true); + const findings = r.response.findings as { summary: { errors: number } }; + expect(findings.summary.errors).toBe(0); + }); + + it('exits 1 on error-severity findings', () => { + const r = runOne('bad.ears', KIRO_LINES); + expect(r.exitCode).toBe(1); + expect(r.response.ok).toBe(false); + const findings = r.response.findings as { diagnostics: { id: string }[] }; + const ids = new Set(findings.diagnostics.map((d) => d.id)); + expect(ids.has('EARS-E014')).toBe(true); + expect(ids.has('EARS-E015')).toBe(true); + }); + + it('--strict flips a warning-only run from exit 0 to exit 1', () => { + const lenient = runOne('warn.ears', WARN_LINE, 'strict', false); + expect(lenient.exitCode).toBe(0); + expect((lenient.response.findings as { summary: { warnings: number } }).summary.warnings).toBe( + 1, + ); + + const strict = runOne('warn.ears', WARN_LINE, 'strict', true); + expect(strict.exitCode).toBe(1); + const findings = strict.response.findings as { + summary: { errors: number; warnings: number }; + diagnostics: { id: string; severity: string }[]; + }; + expect(findings.summary.errors).toBe(1); + expect(findings.summary.warnings).toBe(0); + // The W-band id is preserved; only the effective severity is upgraded. + expect(findings.diagnostics[0]?.id).toBe('EARS-W016'); + expect(findings.diagnostics[0]?.severity).toBe('error'); + }); + + it('aggregates multiple files into one Findings with the right file count', () => { + const r = runValidate( + { + paths: ['a.ears', 'b.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps({ [`${CWD}/a.ears`]: CLEAN_LINE, [`${CWD}/b.ears`]: KIRO_LINES }), + ); + expect(r.exitCode).toBe(1); + const findings = r.response.findings as { summary: { files: number } }; + expect(findings.summary.files).toBe(2); + }); + + it('expands a glob pattern in sorted order', () => { + const r = runValidate( + { + paths: ['*.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps( + { [`${CWD}/a.ears`]: CLEAN_LINE, [`${CWD}/b.ears`]: CLEAN_LINE }, + { glob: () => ['b.ears', 'a.ears'] }, + ), + ); + expect(r.exitCode).toBe(0); + const findings = r.response.findings as { summary: { files: number } }; + expect(findings.summary.files).toBe(2); + }); + + it('reads stdin as one document via -', () => { + const r = runValidate( + { paths: ['-'], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + fakeDeps({}, { readStdin: () => CLEAN_LINE }), + ); + expect(r.exitCode).toBe(0); + const findings = r.response.findings as { summary: { files: number; requirements: number } }; + expect(findings.summary.files).toBe(1); + expect(findings.summary.requirements).toBe(1); + const diag = r.response.findings as { diagnostics: { file: string }[] }; + expect(diag.diagnostics).toEqual([]); + }); + + it('rejects reading stdin twice', () => { + const r = runValidate( + { + paths: ['-', '-'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps({}, { readStdin: () => CLEAN_LINE }), + ); + expect(r.exitCode).toBe(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe( + 'validate.duplicate_stdin', + ); + }); + + it('exits 2 with a typed diagnostic on an unknown profile', () => { + const r = runOne('clean.ears', CLEAN_LINE, 'nope'); + expect(r.exitCode).toBe(2); + expect(r.response.ok).toBe(false); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('cli.unknown_profile'); + expect(r.response.findings).toBeUndefined(); + }); + + it('exits 2 on a missing file (an environment failure, not a finding)', () => { + const r = runValidate( + { + paths: ['ghost.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps({}), + ); + expect(r.exitCode).toBe(2); + const diag = (r.response.diagnostics as { code: string; path?: string }[])[0]; + expect(diag.code).toBe('validate.missing_file'); + expect(diag.path).toBe('ghost.ears'); + }); + + it('exits 2 on an unreadable file', () => { + const r = runValidate( + { + paths: ['locked.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps( + { [`${CWD}/locked.ears`]: '' }, + { + exists: () => true, + readFile: () => { + throw new Error('EACCES'); + }, + }, + ), + ); + expect(r.exitCode).toBe(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.unreadable'); + }); + + it('exits 2 when no paths are given', () => { + const r = runValidate( + { paths: [], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + fakeDeps({}), + ); + expect(r.exitCode).toBe(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.no_files'); + }); + + it('exits 2 when a glob matches nothing', () => { + const r = runValidate( + { + paths: ['*.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, + fakeDeps({}, { glob: () => [] }), + ); + expect(r.exitCode).toBe(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.no_files'); + }); + + it('emits a valid, empty SARIF log as raw stdout on a clean run (exit 0)', () => { + const r = runValidate( + { + paths: ['clean.ears'], + profileName: 'strict', + strict: false, + sarif: true, + json: false, + cwd: CWD, + }, + fakeDeps({ [`${CWD}/clean.ears`]: CLEAN_LINE }), + ); + expect(r.exitCode).toBe(0); + const log = JSON.parse(r.raw ?? '') as { + version: string; + runs: { results: unknown[]; tool: { driver: { name: string; rules: unknown[] } } }[]; + }; + expect(log.version).toBe('2.1.0'); + expect(log.runs[0]?.tool.driver.name).toBe('earsyntax'); + expect(log.runs[0]?.results).toEqual([]); + expect(log.runs[0]?.tool.driver.rules).toEqual([]); + }); + + it('emits SARIF results with EARS ids, levels, and positions on a failing run (exit 1)', () => { + const r = runValidate( + { + paths: ['bad.ears'], + profileName: 'strict', + strict: false, + sarif: true, + json: false, + cwd: CWD, + }, + fakeDeps({ [`${CWD}/bad.ears`]: 'The system resets the timer.\n' }), + ); + expect(r.exitCode).toBe(1); + const log = JSON.parse(r.raw ?? '') as { + runs: { + results: { + ruleId: string; + ruleIndex: number; + level: string; + locations: { + physicalLocation: { artifactLocation: { uri: string }; region: { startLine: number } }; + }[]; + }[]; + tool: { driver: { rules: { id: string }[] } }; + }[]; + }; + const results = log.runs[0]?.results ?? []; + expect(results.length).toBeGreaterThan(0); + const missingShall = results.find((result) => result.ruleId === 'EARS-E007'); + expect(missingShall).toBeDefined(); + expect(missingShall?.level).toBe('error'); + expect(missingShall?.locations[0]?.physicalLocation.artifactLocation.uri).toBe('bad.ears'); + expect(missingShall?.locations[0]?.physicalLocation.region.startLine).toBe(1); + // The referenced rule id is declared on the driver. + expect(log.runs[0]?.tool.driver.rules.map((rule) => rule.id)).toContain('EARS-E007'); + }); + + it('rejects --json --sarif together as a flag conflict', () => { + const r = runValidate( + { + paths: ['clean.ears'], + profileName: 'strict', + strict: false, + sarif: true, + json: true, + cwd: CWD, + }, + fakeDeps({ [`${CWD}/clean.ears`]: CLEAN_LINE }), + ); + expect(r.exitCode).toBe(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('cli.exclusive_flags'); + }); +}); + +describe('runValidate — JSON envelope shape', () => { + it('emits exactly the frozen envelope and findings key order', () => { + const r = runOne('bad.ears', KIRO_LINES); + expect(Object.keys(r.response)).toEqual(['version', 'command', 'ok', 'findings', 'next']); + expect(r.response.command).toBe('validate'); + const findings = r.response.findings as Record; + expect(Object.keys(findings)).toEqual(['ok', 'summary', 'diagnostics']); + expect(Object.keys(findings.summary as Record)).toEqual([ + 'files', + 'requirements', + 'valid', + 'errors', + 'warnings', + ]); + const first = (findings.diagnostics as Record[])[0]; + // Optional keys included only when present, always in contract order. + expect(Object.keys(first).slice(0, 4)).toEqual(['id', 'severity', 'file', 'line']); + }); + + it('adds a repair next action pointing at the first errored file', () => { + const r = runOne('bad.ears', KIRO_LINES, 'strict'); + const next = r.response.next; + expect(next).toHaveLength(1); + expect(next[0]?.command).toBe( + 'earsyntax instructions repair --file bad.ears --profile strict --json', + ); + expect(next[0]?.forAgent).toBe(true); + }); + + it('carries an empty next on a clean run', () => { + const r = runOne('clean.ears', CLEAN_LINE); + expect(r.response.next).toEqual([]); + }); +}); + +/** + * Build a {@link CommandContext} for the handler. Like extract's tests, this + * drives {@link validateCommand} directly and asserts against the returned + * {@link CommandResult}; the handler reads resolved globals, never args, and + * never writes (the dispatcher owns the single write), so no output capture is + * needed. + */ +function makeContext( + positionals: string[], + options: { profile?: string; json?: boolean; quiet?: boolean; strict?: boolean; cwd: string }, +): CommandContext { + const args: ParsedArgs = { positionals, booleans: new Set(), values: new Map() }; + const global: GlobalOptions = { + json: options.json ?? false, + sarif: false, + strict: options.strict ?? false, + quiet: options.quiet ?? false, + profile: options.profile ?? 'strict', + cwd: options.cwd, + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + return { args, global, cwd: options.cwd, emitter }; +} + +describe('validateCommand — handler and emission', () => { + function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'earsyntax-validate-')); + } + + it('frames a JSON-mode run as a clean validate envelope', () => { + const cwd = tempDir(); + writeFileSync(join(cwd, 'clean.ears'), CLEAN_LINE); + const result = validateCommand(makeContext(['clean.ears'], { json: true, cwd })); + expect(result.exitCode).toBe(0); + expect(result.response.command).toBe('validate'); + expect(result.response.ok).toBe(true); + }); + + it('honors --quiet by blanking the pretty rendering but keeping the exit code', () => { + const cwd = tempDir(); + writeFileSync(join(cwd, 'bad.ears'), KIRO_LINES); + const result = validateCommand(makeContext(['bad.ears'], { quiet: true, cwd })); + expect(result.exitCode).toBe(1); + expect(result.pretty).toBe(''); + }); + + it('prints one line per finding plus a summary in pretty mode', () => { + const cwd = tempDir(); + writeFileSync(join(cwd, 'bad.ears'), KIRO_LINES); + const result = validateCommand(makeContext(['bad.ears'], { cwd })); + expect(result.exitCode).toBe(1); + const text = result.pretty; + expect(text).toContain('bad.ears:1'); + expect(text).toContain('EARS-E014'); + expect(text).toMatch(/valid across 1 file\(s\)/); + }); +}); + +describe('validateCommand — kiro and strict profiles', () => { + it('validates the kiro requirements fixture clean under the kiro profile', () => { + const result = validateCommand( + makeContext([KIRO_REQUIREMENTS], { profile: 'kiro', json: true, cwd: REPO_ROOT }), + ); + expect(result.exitCode).toBe(0); + }); + + it('locates nothing in a markdown fixture under strict (strict is markdown-blind)', () => { + // strict's locator covers only .ears/plain text, so a .md yields zero + // candidates and a clean exit. Failing the kiro house style under strict is + // exercised via .ears content below, not via this markdown file. + const result = validateCommand( + makeContext([KIRO_REQUIREMENTS], { profile: 'strict', json: true, cwd: REPO_ROOT }), + ); + expect(result.exitCode).toBe(0); + const findings = result.response.findings as { summary: { requirements: number } }; + expect(findings.summary.requirements).toBe(0); + }); + + it('fails the kiro house style under strict when the lines are plain .ears', () => { + const cwd = mkdtempSync(join(tmpdir(), 'earsyntax-validate-')); + writeFileSync(join(cwd, 'criteria.ears'), KIRO_LINES); + const result = validateCommand( + makeContext(['criteria.ears'], { profile: 'strict', json: true, cwd }), + ); + expect(result.exitCode).toBe(1); + const findings = result.response.findings as { diagnostics: { id: string }[] }; + const ids = new Set(findings.diagnostics.map((d) => d.id)); + expect(ids.has('EARS-E014')).toBe(true); + expect(ids.has('EARS-E015')).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..89dedcb --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,387 @@ +/** + * `earsyntax validate ` — stateless EARS validation. + * + * Locate, extract, parse, and lint EARS in the given files (or stdin `-`) under + * the active profile, and return the frozen Findings model. This command is + * stateless: it works in any directory, knows nothing about a `.earsyntax/` + * workspace, records no manifest, and never edits source. It is the only command + * that returns exit 1 (an error-severity finding). + * + * The heavy lifting lives in `@earsyntax/extract`'s {@link runPipeline}; this + * module only resolves flags, reads inputs, maps pipeline notices onto the + * facade-level `diagnostics` channel, and frames the response. Extraction is not + * reimplemented here. + * + * Exit codes: `0` no error findings, `1` at least one error finding, `2` usage + * or environment failure (unknown profile, missing/unreadable path, bad flag + * combination). A missing file is an environment failure (exit 2), never a lint + * finding. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs'; +import { isAbsolute, relative, sep } from 'node:path'; +import { type Profile, resolveProfile } from '@earsyntax/core'; +import { + type DocumentKind, + inferKind, + type PipelineFile, + type PipelineNotice, + runPipeline, +} from '@earsyntax/extract'; +import { + buildSarifLog, + canonicalizeFindings, + EXIT_USAGE, + exitCodeForFindings, + type Findings, + serializeSarifLog, +} from '@earsyntax/cli-contract'; +import type { CommandContext, CommandResult } from '../context.js'; +import type { FacadeDiagnostic, FacadeResponse, NextAction } from '../facade-types.js'; +import { buildResponse } from '../response.js'; +import { resolveInput } from '../paths.js'; + +/** Characters that mark a positional as a glob pattern rather than a literal path. */ +const GLOB_CHARS = /[*?[\]{}]/; + +/** The document kinds the pipeline understands, for narrowing a profile's first kind. */ +const KNOWN_KINDS: readonly DocumentKind[] = ['ears', 'text', 'markdown', 'yaml', 'json']; + +/** The disk and stdin access the command needs, injectable so tests stay hermetic. */ +export interface ValidateDeps { + /** Whether a path exists on disk. */ + exists(absPath: string): boolean; + /** Read a file's UTF-8 content; throws when the file is unreadable. */ + readFile(absPath: string): string; + /** Expand a glob pattern against `cwd`, returning cwd-relative matches. */ + glob(pattern: string, cwd: string): string[]; + /** Read all of stdin as one UTF-8 document. */ + readStdin(): string; +} + +/** The resolved inputs {@link runValidate} works from. */ +export interface ValidateInputs { + /** Positional paths, glob patterns, or `-` for stdin. */ + paths: string[]; + /** The `--profile` value (defaults to `strict` at the call site). */ + profileName: string; + /** `--strict`: upgrade surviving warnings to errors at the findings layer. */ + strict: boolean; + /** `--sarif`: emit a SARIF 2.1.0 log as raw stdout instead of the envelope. */ + sarif: boolean; + /** `--json`: JSON output. Used only to reject the `--json --sarif` conflict in-band. */ + json: boolean; + /** The resolved working directory paths and globs resolve against. */ + cwd: string; +} + +/** The framed outcome of a validation: the response, its pretty text, and the exit code. */ +export interface ValidateResult { + response: FacadeResponse; + pretty: string; + exitCode: number; + /** + * Raw stdout that replaces the envelope entirely, set only under `--sarif`: a + * serialized SARIF 2.1.0 log. The dispatcher writes it verbatim. + */ + raw?: string; +} + +/** The default deps: real disk and stdin access. */ +const DEFAULT_DEPS: ValidateDeps = { + exists: (absPath) => existsSync(absPath), + readFile: (absPath) => readFileSync(absPath, 'utf8'), + glob: (pattern, cwd) => globSync(pattern, { cwd }), + readStdin: () => readFileSync(0, 'utf8'), +}; + +/** One resolved input file, ready for the pipeline and for a missing/unreadable check. */ +interface ResolvedFile { + pipelineFile: PipelineFile; +} + +/** The outcome of resolving the positional inputs into pipeline files. */ +type ResolveInputsOutcome = + { ok: true; files: PipelineFile[] } | { ok: false; result: ValidateResult }; + +/** + * Run a stateless validation and frame the result. + * + * Never throws for user error: unknown profile, missing or unreadable path, no + * inputs, and the `--json --sarif` conflict are all returned in-band as an + * exit-2 {@link ValidateResult} carrying a facade-level diagnostic. Extraction + * and linting are delegated to {@link runPipeline}. + * + * @param inputs The resolved flags and positional paths. + * @param deps Injectable disk and stdin access; defaults to real I/O. + * @returns The response, pretty text, and exit code. + */ +export function runValidate( + inputs: ValidateInputs, + deps: ValidateDeps = DEFAULT_DEPS, +): ValidateResult { + // `--json` and `--sarif` are mutually exclusive. The dispatcher rejects the + // combination before reaching a handler; this in-band guard covers direct + // callers of runValidate so the invariant holds either way. Same code as the + // dispatcher's guard (cli.exclusive_flags), so the two never diverge. + if (inputs.sarif && inputs.json) { + return usageResult( + 'cli.exclusive_flags', + 'The --json and --sarif flags are mutually exclusive.', + ); + } + + const resolved = resolveProfile(inputs.profileName); + if (!resolved.ok) { + return usageResult('cli.unknown_profile', resolved.error.message); + } + const profile = resolved.profile; + + if (inputs.paths.length === 0) { + return usageResult('validate.no_files', 'Provide one or more files, globs, or - for stdin.'); + } + + const outcome = resolveInputs(inputs.paths, profile, inputs.cwd, deps); + if (!outcome.ok) { + return outcome.result; + } + if (outcome.files.length === 0) { + return usageResult('validate.no_files', 'No files matched the given paths or globs.'); + } + + const { findings, notices } = runPipeline({ + files: outcome.files, + profile, + strict: inputs.strict, + }); + + return frame(canonicalizeFindings(findings), notices, profile, inputs.sarif); +} + +/** Resolve the positional inputs into pipeline files, or an exit-2 result. */ +function resolveInputs( + paths: string[], + profile: Profile, + cwd: string, + deps: ValidateDeps, +): ResolveInputsOutcome { + const files: PipelineFile[] = []; + let readStdin = false; + + for (const path of paths) { + if (path === '-') { + if (readStdin) { + return { + ok: false, + result: usageResult('validate.duplicate_stdin', 'Read stdin (-) at most once.'), + }; + } + readStdin = true; + files.push({ path: '-', content: deps.readStdin(), kind: stdinKind(profile) }); + continue; + } + + if (GLOB_CHARS.test(path)) { + const matches = deps.glob(path, cwd).sort((a, b) => a.localeCompare(b)); + for (const match of matches) { + const resolvedFile = readResolved(match, cwd, deps); + if (!resolvedFile.ok) { + return { ok: false, result: resolvedFile.result }; + } + files.push(resolvedFile.file.pipelineFile); + } + continue; + } + + const resolvedFile = readResolved(path, cwd, deps); + if (!resolvedFile.ok) { + return { ok: false, result: resolvedFile.result }; + } + files.push(resolvedFile.file.pipelineFile); + } + + return { ok: true, files }; +} + +/** Read one literal or glob-matched path into a pipeline file, or an exit-2 result. */ +function readResolved( + userPath: string, + cwd: string, + deps: ValidateDeps, +): { ok: true; file: ResolvedFile } | { ok: false; result: ValidateResult } { + const abs = resolveInput(cwd, userPath); + if (!deps.exists(abs)) { + return { + ok: false, + result: usageResult('validate.missing_file', `File not found: ${userPath}.`, userPath), + }; + } + let content: string; + try { + content = deps.readFile(abs); + } catch { + return { + ok: false, + result: usageResult('validate.unreadable', `Could not read file: ${userPath}.`, userPath), + }; + } + return { + ok: true, + file: { + pipelineFile: { + path: displayPath(userPath, abs, cwd), + content, + kind: inferKind(abs), + }, + }, + }; +} + +/** + * The path recorded on findings for a file. Absolute when the caller passed an + * absolute path, otherwise a cwd-relative POSIX path (per the Findings contract). + */ +function displayPath(userPath: string, abs: string, cwd: string): string { + if (isAbsolute(userPath)) { + return userPath; + } + return relative(cwd, abs).split(sep).join('/'); +} + +/** The document kind to read stdin as: the profile's first located kind, else text. */ +function stdinKind(profile: Profile): DocumentKind { + const first = profile.locator.documentKinds.at(0); + if (first !== undefined && (KNOWN_KINDS as readonly string[]).includes(first)) { + return first as DocumentKind; + } + return 'text'; +} + +/** + * Frame a completed pipeline run into a response, pretty text, and exit code. + * + * Under `--sarif`, a serialized SARIF 2.1.0 projection of the findings is set as + * `raw`, which the dispatcher writes verbatim in place of the envelope. The exit + * code stays findings-driven regardless of output format. + */ +function frame( + findings: Findings, + notices: PipelineNotice[], + profile: Profile, + sarif: boolean, +): ValidateResult { + const diagnostics = notices.map(noticeToDiagnostic); + const environmentError = notices.some((notice) => notice.severity === 'error'); + const ok = !environmentError && findings.summary.errors === 0; + const exitCode = environmentError ? EXIT_USAGE : exitCodeForFindings(findings); + + const next = buildNext(findings, profile); + const response = buildResponse( + { + command: 'validate', + ok, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + next, + }, + { findings }, + ); + + const framed: ValidateResult = { + response, + pretty: prettyFindings(findings, diagnostics), + exitCode, + }; + if (sarif) { + framed.raw = serializeSarifLog(buildSarifLog(findings)); + } + return framed; +} + +/** Map a pipeline notice onto the facade-level diagnostic channel. */ +function noticeToDiagnostic(notice: PipelineNotice): FacadeDiagnostic { + return { + code: notice.code, + severity: notice.severity, + message: notice.message, + ...(notice.file === undefined ? {} : { path: notice.file }), + ...(notice.line === undefined ? {} : { line: notice.line }), + }; +} + +/** A repair `next` action pointing at the first file that carries an error finding. */ +function buildNext(findings: Findings, profile: Profile): NextAction[] { + if (findings.summary.errors === 0) { + return []; + } + const erroredFile = findings.diagnostics.find( + (diagnostic) => diagnostic.severity === 'error' && diagnostic.file !== '-', + )?.file; + if (erroredFile === undefined) { + return []; + } + return [ + { + command: `earsyntax instructions repair --file ${erroredFile} --profile ${profile.name} --json`, + reason: 'Get repair rules for the reported diagnostics.', + forAgent: true, + }, + ]; +} + +/** Pretty output: one line per finding, then per notice, then a summary line. */ +function prettyFindings(findings: Findings, diagnostics: FacadeDiagnostic[]): string { + const lines: string[] = []; + for (const diagnostic of findings.diagnostics) { + const col = diagnostic.col === undefined ? '' : `:${diagnostic.col}`; + lines.push( + `${diagnostic.file}:${diagnostic.line}${col} ${diagnostic.id} ${diagnostic.severity} ${diagnostic.message}`, + ); + } + for (const diagnostic of diagnostics) { + const at = diagnostic.line === undefined ? '' : `:${diagnostic.line}`; + lines.push( + `${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, + ); + } + const { valid, requirements, files, errors, warnings } = findings.summary; + lines.push( + `${valid}/${requirements} valid across ${files} file(s), ${errors} error(s), ${warnings} warning(s)`, + ); + return lines.join('\n'); +} + +/** Build an exit-2 usage/environment result carrying a single facade diagnostic. */ +function usageResult(code: string, message: string, path?: string): ValidateResult { + const diagnostic: FacadeDiagnostic = { + code, + severity: 'error', + message, + ...(path === undefined ? {} : { path }), + }; + const response = buildResponse({ + command: 'validate', + ok: false, + diagnostics: [diagnostic], + next: [], + }); + return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; +} + +/** + * The `validate` command handler. Reads the resolved globals and positionals + * from the context, runs the stateless validation, and returns the + * {@link CommandResult}; the dispatcher performs the single write. `--quiet` + * blanks the pretty rendering (JSON output is unaffected, per the facade). + */ +export function validateCommand(context: CommandContext): CommandResult { + const { global } = context; + const result = runValidate({ + paths: context.args.positionals, + profileName: global.profile, + strict: global.strict, + sarif: global.sarif, + json: global.json, + cwd: context.cwd, + }); + return global.quiet && !global.json ? { ...result, pretty: '' } : result; +} diff --git a/packages/cli/src/commands/version.test.ts b/packages/cli/src/commands/version.test.ts new file mode 100644 index 0000000..09b168e --- /dev/null +++ b/packages/cli/src/commands/version.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the `version` command: the envelope shape, the `--features` + * pretty expansion, and `--quiet` suppressing pretty output uniformly with + * every other command. + */ + +import { BUILTIN_PROFILE_NAMES } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import type { GlobalOptions, ParsedArgs } from '../args.js'; +import { run } from '../cli.js'; +import { createPainter } from '../color.js'; +import type { CommandContext } from '../context.js'; +import type { Emitter } from '../response.js'; +import { AGENTS } from '../renderers/agents.js'; +import { HOSTS } from '../renderers/hosts.js'; +import { CLI_VERSION, FEATURES } from '../version.js'; +import { versionCommand } from './version.js'; + +function makeContext( + options: { json?: boolean; quiet?: boolean; features?: boolean } = {}, +): CommandContext { + const booleans = new Set(); + if (options.features) { + booleans.add('features'); + } + const args: ParsedArgs = { positionals: [], booleans, values: new Map() }; + const global: GlobalOptions = { + json: options.json ?? false, + sarif: false, + strict: false, + quiet: options.quiet ?? false, + profile: 'strict', + cwd: '/work', + }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; + return { args, global, cwd: '/work', emitter }; +} + +describe('versionCommand — envelope', () => { + it('carries the version, no root, and the full features map', () => { + const result = versionCommand(makeContext({ json: true })); + expect(result.response.command).toBe('version'); + expect(result.response.ok).toBe(true); + expect(result.response.root).toBeUndefined(); + expect(result.response.features).toEqual(FEATURES); + expect(result.exitCode).toBe(0); + }); + + it('derives the profiles capability from the same builtin registry the profiles command uses', () => { + expect(FEATURES.profiles).toEqual([...BUILTIN_PROFILE_NAMES]); + }); + + it('derives the hosts and agents capabilities from the renderer registries', () => { + expect(FEATURES.hosts).toEqual([...HOSTS]); + expect(FEATURES.agents).toEqual([...AGENTS]); + }); +}); + +describe('versionCommand — pretty rendering', () => { + it('prints just the version line without --features', () => { + const result = versionCommand(makeContext()); + expect(result.pretty).toBe(`earsyntax ${CLI_VERSION}`); + }); + + it('expands the capability map under --features', () => { + const result = versionCommand(makeContext({ features: true })); + expect(result.pretty).toContain(`facade contract: ${FEATURES.facade}`); + expect(result.pretty).toContain(FEATURES.profiles.join(', ')); + }); +}); + +describe('versionCommand — --quiet suppresses pretty output', () => { + it('empties pretty under --quiet without --json', () => { + const result = versionCommand(makeContext({ quiet: true, features: true })); + expect(result.pretty).toBe(''); + }); + + it('leaves the JSON payload untouched when --quiet is combined with --json', () => { + const result = versionCommand(makeContext({ json: true, quiet: true })); + expect(result.response.features).toEqual(FEATURES); + }); +}); + +describe('version — through the dispatcher', () => { + function capture(argv: string[]): { out: string; code: number } { + let out = ''; + const code = run(argv, { stdout: (text) => (out += text), cwd: '/work' }); + return { out, code }; + } + + it('exits 0 and emits nothing under --quiet without --json', () => { + const { out, code } = capture(['version', '--quiet']); + expect(code).toBe(0); + expect(out).toBe('\n'); + }); + + it('leaves --json output unaffected by --quiet', () => { + const { out, code } = capture(['version', '--json', '--quiet']); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.features).toEqual(FEATURES); + }); +}); diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts new file mode 100644 index 0000000..d4c3ded --- /dev/null +++ b/packages/cli/src/commands/version.ts @@ -0,0 +1,37 @@ +/** + * `earsyntax version` — version and feature discovery. + * + * Never resolves a repo, so `root` is absent. The response always carries the + * {@link FEATURES} capability map; `--features` only expands the pretty output. + * `--quiet` suppresses the pretty text like every other command; it has no + * effect on `--json`. + */ + +import type { CommandContext, CommandResult } from '../context.js'; +import { buildResponse } from '../response.js'; +import { CLI_VERSION, FEATURES } from '../version.js'; + +export function versionCommand(context: CommandContext): CommandResult { + const response = buildResponse( + { command: 'version', ok: true, next: [] }, + { features: FEATURES }, + ); + + const pretty = context.args.booleans.has('features') + ? [ + `earsyntax ${CLI_VERSION}`, + ` facade contract: ${FEATURES.facade}`, + ` commands: ${FEATURES.commands.join(', ')}`, + ` profiles: ${FEATURES.profiles.join(', ')}`, + ` instructions: ${FEATURES.instructions.join(', ')}`, + ` hosts: ${FEATURES.hosts.join(', ')}`, + ` agents: ${FEATURES.agents.join(', ')}`, + ` input formats: ${FEATURES.inputFormats.join(', ')}`, + ` output formats: ${FEATURES.outputFormats.join(', ')}`, + ` sarif: ${FEATURES.sarif ? 'yes' : 'no'}`, + ].join('\n') + : `earsyntax ${CLI_VERSION}`; + + const result = { response, pretty, exitCode: 0 }; + return context.global.quiet && !context.global.json ? { ...result, pretty: '' } : result; +} diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts new file mode 100644 index 0000000..fedff5a --- /dev/null +++ b/packages/cli/src/context.ts @@ -0,0 +1,39 @@ +/** + * The context every command handler receives, and the result it returns. + * + * Handlers are pure with respect to output: they build a response, a pretty + * rendering, and an exit code, and hand them back. The dispatcher owns the one + * write to stdout, which keeps `--json` output pure JSON and `--sarif` output a + * bare SARIF log. + */ + +import type { GlobalOptions, ParsedArgs } from './args.js'; +import type { FacadeResponse } from './facade-types.js'; +import type { Emitter } from './response.js'; + +export interface CommandContext { + args: ParsedArgs; + global: GlobalOptions; + /** The resolved working directory (from `--cwd`, else the process cwd). */ + cwd: string; + /** Carries `json` and the `painter`; handlers use the painter for pretty color but never write. */ + emitter: Emitter; +} + +/** What a command handler returns; the dispatcher performs the single write. */ +export interface CommandResult { + /** The JSON envelope, always built (even in pretty or SARIF mode). */ + response: FacadeResponse; + /** The human rendering, used when `--json` is off and `raw` is unset. */ + pretty: string; + /** The process exit code: `0`, `1`, or `2`. */ + exitCode: number; + /** + * Raw stdout that replaces the envelope entirely (SARIF). When set, the + * dispatcher writes it verbatim regardless of `--json` or `pretty`. + */ + raw?: string; +} + +/** A command handler: does its work and returns a {@link CommandResult}. */ +export type CommandHandler = (context: CommandContext) => CommandResult; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..21058f3 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,32 @@ +/** + * The single error type command handlers throw to abort with a facade + * diagnostic and a specific exit code. + * + * The dispatcher catches {@link CliError}, renders a base {@link FacadeResponse} + * carrying the diagnostic (JSON or pretty), and returns the carried exit code. + * The only failure exit code is `2` (usage or environment): there is no + * workspace to refuse writes over, so exit `3` no longer exists. + */ + +import type { FacadeDiagnostic } from './facade-types.js'; + +export class CliError extends Error { + readonly exitCode: number; + readonly diagnostic: FacadeDiagnostic; + + constructor(exitCode: number, diagnostic: FacadeDiagnostic) { + super(diagnostic.message); + this.name = 'CliError'; + this.exitCode = exitCode; + this.diagnostic = diagnostic; + } +} + +/** Build a usage or environment error (exit 2). */ +export function usageError( + code: string, + message: string, + extra?: Partial, +): CliError { + return new CliError(2, { code, severity: 'error', message, ...extra }); +} diff --git a/packages/cli/src/facade-types.ts b/packages/cli/src/facade-types.ts new file mode 100644 index 0000000..17f420e --- /dev/null +++ b/packages/cli/src/facade-types.ts @@ -0,0 +1,44 @@ +/** + * The facade envelope types for the `earsyntax` CLI. + * + * These shapes are the normative wire format described in + * `docs/refactor/host-native-facade.md`. Every `--json` command returns a + * {@link FacadeResponse}; command-specific payloads are added as top-level + * convenience keys. This module defines only the envelope; each command owns + * its own payload types (findings, candidates, profile summaries, and so on). + */ + +/** Severity of a facade-level notice. Core lint findings live in a command's own payload, not here. */ +export type FacadeSeverity = 'error' | 'warning'; + +/** + * A facade-level diagnostic: a usage or environment notice (bad flag, unknown + * profile, missing path). It is distinct from the lint findings a command + * carries in its `findings` payload; lint results never appear here. + */ +export interface FacadeDiagnostic { + code: string; + severity: FacadeSeverity; + message: string; + path?: string; + line?: number; +} + +/** A runnable follow-up command returned in `next`. */ +export interface NextAction { + command: string; + reason: string; + forAgent?: boolean; +} + +/** The base shape every `--json` response carries. */ +export interface FacadeResponse { + version: string; + command: string; + ok: boolean; + root?: string; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; + /** Command-specific convenience keys (findings, summary, candidates, features, ...). */ + [key: string]: unknown; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..5d03fad --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,11 @@ +/** + * `@earsyntax/cli` public API surface. + * + * The package ships the `earsyntax` binary (see `bin/run.js`). It also exports + * {@link run} for embedding and tests, plus the facade JSON types so consumers + * can type responses they parse. + */ + +export { run, type RunOptions } from './cli.js'; +export { CLI_VERSION, FACADE_CONTRACT, FEATURES } from './version.js'; +export type * from './facade-types.js'; diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts new file mode 100644 index 0000000..0f3e09d --- /dev/null +++ b/packages/cli/src/paths.ts @@ -0,0 +1,47 @@ +/** + * Path helpers, free of any workspace concept. + * + * The host-native CLI resolves user paths against the working directory and, for + * the `root` envelope field, detects the enclosing repository root by walking up + * to a `.git` marker. There is no `.earsyntax/` project to discover: these are + * ordinary filesystem utilities the commands share. + */ + +import { existsSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +/** Resolve a possibly-relative user path against the resolved working directory. */ +export function resolveInput(cwd: string, input: string): string { + return isAbsolute(input) ? input : resolve(cwd, input); +} + +/** + * Convert an absolute path to a `base`-relative POSIX path, or keep it absolute + * when it falls outside `base`. + */ +export function toRelative(base: string, absPath: string): string { + const rel = relative(base, absPath); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + return absPath; + } + return rel.split(sep).join('/'); +} + +/** + * Detect the repository root by walking up from `startDir` to the nearest + * ancestor containing a `.git` entry. Returns `undefined` when none is found, + * so callers can omit the optional `root` field rather than inventing one. + */ +export function detectRoot(startDir: string): string | undefined { + let current = resolve(startDir); + for (;;) { + if (existsSync(resolve(current, '.git'))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} diff --git a/packages/cli/src/renderers/agents.ts b/packages/cli/src/renderers/agents.ts new file mode 100644 index 0000000..3f97c38 --- /dev/null +++ b/packages/cli/src/renderers/agents.ts @@ -0,0 +1,134 @@ +/** + * Agent wrapper renderers for `earsyntax init --agent`. + * + * Each agent maps to files in its own convention: Claude slash commands, a + * Cursor rule, a Copilot prompt, or a managed section inside a shared + * agent-instructions file (`AGENTS.md` for codex and generic, `GEMINI.md` for + * gemini). The wrapper body is the thin protocol from {@link loopSteps}; only + * the surrounding syntax (frontmatter, argument token) differs per agent. + */ + +import { + type Contribution, + emptyContribution, + loopSteps, + PHASES, + type Phase, + profileContext, + type RenderedFile, +} from './protocol.js'; + +/** The agents `--agent` accepts, in the order the facade lists them. */ +export const AGENTS = ['claude', 'codex', 'cursor', 'copilot', 'gemini', 'generic'] as const; +export type Agent = (typeof AGENTS)[number]; + +/** A one-line description of what each phase's wrapper is for. */ +const PHASE_INTRO: Record = { + author: 'Author new EARS requirements into a host document.', + convert: 'Rewrite natural-language requirements already in a host document into EARS.', + repair: 'Repair the diagnostics earsyntax validate reports in a host document.', + review: 'Review the EARS requirements in a host document without changing them.', +}; + +/** The placeholder file token used by every wrapper except Claude commands. */ +const FILE_PLACEHOLDER = ''; + +/** Render one Claude slash command file for a single phase. */ +function claudeCommand(phase: Phase, profile: string, note: string): RenderedFile { + const body = [ + `---`, + `description: ${PHASE_INTRO[phase]}`, + `argument-hint: `, + `---`, + ``, + `${PHASE_INTRO[phase]} Pass the host document as the argument.`, + ``, + ...loopSteps({ phase, file: '$ARGUMENTS', profile }), + ...(note === '' ? [] : ['', note]), + ``, + ].join('\n'); + return { path: `.claude/commands/earsyntax-${phase}.md`, content: body }; +} + +/** The four-phase wrapper body shared by non-Claude agents (Cursor, Copilot, managed sections). */ +function multiPhaseBody(profile: string, note: string): string[] { + const lines: string[] = [ + 'Deterministic EARS authoring and validation loop. Pick the phase that fits', + 'the task, then run this loop against the host document:', + '', + ]; + for (const phase of PHASES) { + lines.push(`${phase}: ${PHASE_INTRO[phase]}`); + } + lines.push('', ...loopSteps({ file: FILE_PLACEHOLDER, profile })); + if (note !== '') { + lines.push('', note); + } + return lines; +} + +/** Render the Cursor rule file. */ +function cursorRule(profile: string, note: string): RenderedFile { + const content = [ + `---`, + `description: EARS authoring and validation loop via earsyntax.`, + `alwaysApply: false`, + `---`, + ``, + ...multiPhaseBody(profile, note), + ``, + ].join('\n'); + return { path: '.cursor/rules/earsyntax.mdc', content }; +} + +/** Render the Copilot prompt file. */ +function copilotPrompt(profile: string, note: string): RenderedFile { + const content = [ + `---`, + `mode: agent`, + `description: EARS authoring and validation loop via earsyntax.`, + `---`, + ``, + ...multiPhaseBody(profile, note), + ``, + ].join('\n'); + return { path: '.github/prompts/earsyntax.prompt.md', content }; +} + +/** The managed-section block shared by codex, generic (AGENTS.md) and gemini (GEMINI.md). */ +function agentLoopBlock(profile: string, note: string): string { + return ['## earsyntax', '', ...multiPhaseBody(profile, note)].join('\n'); +} + +/** + * Render one agent's contribution against the configured hosts. `hosts` decides + * the `--profile` token: one host pins the profile, several make it a + * placeholder with a selection note. + */ +export function renderAgent(agent: Agent, hosts: readonly string[]): Contribution { + const { token: profile, note } = profileContext(hosts); + const contribution = emptyContribution(); + + switch (agent) { + case 'claude': + contribution.owned = PHASES.map((phase) => claudeCommand(phase, profile, note)); + return contribution; + case 'cursor': + contribution.owned = [cursorRule(profile, note)]; + return contribution; + case 'copilot': + contribution.owned = [copilotPrompt(profile, note)]; + return contribution; + case 'codex': + case 'generic': + contribution.managed = [ + { file: 'AGENTS.md', id: 'agent-loop', block: agentLoopBlock(profile, note) }, + ]; + return contribution; + case 'gemini': + contribution.managed = [ + { file: 'GEMINI.md', id: 'agent-loop', block: agentLoopBlock(profile, note) }, + ]; + return contribution; + } +} diff --git a/packages/cli/src/renderers/hosts.ts b/packages/cli/src/renderers/hosts.ts new file mode 100644 index 0000000..d7692ad --- /dev/null +++ b/packages/cli/src/renderers/hosts.ts @@ -0,0 +1,124 @@ +/** + * Host integration renderers for `earsyntax init --host`. + * + * Kiro gets a steering document and a validation hook; Spec Kit gets an + * extension document (Spec Kit reserves its own `speckit.*` command namespace + * and exposes no stable third-party slash-command surface, so earsyntax owns a + * document under `.specify/extensions/` rather than colliding with generated + * command files); OpenSpec contributes a managed section with its validate + * commands to `AGENTS.md`. Every host wrapper pins its own `--profile`, since a + * host file only ever concerns its own documents. + */ + +import { type Contribution, emptyContribution, loopSteps, PHASES } from './protocol.js'; + +/** The hosts `--host` accepts, in the order the facade lists them. */ +export const HOSTS = ['kiro', 'speckit', 'openspec'] as const; +export type Host = (typeof HOSTS)[number]; + +/** The exact validate command each host suggests, used in steering docs and `next`. */ +export const HOST_VALIDATE: Record = { + kiro: { command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro' }, + speckit: { command: 'earsyntax validate "specs/**/spec.md" --profile speckit' }, + openspec: { command: 'earsyntax validate "openspec/specs/**/*.md" --profile openspec' }, +}; + +/** The glob the Kiro validation hook watches; only Kiro's hook needs a raw pattern. */ +const KIRO_HOOK_GLOB = '.kiro/specs/**/requirements.md'; + +/** The four-phase steering body a host document carries, with the profile pinned. */ +function steeringBody(profile: Host, fileToken: string): string[] { + const lines: string[] = [ + 'Deterministic EARS authoring and validation loop for this host. Pick the', + 'phase that fits the task, then run this loop against the document:', + '', + ]; + for (const phase of PHASES) { + lines.push(`- ${phase}`); + } + lines.push('', ...loopSteps({ file: fileToken, profile })); + return lines; +} + +/** Render the Kiro steering document. */ +function kiroSteering(): { path: string; content: string } { + const content = [ + '# earsyntax steering', + '', + ...steeringBody('kiro', ''), + '', + `Validate every Kiro requirements document: ${HOST_VALIDATE.kiro.command}`, + '', + ].join('\n'); + return { path: '.kiro/steering/earsyntax.md', content }; +} + +/** + * Render the Kiro validation hook. Runs earsyntax validate over Kiro + * requirements documents when they are saved. The schema is Kiro's agent-hook + * YAML; the command stays deterministic and offline. + */ +function kiroHook(): { path: string; content: string } { + const content = [ + '# earsyntax validation hook for Kiro.', + '# Runs deterministic EARS validation when a requirements document is saved.', + 'name: ears-validate', + 'description: Validate EARS requirements with earsyntax.', + 'on:', + ' fileEdited:', + ' patterns:', + ` - "${KIRO_HOOK_GLOB}"`, + 'run: >-', + ` ${HOST_VALIDATE.kiro.command} --json`, + '', + ].join('\n'); + return { path: '.kiro/hooks/ears-validate.yaml', content }; +} + +/** Render the Spec Kit extension document. */ +function speckitExtension(): { path: string; content: string } { + const content = [ + '# earsyntax extension for Spec Kit', + '', + 'Spec Kit owns the specification lifecycle and reserves its `speckit.*`', + 'command namespace. earsyntax adds only the deterministic EARS loop below; it', + 'never creates, plans, or accepts specs.', + '', + ...steeringBody('speckit', ''), + '', + `Validate every Spec Kit spec: ${HOST_VALIDATE.speckit.command}`, + '', + ].join('\n'); + return { path: '.specify/extensions/earsyntax.md', content }; +} + +/** The OpenSpec managed block for AGENTS.md: the validate commands for specs and changes. */ +function openspecBlock(): string { + return [ + '## earsyntax with OpenSpec', + '', + 'Validate OpenSpec requirements and scenarios with the deterministic EARS', + 'checker. Never approve, accept, or archive a change based on this run.', + '', + `Run: ${HOST_VALIDATE.openspec.command} --json`, + 'Run: earsyntax validate "openspec/changes/**/*.md" --profile openspec --json', + ].join('\n'); +} + +/** Render one host's contribution. */ +export function renderHost(host: Host): Contribution { + const contribution = emptyContribution(); + switch (host) { + case 'kiro': + contribution.owned = [kiroSteering(), kiroHook()]; + return contribution; + case 'speckit': + contribution.owned = [speckitExtension()]; + return contribution; + case 'openspec': + contribution.managed = [ + { file: 'AGENTS.md', id: 'openspec-validate', block: openspecBlock() }, + ]; + return contribution; + } +} diff --git a/packages/cli/src/renderers/index.ts b/packages/cli/src/renderers/index.ts new file mode 100644 index 0000000..9466811 --- /dev/null +++ b/packages/cli/src/renderers/index.ts @@ -0,0 +1,12 @@ +/** + * Renderer surface for `earsyntax init`. + * + * Re-exports the agent and host renderers and the shared protocol primitives so + * the init command imports one module. The renderers are pure: given the same + * agents and hosts they return the same bytes, with no clock or filesystem + * access. + */ + +export * from './protocol.js'; +export { AGENTS, type Agent, renderAgent } from './agents.js'; +export { HOSTS, type Host, HOST_VALIDATE, renderHost } from './hosts.js'; diff --git a/packages/cli/src/renderers/protocol.ts b/packages/cli/src/renderers/protocol.ts new file mode 100644 index 0000000..62dbd92 --- /dev/null +++ b/packages/cli/src/renderers/protocol.ts @@ -0,0 +1,121 @@ +/** + * Shared renderer primitives for `earsyntax init`. + * + * Every wrapper this module produces is thin: it never duplicates the EARS + * protocol, it points the agent at `earsyntax instructions` and `earsyntax + * validate` and tells it to loop until clean. The rendered bytes are + * deterministic (no timestamps, no host paths beyond the configured hosts) so + * running `init` twice produces byte-identical files. + * + * Two kinds of output exist. An {@link RenderedFile} is a file earsyntax owns + * whole (a Claude command, a Cursor rule, a Kiro steering doc). A + * {@link ManagedContribution} is a block spliced into a file earsyntax shares + * with other tools (`AGENTS.md`, `GEMINI.md`) between begin/end markers, leaving + * the rest of that file untouched. + */ + +/** The built-in host profile names, in render order. Used when no `--host` is set. */ +export const BUILTIN_HOSTS = ['kiro', 'speckit', 'openspec'] as const; + +/** The four instruction phases an agent runs, in loop order. */ +export const PHASES = ['author', 'convert', 'repair', 'review'] as const; +export type Phase = (typeof PHASES)[number]; + +/** Markers bounding earsyntax's managed section inside a shared markdown file. */ +export const MANAGED_BEGIN = ''; +export const MANAGED_END = ''; + +/** A file earsyntax owns whole; `path` is repo-root-relative POSIX. */ +export interface RenderedFile { + path: string; + content: string; +} + +/** + * A block earsyntax splices into a shared file. `id` dedupes contributions that + * render identically (codex and generic both contribute the same agent-loop + * block); `file` is the repo-root-relative shared file it lands in. + */ +export interface ManagedContribution { + file: string; + id: string; + block: string; +} + +/** What one requested agent or host renders: owned files and shared-file blocks. */ +export interface Contribution { + owned: RenderedFile[]; + managed: ManagedContribution[]; +} + +/** An empty contribution, the base every renderer extends. */ +export function emptyContribution(): Contribution { + return { owned: [], managed: [] }; +} + +/** + * The `--profile` token a host-agnostic wrapper uses, plus a note. With one + * configured host the token is that host's name; with several it is the + * `` placeholder and the note lists the choices so the agent picks the + * one matching the document it edits. + */ +export function profileContext(hosts: readonly string[]): { token: string; note: string } { + const configured = hosts.length > 0 ? hosts : [...BUILTIN_HOSTS]; + if (configured.length === 1) { + return { token: configured[0], note: '' }; + } + return { + token: '', + note: `Set to the host that matches the document: ${configured.join(', ')}.`, + }; +} + +/** Options for {@link loopSteps}: which phase, which file token, which profile token. */ +export interface LoopOptions { + /** A fixed phase, or undefined for the `` placeholder. */ + phase?: Phase; + /** The `--file` token: `$ARGUMENTS` for Claude, a `` placeholder elsewhere. */ + file: string; + /** The `--profile` token: a host name or ``. */ + profile: string; +} + +/** + * The thin protocol, one instruction per line. This is the whole contract a + * wrapper carries: run instructions, follow them, edit only the host file, + * re-validate, repeat, never approve. It never restates an EARS rule. + */ +export function loopSteps(options: LoopOptions): string[] { + const phase = options.phase ?? ''; + return [ + `Run: earsyntax instructions ${phase} --file ${options.file} --profile ${options.profile} --json`, + 'Follow the returned rules exactly.', + 'Edit only the host file passed to --file.', + `Run: earsyntax validate ${options.file} --profile ${options.profile} --json`, + 'Repeat until validate reports zero errors.', + 'Do not approve, accept, or merge.', + ]; +} + +/** + * Splice a managed region carrying `block` into `existing`. Returns the new file + * content. Absent file: the region alone. Markers present: the region between + * them is replaced and the rest is preserved byte for byte. Markers absent: the + * region is appended after one blank line. Idempotent: re-splicing an identical + * block over its own output returns identical bytes. + */ +export function spliceManaged(existing: string | undefined, block: string): string { + const region = `${MANAGED_BEGIN}\n${block}\n${MANAGED_END}`; + if (existing === undefined) { + return `${region}\n`; + } + const beginIdx = existing.indexOf(MANAGED_BEGIN); + const endIdx = existing.indexOf(MANAGED_END); + if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { + const before = existing.slice(0, beginIdx); + const after = existing.slice(endIdx + MANAGED_END.length); + return `${before}${region}${after}`; + } + const base = existing.endsWith('\n') ? existing : `${existing}\n`; + return `${base}\n${region}\n`; +} diff --git a/packages/cli/src/response.ts b/packages/cli/src/response.ts new file mode 100644 index 0000000..5018246 --- /dev/null +++ b/packages/cli/src/response.ts @@ -0,0 +1,80 @@ +/** + * Base response construction, JSON serialization, and output emission. + * + * Every `--json` command returns a {@link FacadeResponse}. This module builds + * the base shape in a fixed key order and prints either the serialized JSON or + * a caller-supplied pretty string. It is the one place that writes to stdout. + */ + +import type { Painter } from './color.js'; +import type { CliError } from './errors.js'; +import type { FacadeDiagnostic, FacadeResponse, NextAction } from './facade-types.js'; +import { CLI_VERSION } from './version.js'; + +/** Fields for building a base response, before command-specific keys are merged in. */ +export interface BaseInit { + command: string; + ok: boolean; + root?: string; + diagnostics?: FacadeDiagnostic[]; + next?: NextAction[]; +} + +/** + * Build a {@link FacadeResponse} with the base fields in a stable order, + * then merge command-specific convenience keys on top. Construction is a single + * immutable literal: no mutation, no `delete`. Key order is `version`, + * `command`, `ok`, `root?`, command keys, `diagnostics?`, `next`. + */ +export function buildResponse(init: BaseInit, extra: Record = {}): FacadeResponse { + const hasDiagnostics = init.diagnostics !== undefined && init.diagnostics.length > 0; + return { + version: CLI_VERSION, + command: init.command, + ok: init.ok, + ...(init.root !== undefined ? { root: init.root } : {}), + ...extra, + ...(hasDiagnostics ? { diagnostics: init.diagnostics } : {}), + next: init.next ?? [], + }; +} + +/** Serialize a response to pretty-printed, deterministic JSON. */ +export function serialize(response: FacadeResponse): string { + return JSON.stringify(response, null, 2); +} + +/** Where and how a command writes its output. */ +export interface Emitter { + json: boolean; + painter: Painter; + write(text: string): void; +} + +/** Emit a command result: raw stdout if present, else JSON when `--json`, else the pretty string. */ +export function emitResult( + emitter: Emitter, + response: FacadeResponse, + pretty: string, + raw?: string, +): void { + if (raw !== undefined) { + emitter.write(raw.endsWith('\n') ? raw : `${raw}\n`); + return; + } + emitter.write(`${emitter.json ? serialize(response) : pretty}\n`); +} + +/** Build the response for a {@link CliError} so the dispatcher can emit it uniformly. */ +export function errorResponse( + command: string, + root: string | undefined, + error: CliError, +): FacadeResponse { + return buildResponse({ + command, + ok: false, + root, + diagnostics: [error.diagnostic], + }); +} diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts new file mode 100644 index 0000000..35dc999 --- /dev/null +++ b/packages/cli/src/rules.ts @@ -0,0 +1,295 @@ +/** + * The deterministic instruction data the `instructions` command returns. + * + * `earsyntax instructions ` hands a coding agent + * the rules for one loop step against a host file. Those rules are pure data: + * no clock, no file system, no network, no LLM, and no timestamps. The same + * mode, profile, and findings always yield the same rule text, so the output is + * reproducible and diffable. + * + * The content is adapted from `docs/agent-rules.md`, keeping the EARS authoring + * and repair guidance (pattern selection, canonical templates, one obligation + * per requirement, do-not-invent, diagnostic-to-fix hints) and dropping the + * retired workspace flow (`.ears` files, `traceability.json`, `questions.md`, + * and the acceptance gate). The rules describe editing the host document in + * place, per the active profile's locator. + * + * The instruction body never tells an agent to approve, accept, or merge, and + * never references a workspace, work item, or manifest. + */ + +import type { Findings, Profile, ProfileDialect } from '@earsyntax/core'; + +/** The four instruction modes, in their frozen order. */ +export const INSTRUCTION_MODES = ['author', 'convert', 'repair', 'review'] as const; + +/** One instruction mode. */ +export type InstructionMode = (typeof INSTRUCTION_MODES)[number]; + +/** Whether `value` is a known instruction mode. */ +export function isInstructionMode(value: string): value is InstructionMode { + return (INSTRUCTION_MODES as readonly string[]).includes(value); +} + +/** Only `author` and `convert` may read a `--from` source. */ +export function modeAcceptsSource(mode: InstructionMode): boolean { + return mode === 'author' || mode === 'convert'; +} + +/** The locator payload: the document kinds the profile locates and a generated summary. */ +export interface LocatorPayload { + documentKinds: string[]; + summary: string; +} + +/** The dialect payload: the profile's grammar tolerances, in the facade key order. */ +export interface DialectPayload { + keywordCase: ProfileDialect['keywordCase']; + commaAfterLeadingClause: ProfileDialect['commaAfterLeadingClause']; + allowLiteralSystemName: string[]; + allowStoryWrapper: boolean; + allowFrameMetadata: boolean; + allowProhibition: boolean; +} + +/** A fallback sentence for a locator rule that carries no `note`, mentioning the file kinds. */ +function fallbackRuleSentence(rule: Profile['locator']['include'][number], kinds: string): string { + switch (rule.kind) { + case 'every-line': + return `Every non-empty line of ${kinds} files.`; + case 'heading-section': + return rule.headingPattern + ? `Body lines of sections matching /${rule.headingPattern}/ in ${kinds} files.` + : `Body lines of headed sections in ${kinds} files.`; + case 'list-item': + return rule.underHeading + ? `List items under headings matching /${rule.underHeading}/ in ${kinds} files.` + : `List items in ${kinds} files.`; + case 'block': + return rule.blockPrefix + ? `${rule.blockPrefix} blocks in ${kinds} files.` + : `Prefixed blocks in ${kinds} files.`; + default: + return `Located regions in ${kinds} files.`; + } +} + +/** + * Build the locator payload from profile data. + * + * The summary is generated from the include rules: each rule's authored `note` + * when present (the profiles carry human-readable notes for their markdown + * locators), otherwise a fallback sentence derived from the rule's kind and the + * document kinds. Nothing here is hand-written per profile, so the summary + * cannot drift from the profile object. + */ +export function buildLocator(profile: Profile): LocatorPayload { + const kinds = profile.locator.documentKinds.join(', '); + const includes = profile.locator.include; + const summary = + includes.length === 0 + ? `No requirement regions are located in ${kinds} files.` + : includes.map((rule) => rule.note ?? fallbackRuleSentence(rule, kinds)).join(' '); + return { documentKinds: [...profile.locator.documentKinds], summary }; +} + +/** Build the dialect payload from profile data, in the facade key order. */ +export function buildDialect(profile: Profile): DialectPayload { + const dialect = profile.dialect; + return { + keywordCase: dialect.keywordCase, + commaAfterLeadingClause: dialect.commaAfterLeadingClause, + allowLiteralSystemName: [...dialect.allowLiteralSystemName], + allowStoryWrapper: dialect.allowStoryWrapper, + allowFrameMetadata: dialect.allowFrameMetadata, + allowProhibition: dialect.allowProhibition, + }; +} + +/** Pattern-selection decision guidance, shared by every writing mode. */ +const PATTERN_SELECTION: readonly string[] = [ + 'Choose the narrowest EARS pattern that fits the behaviour; do not force everything into When.', + 'Use While for behaviour active during a state, Where for behaviour gated by an optional feature, and If ..., then ... for behaviour handling an error or other unwanted condition.', + 'Use the ubiquitous form for behaviour that is always active with no trigger or state.', +]; + +/** The six canonical EARS templates, one rule each. */ +const CANONICAL_TEMPLATES: readonly string[] = [ + 'Ubiquitous template: The shall .', + 'Event-driven template: When , the shall .', + 'State-driven template: While , the shall .', + 'Optional-feature template: Where , the shall .', + 'Unwanted-behaviour template: If , then the shall .', + 'Complex template: order leading clauses as While, then Where, then When, then If, before the shall .', +]; + +/** One obligation per requirement and compound-splitting guidance. */ +const COMPOUND_RULES: readonly string[] = [ + 'Write one requirement per statement, each with exactly one shall stating a single obligation.', + 'When a statement carries several obligations, split it into separate requirements; do not split a phrase that only qualifies the response.', +]; + +/** The do-not-invent guardrails. */ +const DO_NOT_INVENT: readonly string[] = [ + 'Write only behaviour the source states; do not add logging, retries, rate limits, persistence, or permissions it does not require.', + 'When behaviour is missing, vague, or conflicting, leave it out and flag it for a human rather than guessing a precise requirement.', +]; + +/** The closing edit rule for the writing modes. */ +const EDIT_RULE = + 'Edit only the host file, in place, and preserve the surrounding document structure.'; + +/** Per-mode opening rules describing what to do to the host file. */ +const MODE_INTRO: Record = { + author: [ + 'Write new EARS requirements into the requirements region of the host file that the locator describes, and nowhere else.', + 'If that region does not exist yet, create it following the host document convention; add no prose or headings beyond it.', + ], + convert: [ + 'Rewrite the natural-language requirements already in the host file requirements region into EARS form, in place.', + 'Preserve each requirement original intent; change wording only to reach a canonical EARS shape.', + ], + repair: [ + 'Change only what the reported findings justify; leave passing requirements untouched.', + 'Work through the findings by id using the guidance below, then re-run validation and repeat until no error-severity finding remains.', + 'Do not delete a failing requirement to make validation pass, and do not weaken a requirement because it is harder to parse.', + ], + review: [ + 'This review is read-only: describe the state of the located requirements and make no change to the host file.', + 'Summarize how many requirements were reviewed, how they distribute across the EARS patterns, and every finding grouped by severity.', + 'Report the validation status and what a human must resolve before the requirements are ready, and leave that decision to the human.', + ], +}; + +/** Rules added when `--from ` points at an input spec (author and convert only). */ +const FROM_RULES: readonly string[] = [ + 'Read the requirement content from the source file; it is your input to understand, not something to modify.', + 'Write the resulting EARS requirements into the host file at the region the locator describes.', + 'Leave the source file unchanged.', +]; + +/** + * Concise repair guidance keyed by current diagnostic id. + * + * Adapted from the `docs/diagnostics.md` fix tables. Only ids present in a + * repair run are emitted, so the rules stay keyed to the findings actually + * reported. An id absent from this map still appears in the embedded findings + * (each finding may carry its own `fix`); the map is a compact per-id summary, + * not the sole source of remediation. + * + * The catalog-term entries below (EARS-W001 through EARS-W012) and any + * `expr.*` term codes only ever surface when the pipeline runs with a catalog + * supplied. The CLI has no `--catalog` flag today, so `earsyntax` never + * triggers them; they stay in this map because library callers can pass a + * catalog into the pipeline directly and still want the fix guidance. + */ +export const FIX_BY_ID: Readonly> = { + 'EARS-E001': 'Use the specific canonical system name so it matches exactly one catalog entry.', + 'EARS-E002': + 'Use the catalog canonical system name, or confirm the system with the catalog owner.', + 'EARS-E003': 'Fill the empty leading clause body, or remove the clause if it was accidental.', + 'EARS-E004': 'Add the response after shall, or flag a question if the source states none.', + 'EARS-E005': + 'Reorder the leading clauses to While, then Where, then When, then If, before the system shall response.', + 'EARS-E006': 'Add the missing then: If , then the shall .', + 'EARS-E007': 'Add a single shall response boundary stating one obligation.', + 'EARS-E008': 'Insert the system name before shall: the shall .', + 'EARS-E009': 'Split into separate requirements, one shall each.', + 'EARS-E010': + 'Rewrite the line into a canonical EARS template, or move it out of the requirements region.', + 'EARS-E011': 'Remove the empty group or supply the missing operand in the clause expression.', + 'EARS-E012': 'Fix the malformed operator run (for example a trailing and or a leading or).', + 'EARS-E013': 'Balance the parentheses in the clause expression.', + 'EARS-E014': 'Match the EARS keyword casing the profile requires.', + 'EARS-E015': + 'Add the comma after the leading clause: When , the shall .', + 'EARS-E016': + 'Restate the prohibition as a positive obligation, or use a profile that allows shall not.', + 'EARS-W001': 'Use the specific canonical event name.', + 'EARS-W002': 'Use the canonical event name, or add the event to the catalog if it is correct.', + 'EARS-W003': 'Use the specific canonical feature name.', + 'EARS-W004': + 'Use the canonical feature name, or add the feature to the catalog if it is correct.', + 'EARS-W005': 'Use the specific canonical state name.', + 'EARS-W006': 'Use the canonical state name, or add the state to the catalog if it is correct.', + 'EARS-W007': + 'Add a requirement that uses the cataloged term if one is missing, or note the gap; do not invent behaviour to satisfy coverage.', + 'EARS-W008': 'Disambiguate the term so it matches one catalog entry, or use the canonical name.', + 'EARS-W009': 'Align the unresolved term in the clause with the catalog.', + 'EARS-W010': 'Add parentheses to the mixed and/or expression to make grouping explicit.', + 'EARS-W011': + 'Align the clause term with a catalog entry, or add the term to the catalog if it is correct.', + 'EARS-W012': 'Prefer the canonical catalog name over the matched alias.', + 'EARS-W013': 'Split the semicolon-joined responses into separate requirements.', + 'EARS-W014': 'Rewrite the sentence into a clean EARS template.', + 'EARS-W015': 'Move the trailing text into the requirement or remove it.', + 'EARS-W016': + 'Replace the vague term with an observable, bounded response, or flag a question if the bound is unknown.', +}; + +/** Per-id fix rules for the diagnostics a repair run reports, in first-seen order, deduped. */ +function diagnosticFixRules(findings: Findings | undefined): string[] { + if (findings === undefined) { + return []; + } + const seen = new Set(); + const rules: string[] = []; + for (const diagnostic of findings.diagnostics) { + if (seen.has(diagnostic.id)) { + continue; + } + seen.add(diagnostic.id); + if (Object.hasOwn(FIX_BY_ID, diagnostic.id)) { + rules.push(`${diagnostic.id}: ${FIX_BY_ID[diagnostic.id]}`); + } + } + return rules; +} + +/** Inputs that shape the rule text beyond the mode. */ +export interface BuildRulesInput { + /** The active instruction mode. */ + mode: InstructionMode; + /** Whether a `--from` source is present (author and convert only). */ + hasSource: boolean; + /** The findings a repair run addresses; drives the per-id fix rules. Absent otherwise. */ + findings?: Findings; +} + +/** + * Build the ordered rule strings for one instruction step. + * + * The list is deterministic: mode intro, then (for author and convert with a + * source) the read-from-source rules, then the shared EARS authoring guidance + * for the writing modes, then the mode-specific tail. Repair appends per-id fix + * rules for the findings it carries; review lists the canonical templates so the + * agent can classify the pattern distribution it reports. + */ +export function buildRules(input: BuildRulesInput): string[] { + const { mode, hasSource, findings } = input; + const rules: string[] = [...MODE_INTRO[mode]]; + + if (hasSource && modeAcceptsSource(mode)) { + rules.push(...FROM_RULES); + } + + if (mode === 'author' || mode === 'convert') { + rules.push( + ...PATTERN_SELECTION, + ...CANONICAL_TEMPLATES, + ...COMPOUND_RULES, + ...DO_NOT_INVENT, + EDIT_RULE, + ); + } + + if (mode === 'repair') { + rules.push(...diagnosticFixRules(findings), EDIT_RULE); + } + + if (mode === 'review') { + rules.push(...CANONICAL_TEMPLATES); + } + + return rules; +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..be1a92c --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,76 @@ +/** + * Version and feature discovery. + * + * The reported `version` is the installed `@earsyntax/cli` package version, + * read from `package.json` at runtime so it never drifts from what npm shipped. + * {@link FEATURES} is the capability map agents branch on to discover the closed + * command surface without guessing. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BUILTIN_PROFILE_NAMES } from '@earsyntax/core'; +import { AGENTS } from './renderers/agents.js'; +import { HOSTS } from './renderers/hosts.js'; + +function readPackageVersion(): string { + // From dist/version.js (built) or src/version.ts (vitest), the package root + // is one directory up, and package.json lives there. + const here = dirname(fileURLToPath(import.meta.url)); + const pkgPath = join(here, '..', 'package.json'); + try { + const raw = readFileSync(pkgPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && 'version' in parsed) { + const version = (parsed as { version?: unknown }).version; + if (typeof version === 'string') { + return version; + } + } + } catch { + // Fall through to the pinned default. + } + return '0.1.0'; +} + +/** The installed package version, resolved once at module load. */ +export const CLI_VERSION = readPackageVersion(); + +/** The facade contract version. Increments only on a breaking JSON change. */ +export const FACADE_CONTRACT = 1; + +/** The capability map shape agents branch on. */ +export interface Features { + facade: number; + commands: string[]; + profiles: string[]; + instructions: string[]; + hosts: string[]; + agents: string[]; + inputFormats: string[]; + outputFormats: string[]; + sarif: boolean; +} + +/** Capability map returned by `version --features`. */ +export const FEATURES: Features = { + facade: FACADE_CONTRACT, + commands: [ + 'validate', + 'extract', + 'instructions', + 'explain', + 'profiles', + 'doctor', + 'init', + 'version', + ], + profiles: [...BUILTIN_PROFILE_NAMES], + instructions: ['author', 'convert', 'repair', 'review'], + hosts: [...HOSTS], + agents: [...AGENTS], + inputFormats: ['ears', 'text', 'markdown', 'yaml', 'json'], + outputFormats: ['pretty', 'json', 'sarif'], + sarif: true, +}; diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 0000000..3c52bf1 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/__fixtures__/**", + "node_modules", + "dist" + ], + "references": [ + { "path": "../core/tsconfig.build.json" }, + { "path": "../cli-contract/tsconfig.build.json" }, + { "path": "../extract/tsconfig.build.json" } + ] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..1e9f126 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..104510b --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + globals: false, + passWithNoTests: true, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..da06959 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,44 @@ +{ + "name": "@earsyntax/core", + "version": "0.0.1-alpha.0", + "type": "module", + "license": "Apache-2.0", + "author": "Suites", + "description": "Deterministic EARS requirements parser, linter, and diagnostics.", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "predev": "pnpm rimraf dist tsconfig.build.tsbuildinfo", + "build": "pnpm tsc -p tsconfig.build.json", + "dev": "pnpm tsc -p tsconfig.build.json --watch --incremental", + "lint": "pnpm eslint \"src/**/*.ts\"", + "lint:fix": "pnpm eslint \"src/**/*.ts\" --fix", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "pnpm tsc -p tsconfig.build.json --noEmit" + }, + "engines": { + "node": ">=22" + }, + "devDependencies": { + "@types/node": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/core/src/catalog.test.ts b/packages/core/src/catalog.test.ts new file mode 100644 index 0000000..d041751 --- /dev/null +++ b/packages/core/src/catalog.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from 'vitest'; + +import { + catalogCoverageDiagnostics, + findMatches, + isCatalogEmpty, + normalizeKey, + resolveAndCollect, + resolveTerm, +} from './catalog.js'; +import type { Catalog, EarsAst } from './types.js'; + +describe('normalizeKey', () => { + it('lowercases and collapses surrounding and internal whitespace', () => { + expect(normalizeKey(' Billing Service ')).toBe('billing service'); + }); + + it('trims leading and trailing punctuation from each token', () => { + expect(normalizeKey('Billing, Service.')).toBe('billing service'); + expect(normalizeKey('"quoted";')).toBe('quoted'); + }); + + it('returns an empty key for blank input', () => { + expect(normalizeKey('')).toBe(''); + expect(normalizeKey(' ')).toBe(''); + }); + + it('is stable across case and whitespace variants', () => { + expect(normalizeKey('REVERSE\tThrust')).toBe(normalizeKey('reverse thrust')); + }); +}); + +describe('isCatalogEmpty', () => { + it('treats an undefined catalog as empty', () => { + expect(isCatalogEmpty(undefined)).toBe(true); + }); + + it('treats a catalog with no entries in any group as empty', () => { + expect(isCatalogEmpty({})).toBe(true); + expect(isCatalogEmpty({ systems: [], events: [] })).toBe(true); + }); + + it('is not empty when any group has an entry', () => { + expect(isCatalogEmpty({ systems: [{ id: 'S1', name: 'billing service' }] })).toBe(false); + }); +}); + +describe('resolveTerm: single match', () => { + const catalog: Catalog = { + systems: [{ id: 'SYS-BILLING', name: 'billing service', aliases: ['billing'] }], + }; + + it('matches an exact canonical name with viaAlias false and no diagnostics', () => { + const { term, diagnostics } = resolveTerm('billing service', 'system', catalog, 'strict'); + expect(term.matched).toEqual({ group: 'systems', id: 'SYS-BILLING', name: 'billing service' }); + expect(term.viaAlias).toBeUndefined(); + expect(term.unresolved).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + it('matches through case and whitespace normalization', () => { + const { term } = resolveTerm(' BILLING SERVICE ', 'system', catalog, 'strict'); + expect(term.matched?.id).toBe('SYS-BILLING'); + }); + + it('flags an alias match with viaAlias and a lint.alias_used warning', () => { + const { term, diagnostics } = resolveTerm('billing', 'system', catalog, 'strict'); + expect(term.matched?.id).toBe('SYS-BILLING'); + expect(term.viaAlias).toBe(true); + expect(diagnostics).toEqual([ + { + code: 'lint.alias_used', + severity: 'warning', + message: 'alias used instead of canonical term', + }, + ]); + }); + + it('prefers the canonical name over an alias within the same entry', () => { + const dupeCatalog: Catalog = { + systems: [{ id: 'SYS-1', name: 'reverse thrust', aliases: ['reverse thrust'] }], + }; + const { term, diagnostics } = resolveTerm('reverse thrust', 'system', dupeCatalog, 'strict'); + expect(term.viaAlias).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); +}); + +describe('resolveTerm: unresolved', () => { + const catalog: Catalog = { + systems: [{ id: 'SYS-1', name: 'billing service' }], + events: [{ id: 'EV-1', name: 'timeout' }], + }; + + it('marks an unknown system unresolved as an error in strict mode without an expr code', () => { + const { term, diagnostics } = resolveTerm('unknown system', 'system', catalog, 'strict'); + expect(term.unresolved).toBe(true); + expect(term.matched).toBeUndefined(); + expect(diagnostics).toEqual([ + { code: 'catalog.system_unresolved', severity: 'error', message: 'unresolved catalog term' }, + ]); + }); + + it('keeps expr.unknown_term for unresolved clause-expression terms', () => { + const { diagnostics } = resolveTerm('never happens', 'event', catalog, 'strict'); + expect(diagnostics.map((d) => d.code)).toEqual([ + 'expr.unknown_term', + 'catalog.event_unresolved', + ]); + }); + + it('downgrades an unresolved system to a warning in guided mode', () => { + const { diagnostics } = resolveTerm('unknown system', 'system', catalog, 'guided'); + const systemDiag = diagnostics.find((d) => d.code === 'catalog.system_unresolved'); + expect(systemDiag?.severity).toBe('warning'); + }); + + it('keeps an unresolved non-system term a warning even in strict mode', () => { + const { diagnostics } = resolveTerm('never happens', 'event', catalog, 'strict'); + const eventDiag = diagnostics.find((d) => d.code === 'catalog.event_unresolved'); + expect(eventDiag?.severity).toBe('warning'); + }); + + it('attaches the span to unresolved diagnostics when one is given', () => { + const span = { start: 3, end: 9 }; + const { diagnostics } = resolveTerm('mystery', 'event', catalog, 'strict', span); + expect(diagnostics.every((d) => d.span === span)).toBe(true); + }); +}); + +describe('resolveTerm: canonical precedence over aliases', () => { + // A term that matches one entry's canonical name and another entry's alias + // resolves to the canonical entry. Aliases are considered only when no + // canonical name matches (types.ts Catalog contract: canonical, then alias, + // then ambiguous). The Go reference mixes both into an ambiguous set; this is + // an intentional deviation recorded in docs/compatibility.md. + it('prefers a canonical match over an alias in another entry', () => { + const catalog: Catalog = { + events: [{ id: 'EV-A', name: 'timeout' }], + states: [{ id: 'ST-B', name: 'session ended', aliases: ['timeout'] }], + }; + const { term, diagnostics } = resolveTerm('timeout', 'event', catalog, 'strict'); + expect(term.matched).toEqual({ group: 'events', id: 'EV-A', name: 'timeout' }); + expect(term.ambiguous).toBeUndefined(); + expect(term.viaAlias).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + it('falls back to alias matches only when no canonical name matches', () => { + const catalog: Catalog = { + events: [{ id: 'EV-A', name: 'connection timeout', aliases: ['timeout'] }], + states: [{ id: 'ST-B', name: 'session ended', aliases: ['timeout'] }], + }; + const { term } = resolveTerm('timeout', 'event', catalog, 'strict'); + expect(term.matched).toBeUndefined(); + expect(term.ambiguous?.map((ref) => ref.id)).toEqual(['EV-A', 'ST-B']); + }); +}); + +describe('resolveTerm: ambiguous', () => { + it('reports all candidates when more than one canonical name matches', () => { + const catalog: Catalog = { + events: [{ id: 'EV-A', name: 'timeout' }], + states: [{ id: 'ST-B', name: 'timeout' }], + }; + const { term, diagnostics } = resolveTerm('timeout', 'event', catalog, 'strict'); + expect(term.matched).toBeUndefined(); + expect(term.ambiguous).toEqual([ + { group: 'events', id: 'EV-A', name: 'timeout' }, + { group: 'states', id: 'ST-B', name: 'timeout' }, + ]); + expect(diagnostics).toEqual([ + { + code: 'expr.ambiguous_term', + severity: 'warning', + message: 'ambiguous catalog term in expression', + }, + { + code: 'catalog.event_ambiguous', + severity: 'warning', + message: 'ambiguous catalog term match', + }, + ]); + }); + + it('reports an ambiguous system with only its catalog code', () => { + const catalog: Catalog = { + systems: [ + { id: 'SYS-A', name: 'core' }, + { id: 'SYS-B', name: 'core' }, + ], + }; + const { term, diagnostics } = resolveTerm('core', 'system', catalog, 'strict'); + expect(term.ambiguous?.map((ref) => ref.id)).toEqual(['SYS-A', 'SYS-B']); + expect(diagnostics).toEqual([ + { + code: 'catalog.system_ambiguous', + severity: 'error', + message: 'ambiguous catalog term match', + }, + ]); + }); + + it('orders ambiguous candidates by group then id deterministically', () => { + const catalog: Catalog = { + events: [ + { id: 'EV-9', name: 'spike' }, + { id: 'EV-1', name: 'spike' }, + ], + }; + const { term } = resolveTerm('spike', 'event', catalog, 'strict'); + expect(term.ambiguous?.map((ref) => ref.id)).toEqual(['EV-1', 'EV-9']); + }); +}); + +describe('findMatches: role-scoped groups', () => { + const catalog: Catalog = { + systems: [{ id: 'SYS-1', name: 'shared' }], + events: [{ id: 'EV-1', name: 'shared' }], + states: [{ id: 'ST-1', name: 'shared' }], + features: [{ id: 'FT-1', name: 'shared' }], + }; + + it('restricts the system role to the systems group only', () => { + const matches = findMatches('shared', 'system', catalog); + expect(matches.map((m) => m.ref.group)).toEqual(['systems']); + }); + + it('lets an event term reach events and states but not systems', () => { + const groups = findMatches('shared', 'event', catalog).map((m) => m.ref.group); + expect(groups).toContain('events'); + expect(groups).toContain('states'); + expect(groups).not.toContain('systems'); + }); + + it('returns no matches for a blank term', () => { + expect(findMatches(' ', 'event', catalog)).toEqual([]); + }); + + it('returns only canonical matches when a term is canonical here and an alias there', () => { + const mixed: Catalog = { + events: [{ id: 'EV-A', name: 'timeout' }], + states: [{ id: 'ST-B', name: 'session ended', aliases: ['timeout'] }], + }; + const matches = findMatches('timeout', 'event', mixed); + expect(matches).toHaveLength(1); + expect(matches[0].ref.id).toBe('EV-A'); + expect(matches[0].viaAlias).toBe(false); + }); +}); + +describe('resolveTerm: no-catalog mode', () => { + it('skips matching for an undefined catalog', () => { + const { term, diagnostics } = resolveTerm('anything', 'system', undefined, 'strict'); + expect(term).toEqual({ raw: 'anything', role: 'system' }); + expect(diagnostics).toEqual([]); + }); + + it('skips matching for a catalog with no entries', () => { + const { term, diagnostics } = resolveTerm('anything', 'event', {}, 'strict'); + expect(term.matched).toBeUndefined(); + expect(term.unresolved).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); +}); + +describe('resolveAndCollect', () => { + const catalog: Catalog = { + systems: [{ id: 'SYS-1', name: 'billing service' }], + events: [{ id: 'EV-1', name: 'timeout' }], + }; + + function buildAst(): EarsAst { + return { + pattern: 'event-driven', + system: { raw: 'billing service', role: 'system' }, + trigger: { + kind: 'and', + span: { start: 0, end: 24 }, + items: [ + { kind: 'term', text: 'timeout', span: { start: 0, end: 7 } }, + { kind: 'term', text: 'ghost event', span: { start: 12, end: 23 } }, + ], + }, + responses: ['do x'], + raw: 'When timeout and ghost event, billing service shall do x', + }; + } + + it('fills the system term on the returned ast and collects it as a reference', () => { + const ast = buildAst(); + const { ast: resolved, references } = resolveAndCollect(ast, catalog); + expect(resolved.system.matched?.id).toBe('SYS-1'); + const systemRef = references.find((ref) => ref.clause === 'system'); + expect(systemRef?.matched?.id).toBe('SYS-1'); + }); + + it('fills clause term nodes on the returned ast', () => { + const ast = buildAst(); + const { ast: resolved } = resolveAndCollect(ast, catalog); + const items = resolved.trigger?.kind === 'and' ? resolved.trigger.items : []; + const timeout = items[0]; + expect(timeout.kind === 'term' ? timeout.term?.matched?.id : undefined).toBe('EV-1'); + }); + + it('does not mutate the input ast', () => { + const ast = buildAst(); + const before = structuredClone(ast); + resolveAndCollect(ast, catalog); + expect(ast).toEqual(before); + expect(ast.system.matched).toBeUndefined(); + const items = ast.trigger?.kind === 'and' ? ast.trigger.items : []; + const timeout = items[0]; + expect(timeout.kind === 'term' ? timeout.term : undefined).toBeUndefined(); + }); + + it('returns a fresh responses array rather than aliasing the input', () => { + const ast = buildAst(); + const { ast: resolved } = resolveAndCollect(ast, catalog); + expect(resolved.responses).toEqual(ast.responses); + // The output must not share the input array: a later mutation of one must + // not be observable through the other. + expect(resolved.responses).not.toBe(ast.responses); + }); + + it('orders references by span start with spanless system reference last', () => { + const ast = buildAst(); + const { references } = resolveAndCollect(ast, catalog); + expect(references.map((ref) => ref.clause)).toEqual(['trigger', 'trigger', 'system']); + expect(references[0].text).toBe('timeout'); + expect(references[1].text).toBe('ghost event'); + }); + + it('warns when a clause mixes resolved and unresolved terms', () => { + const ast = buildAst(); + const { diagnostics } = resolveAndCollect(ast, catalog); + const mixed = diagnostics.find((d) => d.code === 'expr.mixed_unresolved_terms'); + expect(mixed).toBeDefined(); + expect(mixed?.severity).toBe('warning'); + expect(mixed?.span).toEqual({ start: 0, end: 24 }); + }); + + it('resolves the unwanted (If) clause under the event role', () => { + const ast: EarsAst = { + pattern: 'unwanted-behaviour', + system: { raw: 'billing service', role: 'system' }, + unwanted: { kind: 'term', text: 'timeout', span: { start: 3, end: 10 } }, + responses: ['do x'], + raw: 'If timeout, the billing service shall do x', + }; + const { references } = resolveAndCollect(ast, catalog); + const unwantedRef = references.find((ref) => ref.clause === 'unwanted'); + expect(unwantedRef?.role).toBe('event'); + expect(unwantedRef?.matched?.id).toBe('EV-1'); + }); + + it('skips catalog matching entirely in no-catalog mode', () => { + const ast = buildAst(); + const { references, diagnostics } = resolveAndCollect(ast, undefined); + expect(diagnostics).toEqual([]); + expect( + references.every((ref) => ref.matched === undefined && ref.unresolved === undefined), + ).toBe(true); + }); +}); + +describe('catalogCoverageDiagnostics', () => { + const catalog: Catalog = { + systems: [{ id: 'SYS-1', name: 'billing service' }], + events: [ + { id: 'EV-1', name: 'timeout', aliases: ['time out'] }, + { id: 'EV-2', name: 'reverse thrust' }, + ], + }; + + it('flags catalog entries that no text references', () => { + const diagnostics = catalogCoverageDiagnostics( + ['The billing service shall react to a timeout.'], + catalog, + { mode: 'strict' }, + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toEqual({ + code: 'catalog.term_unreferenced', + severity: 'warning', + message: + 'catalog events term "reverse thrust" (EV-2) is not referenced by any requirement text', + }); + }); + + it('counts an alias occurrence as coverage', () => { + const diagnostics = catalogCoverageDiagnostics( + ['The billing service shall react to a time out event and reverse thrust.'], + catalog, + { mode: 'strict' }, + ); + expect(diagnostics.map((d) => d.message)).not.toContain( + 'catalog events term "timeout" (EV-1) is not referenced by any requirement text', + ); + }); + + it('requires whole-phrase matches on word boundaries', () => { + const diagnostics = catalogCoverageDiagnostics( + ['The billing service shall log a timeouts spike.'], + { events: [{ id: 'EV-1', name: 'timeout' }] }, + { mode: 'strict' }, + ); + expect(diagnostics.map((d) => d.code)).toContain('catalog.term_unreferenced'); + }); + + it('returns nothing in guided mode', () => { + expect(catalogCoverageDiagnostics(['anything'], catalog, { mode: 'guided' })).toEqual([]); + }); + + it('returns nothing when there are no texts', () => { + expect(catalogCoverageDiagnostics([], catalog, { mode: 'strict' })).toEqual([]); + }); + + it('returns nothing when no catalog is supplied', () => { + expect(catalogCoverageDiagnostics(['anything'], undefined, { mode: 'strict' })).toEqual([]); + }); + + it('defaults to strict mode when no options are given', () => { + const diagnostics = catalogCoverageDiagnostics(['unrelated text'], { + systems: [{ id: 'SYS-1', name: 'billing service' }], + }); + expect(diagnostics).toHaveLength(1); + }); + + it('produces a stably ordered result', () => { + const many: Catalog = { + systems: [ + { id: 'SYS-2', name: 'zeta' }, + { id: 'SYS-1', name: 'alpha' }, + ], + }; + const diagnostics = catalogCoverageDiagnostics(['nothing here'], many, { mode: 'strict' }); + const messages = diagnostics.map((d) => d.message); + expect([...messages]).toEqual([...messages].sort()); + }); +}); diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts new file mode 100644 index 0000000..3bb65d8 --- /dev/null +++ b/packages/core/src/catalog.ts @@ -0,0 +1,653 @@ +/** + * Deterministic catalog term matching for `@earsyntax/core`. + * + * This module ports the matching behavior of the Go reference implementation + * (`ears-lint-go/catalog_match.go` and `coverage.go`) to TypeScript. It answers + * one question for a raw term: does it match a catalog entry by exact canonical + * name, by exact alias, ambiguously (more than one entry), or not at all. + * + * Matching is strictly deterministic. There is no fuzzy or semantic matching: + * only case-insensitive, whitespace-collapsed, punctuation-trimmed equality of + * normalized keys. Candidate lists are stably ordered by group then id so the + * same input always yields the same output. + * + * No-catalog mode: when no catalog is supplied, or the supplied catalog has no + * entries in any group, matching is skipped. A term resolved in this mode is + * neither matched nor unresolved (it carries only `raw` and `role`), matching + * the frozen `TermMatch` contract in {@link ./types.js}. This differs from the + * literal Go reference, which has no notion of an absent catalog and would mark + * every term unresolved against an empty catalog. The behavior here follows the + * TypeScript contract and the orchestration brief, where an absent-or-empty + * catalog means "nothing to match against". + */ + +import { sortDiagnostics } from './diagnostics.js'; +import type { + Catalog, + CatalogEntry, + CatalogRef, + ClauseExpr, + Diagnostic, + DiagnosticCode, + EarsAst, + Mode, + Options, + ReferenceMatch, + Severity, + Span, + TermMatch, + TermRole, +} from './types.js'; + +/** Sentinel start offset used when a diagnostic or reference has no span. */ +const NO_SPAN_START = (1 << 30) - 1; + +/** Punctuation trimmed from the ends of each token during normalization. */ +const TRIM_PUNCTUATION = /^[.,;:!?"'`]+|[.,;:!?"'`]+$/g; + +/** + * A single catalog match candidate: the resolved reference, the role of the + * group it came from, and whether it matched via an alias. + */ +interface MatchCandidate { + ref: CatalogRef; + role: TermRole; + viaAlias: boolean; +} + +/** A catalog group paired with its group name and semantic role. */ +interface GroupEntries { + group: string; + role: TermRole; + entries: CatalogEntry[]; +} + +/** + * The outcome of resolving a single raw term: the filled {@link TermMatch} and + * the raw diagnostics produced while resolving it (unsorted, unmerged). + */ +export interface TermResolution { + term: TermMatch; + diagnostics: Diagnostic[]; +} + +/** + * The outcome of resolving an entire requirement AST. + * + * `ast` is a new object: the resolver never mutates its input. `references` + * lists every catalog reference found (in stable order) and `diagnostics` + * holds the catalog diagnostics (stably sorted). + */ +export interface ResolveResult { + ast: EarsAst; + references: ReferenceMatch[]; + diagnostics: Diagnostic[]; +} + +/** + * Normalize a term into its comparison key. + * + * Ports `normalizeKey` from the Go reference: trim, lowercase, split on any + * whitespace run, trim leading and trailing punctuation from each token, then + * rejoin with single spaces. Tokens that become empty after punctuation + * trimming are preserved as empty (matching the Go `strings.Join` behavior), + * so the key is a faithful, deterministic transform of the input. + */ +export function normalizeKey(text: string): string { + const trimmed = text.trim(); + if (trimmed === '') { + return ''; + } + const parts = trimmed.toLowerCase().split(/\s+/); + return parts.map((part) => part.replace(TRIM_PUNCTUATION, '')).join(' '); +} + +/** + * Return `true` when there is nothing to match against: no catalog, or a + * catalog whose every group is absent or empty. In this state the matcher + * operates in no-catalog mode and skips matching entirely. + */ +export function isCatalogEmpty(catalog?: Catalog): boolean { + if (!catalog) { + return true; + } + const groups = [ + catalog.systems, + catalog.actors, + catalog.events, + catalog.states, + catalog.features, + catalog.modes, + catalog.conditions, + catalog.dataTerms, + ]; + return groups.every((group) => !group || group.length === 0); +} + +/** + * Build the full ordered list of catalog groups, substituting an empty array + * for any group the catalog omits. + */ +function allGroups(catalog: Catalog): GroupEntries[] { + return [ + { group: 'systems', role: 'system', entries: catalog.systems ?? [] }, + { group: 'actors', role: 'actor', entries: catalog.actors ?? [] }, + { group: 'events', role: 'event', entries: catalog.events ?? [] }, + { group: 'states', role: 'state', entries: catalog.states ?? [] }, + { group: 'features', role: 'feature', entries: catalog.features ?? [] }, + { group: 'modes', role: 'mode', entries: catalog.modes ?? [] }, + { group: 'conditions', role: 'condition', entries: catalog.conditions ?? [] }, + { group: 'dataTerms', role: 'data-term', entries: catalog.dataTerms ?? [] }, + ]; +} + +/** + * Select which catalog groups a term of the given role may match, in the same + * priority order as the Go reference `allowedGroups`. The final candidate order + * is fixed later by a stable sort on group then id, so this ordering only + * affects which groups are searched, not the reported order. + */ +function allowedGroups(role: TermRole, all: GroupEntries[]): GroupEntries[] { + // Index legend: 0 systems, 1 actors, 2 events, 3 states, 4 features, + // 5 modes, 6 conditions, 7 dataTerms. + if (role === 'system') { + return [all[0]]; + } + if (role === 'feature') { + return [all[4], all[5], all[6], all[7]]; + } + if (role === 'event') { + return [all[2], all[6], all[3], all[5], all[1], all[4], all[7]]; + } + if (role === 'state') { + return [all[3], all[6], all[5], all[7], all[1], all[2], all[4]]; + } + return all; +} + +/** + * Find every catalog entry a raw term matches, across the groups allowed for + * its role. Exact canonical name matches are recorded first; an entry is also + * checked against its aliases, but only when its canonical name did not match + * (mirroring the Go `continue`). Duplicate entries are removed. + */ +export function findMatches( + raw: string, + requestedRole: TermRole, + catalog: Catalog, +): MatchCandidate[] { + const key = normalizeKey(raw); + if (key === '') { + return []; + } + const groups = allowedGroups(requestedRole, allGroups(catalog)); + // Canonical name matches take precedence over alias matches (the documented + // TermMatch contract in types.ts: canonical, then alias, then ambiguous). An + // alias match is considered only when no entry matched by canonical name, so + // a term that names one entry canonically and another by alias resolves to + // the canonical entry rather than being reported ambiguous. This is an + // intentional deviation from the Go reference, which mixes both into one + // candidate set; see docs/compatibility.md. + const canonical: MatchCandidate[] = []; + const aliased: MatchCandidate[] = []; + for (const group of groups) { + for (const entry of group.entries) { + if (normalizeKey(entry.name) === key) { + canonical.push({ + ref: { group: group.group, id: entry.id, name: entry.name }, + role: group.role, + viaAlias: false, + }); + continue; + } + for (const alias of entry.aliases ?? []) { + if (normalizeKey(alias) === key) { + aliased.push({ + ref: { group: group.group, id: entry.id, name: entry.name }, + role: group.role, + viaAlias: true, + }); + break; + } + } + } + } + return dedupeMatches(canonical.length > 0 ? canonical : aliased); +} + +/** Remove duplicate candidates by group and id, keeping the first occurrence. */ +function dedupeMatches(matches: MatchCandidate[]): MatchCandidate[] { + const seen = new Set(); + const out: MatchCandidate[] = []; + for (const candidate of matches) { + const key = `${candidate.ref.group}:${candidate.ref.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(candidate); + } + return out; +} + +/** Order candidates deterministically by group, then id. */ +function sortCandidates(matches: MatchCandidate[]): void { + matches.sort((a, b) => { + if (a.ref.group !== b.ref.group) { + return a.ref.group < b.ref.group ? -1 : 1; + } + if (a.ref.id !== b.ref.id) { + return a.ref.id < b.ref.id ? -1 : 1; + } + return 0; + }); +} + +/** `guided` downgrades system errors to warnings; `strict` keeps them errors. */ +function severityByMode(mode: Mode): Severity { + return mode === 'guided' ? 'warning' : 'error'; +} + +/** + * Severity for a catalog `unresolved` or `ambiguous` diagnostic: mode-driven + * for the system role (error in strict, warning in guided), always a warning + * for every other role. + */ +function roleSeverity(role: TermRole, mode: Mode): Severity { + return role === 'system' ? severityByMode(mode) : 'warning'; +} + +/** + * Build the `catalog._` diagnostic code for a role. The cast is + * safe: this is only ever called with the system, state, event, or feature + * roles, which are the only roles clauses assign, and each has a registered + * code family in {@link DiagnosticCode}. + */ +function catalogCode(role: TermRole, suffix: 'unresolved' | 'ambiguous'): DiagnosticCode { + const key = role.replace(/-/g, '_'); + return `catalog.${key}_${suffix}` as DiagnosticCode; +} + +/** Construct a diagnostic, attaching the span only when one is known. */ +function makeDiagnostic( + code: DiagnosticCode, + severity: Severity, + message: string, + span?: Span, +): Diagnostic { + return span ? { code, severity, message, span } : { code, severity, message }; +} + +/** + * Resolve a single raw term against the catalog under a requested role. + * + * Returns the filled {@link TermMatch} plus the raw diagnostics the match + * produced. Exactly one outcome applies: a single `matched` entry (with an + * `lint.alias_used` warning when matched via an alias), a non-empty `ambiguous` + * list, or `unresolved`. In no-catalog mode the term is returned untouched + * beyond its `raw` and `role`, with no diagnostics. + */ +export function resolveTerm( + raw: string, + requestedRole: TermRole, + catalog: Catalog | undefined, + mode: Mode, + span?: Span, +): TermResolution { + const term: TermMatch = { raw, role: requestedRole }; + const diagnostics: Diagnostic[] = []; + + if (!catalog || isCatalogEmpty(catalog)) { + return { term, diagnostics }; + } + + const matches = findMatches(raw, requestedRole, catalog); + sortCandidates(matches); + + if (matches.length === 1) { + const match = matches[0]; + // Keep the requested role on the TermMatch. Per the TermMatch contract in + // types.ts, `role` is the role the term was expected to play, not the role + // of the group it happened to match. The matched group is still recorded in + // `matched.group`. (The Go reference overwrites the role here; deviation + // noted in docs/compatibility.md.) + term.matched = match.ref; + if (match.viaAlias) { + term.viaAlias = true; + diagnostics.push( + makeDiagnostic('lint.alias_used', 'warning', 'alias used instead of canonical term', span), + ); + } + return { term, diagnostics }; + } + + if (matches.length > 1) { + term.ambiguous = matches.map((match) => match.ref); + // The system term reports only its catalog code; the expr.* term codes are + // reserved for terms inside clause expressions (intentional deviation from + // Go, which emits both for every role). + if (requestedRole !== 'system') { + diagnostics.push( + makeDiagnostic( + 'expr.ambiguous_term', + 'warning', + 'ambiguous catalog term in expression', + span, + ), + ); + } + diagnostics.push( + makeDiagnostic( + catalogCode(requestedRole, 'ambiguous'), + roleSeverity(requestedRole, mode), + 'ambiguous catalog term match', + span, + ), + ); + return { term, diagnostics }; + } + + term.unresolved = true; + // See the ambiguous branch: the system term omits the expr.* term code. + if (requestedRole !== 'system') { + diagnostics.push( + makeDiagnostic('expr.unknown_term', 'warning', 'unknown term in expression', span), + ); + } + diagnostics.push( + makeDiagnostic( + catalogCode(requestedRole, 'unresolved'), + roleSeverity(requestedRole, mode), + 'unresolved catalog term', + span, + ), + ); + return { term, diagnostics }; +} + +/** Build a reference match from a resolved term, omitting empty optionals. */ +function makeReference(clause: string, term: TermMatch, span?: Span): ReferenceMatch { + const reference: ReferenceMatch = { clause, text: term.raw, role: term.role }; + if (term.matched) { + reference.matched = term.matched; + } + if (term.ambiguous) { + reference.ambiguous = term.ambiguous; + } + if (term.unresolved) { + reference.unresolved = true; + } + if (term.viaAlias) { + reference.viaAlias = true; + } + if (span) { + reference.span = span; + } + return reference; +} + +/** Return a copy of a clause node with its span attached, when one is known. */ +function withSpan(node: T, span?: Span): T { + return span ? { ...node, span } : node; +} + +/** + * Resolve every term in a clause expression, returning a new expression tree + * with each term node's match result filled and one {@link ReferenceMatch} per + * term. The input tree is never mutated. + * + * When the clause mixes resolved and unresolved terms an + * `expr.mixed_unresolved_terms` warning is added, spanning the whole clause. + */ +function resolveExpr( + clauseName: string, + expr: ClauseExpr, + role: TermRole, + catalog: Catalog | undefined, + mode: Mode, +): { node: ClauseExpr; references: ReferenceMatch[]; diagnostics: Diagnostic[] } { + const references: ReferenceMatch[] = []; + const diagnostics: Diagnostic[] = []; + let resolvedCount = 0; + let unresolvedCount = 0; + + const rebuild = (node: ClauseExpr): ClauseExpr => { + if (node.kind === 'term') { + const { term, diagnostics: termDiagnostics } = resolveTerm( + node.text, + role, + catalog, + mode, + node.span, + ); + diagnostics.push(...termDiagnostics); + if (term.matched) { + resolvedCount += 1; + } + if (term.unresolved) { + unresolvedCount += 1; + } + references.push(makeReference(clauseName, term, node.span)); + return withSpan({ kind: 'term', text: node.text, term }, node.span); + } + if (node.kind === 'not') { + return withSpan({ kind: 'not', item: rebuild(node.item) }, node.span); + } + if (node.kind === 'group') { + return withSpan({ kind: 'group', item: rebuild(node.item) }, node.span); + } + if (node.kind === 'and') { + return withSpan({ kind: 'and', items: node.items.map(rebuild) }, node.span); + } + if (node.kind === 'or') { + return withSpan({ kind: 'or', items: node.items.map(rebuild) }, node.span); + } + return withSpan({ kind: 'free-text', text: node.text }, node.span); + }; + + const node = rebuild(expr); + + if (resolvedCount > 0 && unresolvedCount > 0) { + diagnostics.push( + makeDiagnostic( + 'expr.mixed_unresolved_terms', + 'warning', + 'expression mixes resolved and unresolved terms', + expr.span, + ), + ); + } + + return { node, references, diagnostics }; +} + +/** Stable sort of references by span start, then clause, then text. */ +function sortReferences(references: ReferenceMatch[]): ReferenceMatch[] { + const out = [...references]; + out.sort((a, b) => { + const aStart = a.span ? a.span.start : NO_SPAN_START; + const bStart = b.span ? b.span.start : NO_SPAN_START; + if (aStart !== bStart) { + return aStart - bStart; + } + if (a.clause !== b.clause) { + return a.clause < b.clause ? -1 : 1; + } + if (a.text !== b.text) { + return a.text < b.text ? -1 : 1; + } + return 0; + }); + return out; +} + +/** + * Resolve every catalog reference in a requirement AST. + * + * Pure: the input AST is never mutated. Returns a new {@link EarsAst} whose + * `system` and clause term nodes carry their match results, plus every + * reference found (stably ordered) and the catalog diagnostics (stably sorted). + * + * Clause roles follow the Go reference: preconditions are states, triggers are + * events, features are features. The `unwanted` (If) clause, which the frozen + * TypeScript AST carries as a distinct field, is resolved under the event role, + * matching the Go `roleForClause` mapping for If clauses. It is a no-op when + * the field is absent. + */ +export function resolveAndCollect( + ast: EarsAst, + catalog: Catalog | undefined, + options?: Options, +): ResolveResult { + const mode: Mode = options?.mode ?? 'strict'; + const references: ReferenceMatch[] = []; + const diagnostics: Diagnostic[] = []; + + const systemResolution = resolveTerm(ast.system.raw, 'system', catalog, mode); + diagnostics.push(...systemResolution.diagnostics); + references.push(makeReference('system', systemResolution.term)); + + const resolveClause = ( + name: string, + expr: ClauseExpr | undefined, + role: TermRole, + ): ClauseExpr | undefined => { + if (!expr) { + return undefined; + } + const result = resolveExpr(name, expr, role, catalog, mode); + references.push(...result.references); + diagnostics.push(...result.diagnostics); + return result.node; + }; + + const preconditions = resolveClause('preconditions', ast.preconditions, 'state'); + const trigger = resolveClause('trigger', ast.trigger, 'event'); + const feature = resolveClause('feature', ast.feature, 'feature'); + const unwanted = resolveClause('unwanted', ast.unwanted, 'event'); + + const nextAst: EarsAst = { + pattern: ast.pattern, + system: systemResolution.term, + // Copy the responses so the returned AST never aliases the input array; the + // resolver's contract is that it never mutates or shares its input. + responses: [...ast.responses], + raw: ast.raw, + ...(preconditions ? { preconditions } : {}), + ...(trigger ? { trigger } : {}), + ...(feature ? { feature } : {}), + ...(unwanted ? { unwanted } : {}), + }; + + return { + ast: nextAst, + references: sortReferences(references), + diagnostics: sortDiagnostics(diagnostics), + }; +} + +/** + * Report catalog entries no requirement text references. + * + * Ports the Go coverage lint. Emits a `catalog.term_unreferenced` warning for + * each entry whose canonical name and none of its aliases appear as a whole + * phrase (case-insensitive, on non-alphanumeric boundaries) in any of the + * supplied requirement texts. Coverage runs only in strict mode and only when + * at least one text is supplied; otherwise it returns an empty list. + */ +export function catalogCoverageDiagnostics( + texts: string[], + catalog: Catalog | undefined, + options?: Options, +): Diagnostic[] { + const mode: Mode = options?.mode ?? 'strict'; + if (!catalog || texts.length === 0 || mode !== 'strict') { + return []; + } + + const lowered = texts.map((text) => text.trim().toLowerCase()); + const out: Diagnostic[] = []; + + const add = (group: string, entries?: CatalogEntry[]): void => { + for (const entry of entries ?? []) { + const id = entry.id.trim(); + const name = entry.name.trim(); + if (id === '' || name === '') { + continue; + } + if (catalogEntryCovered(lowered, entry)) { + continue; + } + out.push({ + code: 'catalog.term_unreferenced', + severity: 'warning', + message: `catalog ${group} term "${name}" (${id}) is not referenced by any requirement text`, + }); + } + }; + + add('systems', catalog.systems); + add('actors', catalog.actors); + add('events', catalog.events); + add('states', catalog.states); + add('features', catalog.features); + add('modes', catalog.modes); + add('conditions', catalog.conditions); + add('dataTerms', catalog.dataTerms); + + return sortDiagnostics(out); +} + +/** `true` when the entry's name or any alias appears as a phrase in any text. */ +function catalogEntryCovered(texts: string[], entry: CatalogEntry): boolean { + const candidates = [entry.name.trim(), ...(entry.aliases ?? [])]; + for (const candidate of candidates) { + const term = candidate.trim().toLowerCase(); + if (term === '') { + continue; + } + for (const text of texts) { + if (containsPhrase(text, term)) { + return true; + } + } + } + return false; +} + +/** + * `true` when `phrase` occurs in `text` bounded by non-alphanumeric characters + * (or string edges) on both sides. Both arguments are expected already + * lowercased. Ports the Go `containsPhrase` scan. + */ +function containsPhrase(text: string, phrase: string): boolean { + if (text === '' || phrase === '') { + return false; + } + let from = 0; + for (;;) { + const index = text.indexOf(phrase, from); + if (index < 0) { + return false; + } + const end = index + phrase.length; + if (phraseBoundary(text, index - 1) && phraseBoundary(text, end)) { + return true; + } + from = end; + if (from >= text.length) { + return false; + } + } +} + +/** A position is a phrase boundary when it is off the string or non-alphanumeric. */ +function phraseBoundary(text: string, index: number): boolean { + if (index < 0 || index >= text.length) { + return true; + } + const code = text.charCodeAt(index); + const isLower = code >= 97 && code <= 122; // a-z + const isDigit = code >= 48 && code <= 57; // 0-9 + return !(isLower || isDigit); +} diff --git a/packages/core/src/diagnostics.test.ts b/packages/core/src/diagnostics.test.ts new file mode 100644 index 0000000..f65c80a --- /dev/null +++ b/packages/core/src/diagnostics.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDiagnostic, + computeValid, + dedupeDiagnostics, + MODE_DEPENDENT_CODES, + messageForCode, + severityByMode, + severityForCode, + sortDiagnostics, +} from './diagnostics.js'; +import type { Diagnostic, DiagnosticCode, Span } from './types.js'; + +const ALL_CODES = [ + 'ears.no_match', + 'ears.invalid_clause_order', + 'ears.missing_system', + 'ears.missing_shall', + 'ears.multiple_shall', + 'ears.invalid_if_then_form', + 'ears.empty_clause', + 'ears.empty_response', + 'expr.unbalanced_parentheses', + 'expr.invalid_operator_sequence', + 'expr.empty_subexpression', + 'expr.operator_precedence_warning', + 'expr.unknown_term', + 'expr.ambiguous_term', + 'expr.mixed_unresolved_terms', + 'catalog.system_unresolved', + 'catalog.system_ambiguous', + 'catalog.state_unresolved', + 'catalog.state_ambiguous', + 'catalog.event_unresolved', + 'catalog.event_ambiguous', + 'catalog.feature_unresolved', + 'catalog.feature_ambiguous', + 'catalog.term_unreferenced', + 'lint.multiple_responses', + 'lint.vague_response', + 'lint.unparsed_tail', + 'lint.alias_used', + 'lint.suspicious_text_shape', + 'ears.keyword_case', + 'ears.missing_leading_comma', + 'ears.prohibition_not_allowed', +] as const satisfies readonly DiagnosticCode[]; + +const STRUCTURAL_CODES = [ + 'ears.no_match', + 'ears.invalid_clause_order', + 'ears.missing_system', + 'ears.missing_shall', + 'ears.multiple_shall', + 'ears.invalid_if_then_form', + 'ears.empty_clause', + 'ears.empty_response', + 'expr.unbalanced_parentheses', + 'expr.invalid_operator_sequence', + 'expr.empty_subexpression', +] as const satisfies readonly DiagnosticCode[]; + +const SYSTEM_CATALOG_CODES = [ + 'catalog.system_unresolved', + 'catalog.system_ambiguous', +] as const satisfies readonly DiagnosticCode[]; + +const HOST_NATIVE_CODES = [ + 'ears.keyword_case', + 'ears.missing_leading_comma', + 'ears.prohibition_not_allowed', +] as const satisfies readonly DiagnosticCode[]; + +const NON_SYSTEM_CATALOG_CODES = [ + 'catalog.state_unresolved', + 'catalog.state_ambiguous', + 'catalog.event_unresolved', + 'catalog.event_ambiguous', + 'catalog.feature_unresolved', + 'catalog.feature_ambiguous', + 'catalog.term_unreferenced', +] as const satisfies readonly DiagnosticCode[]; + +const LINT_CODES = [ + 'lint.multiple_responses', + 'lint.vague_response', + 'lint.unparsed_tail', + 'lint.alias_used', + 'lint.suspicious_text_shape', +] as const satisfies readonly DiagnosticCode[]; + +const EXPR_WARNING_CODES = [ + 'expr.operator_precedence_warning', + 'expr.unknown_term', + 'expr.ambiguous_term', + 'expr.mixed_unresolved_terms', +] as const satisfies readonly DiagnosticCode[]; + +const span = (start: number, end: number): Span => ({ start, end }); + +const startOf = (d: Diagnostic): number | undefined => (d.span ? d.span.start : undefined); +const endOf = (d: Diagnostic): number | undefined => (d.span ? d.span.end : undefined); + +describe('severityByMode', () => { + it('is error in strict mode and warning in guided mode', () => { + expect(severityByMode('strict')).toBe('error'); + expect(severityByMode('guided')).toBe('warning'); + }); +}); + +describe('severityForCode', () => { + it('makes structural failures errors in strict and warnings in guided', () => { + for (const code of STRUCTURAL_CODES) { + expect(severityForCode(code, 'strict')).toBe('error'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('makes system catalog failures errors in strict and warnings in guided', () => { + for (const code of SYSTEM_CATALOG_CODES) { + expect(severityForCode(code, 'strict')).toBe('error'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('makes host-native grammar failures errors in strict and warnings in guided', () => { + for (const code of HOST_NATIVE_CODES) { + expect(severityForCode(code, 'strict')).toBe('error'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('keeps non-system catalog failures warnings in both modes', () => { + for (const code of NON_SYSTEM_CATALOG_CODES) { + expect(severityForCode(code, 'strict')).toBe('warning'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('keeps lint findings warnings in both modes', () => { + for (const code of LINT_CODES) { + expect(severityForCode(code, 'strict')).toBe('warning'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('keeps expression precedence and term warnings warnings in both modes', () => { + for (const code of EXPR_WARNING_CODES) { + expect(severityForCode(code, 'strict')).toBe('warning'); + expect(severityForCode(code, 'guided')).toBe('warning'); + } + }); + + it('classifies every registered code as either mode-dependent or warning', () => { + for (const code of ALL_CODES) { + const inSet = MODE_DEPENDENT_CODES.has(code); + expect(severityForCode(code, 'strict')).toBe(inSet ? 'error' : 'warning'); + } + }); + + it('MODE_DEPENDENT_CODES contains exactly the structural, system catalog, and host-native codes', () => { + const expected = [...STRUCTURAL_CODES, ...SYSTEM_CATALOG_CODES, ...HOST_NATIVE_CODES].sort(); + const actual = [...MODE_DEPENDENT_CODES].sort(); + expect(actual).toEqual(expected); + }); +}); + +describe('messageForCode', () => { + it('produces a non-empty single sentence for every code', () => { + for (const code of ALL_CODES) { + const message = messageForCode(code); + expect(message.length).toBeGreaterThan(0); + expect(message.endsWith('.')).toBe(true); + } + }); + + it('never emits an em dash', () => { + for (const code of ALL_CODES) { + expect( + messageForCode(code, { term: 'x', clause: 'while', alias: 'a', canonical: 'b' }), + ).not.toContain('—'); + } + }); + + it('interpolates the offending term when provided', () => { + expect(messageForCode('catalog.system_unresolved', { term: 'billing service' })).toContain( + 'billing service', + ); + expect(messageForCode('expr.unknown_term', { term: 'the widget spins' })).toContain( + 'the widget spins', + ); + expect(messageForCode('lint.vague_response', { term: 'appropriate' })).toContain('appropriate'); + }); + + it('falls back to a generic phrasing without context', () => { + expect(messageForCode('catalog.system_unresolved')).toBe( + 'The system name does not match any known system.', + ); + expect(messageForCode('expr.unknown_term')).toBe( + 'A clause term does not match any known catalog entry.', + ); + }); + + it('interpolates the clause keyword for clause-scoped codes', () => { + expect(messageForCode('ears.empty_clause', { clause: 'while' })).toContain("'while'"); + expect(messageForCode('ears.invalid_clause_order', { clause: 'if' })).toContain("'if'"); + }); + + it('interpolates alias and canonical name for lint.alias_used', () => { + expect(messageForCode('lint.alias_used', { alias: 'db', canonical: 'Postgres' })).toBe( + 'The catalog alias "db" matched; prefer the canonical name "Postgres".', + ); + expect(messageForCode('lint.alias_used', { term: 'db' })).toContain('db'); + expect(messageForCode('lint.alias_used')).toBe( + 'A catalog alias matched; prefer the canonical name.', + ); + }); +}); + +describe('buildDiagnostic', () => { + it('builds a strict error for a structural finding with its span', () => { + const d = buildDiagnostic({ code: 'ears.missing_shall', span: span(0, 5) }, 'strict'); + expect(d).toEqual({ + code: 'ears.missing_shall', + severity: 'error', + message: "The requirement does not contain exactly one 'shall' response boundary.", + span: { start: 0, end: 5 }, + }); + }); + + it('downgrades a structural finding to a warning in guided mode', () => { + const d = buildDiagnostic({ code: 'ears.missing_shall' }, 'guided'); + expect(d.severity).toBe('warning'); + expect(d.span).toBeUndefined(); + }); + + it('keeps a non-system catalog finding a warning even in strict mode', () => { + const d = buildDiagnostic({ code: 'catalog.state_unresolved' }, 'strict', { + term: 'is idle', + }); + expect(d.severity).toBe('warning'); + expect(d.message).toContain('is idle'); + }); + + it('omits the span key entirely when the finding has no span', () => { + const d = buildDiagnostic({ code: 'lint.unparsed_tail' }, 'strict'); + expect('span' in d).toBe(false); + }); +}); + +describe('sortDiagnostics', () => { + const make = ( + code: DiagnosticCode, + s: Span | undefined, + severity: Diagnostic['severity'] = 'warning', + message = 'm', + ): Diagnostic => (s ? { code, severity, message, span: s } : { code, severity, message }); + + it('orders by span start ascending', () => { + const sorted = sortDiagnostics([ + make('ears.no_match', span(10, 12)), + make('ears.no_match', span(2, 4)), + make('ears.no_match', span(6, 8)), + ]); + expect(sorted.map(startOf)).toEqual([2, 6, 10]); + }); + + it('breaks a start tie by span end ascending', () => { + const sorted = sortDiagnostics([ + make('ears.no_match', span(5, 20)), + make('ears.no_match', span(5, 8)), + make('ears.no_match', span(5, 12)), + ]); + expect(sorted.map(endOf)).toEqual([8, 12, 20]); + }); + + it('breaks a span tie by code, then message, then severity', () => { + const sorted = sortDiagnostics([ + make('expr.unknown_term', span(0, 3), 'warning', 'b'), + make('expr.unknown_term', span(0, 3), 'error', 'a'), + make('ears.no_match', span(0, 3), 'warning', 'z'), + ]); + expect(sorted.map((d) => d.code)).toEqual([ + 'ears.no_match', + 'expr.unknown_term', + 'expr.unknown_term', + ]); + // Same code and span: message 'a' sorts before 'b'. + expect(sorted[1].message).toBe('a'); + expect(sorted[2].message).toBe('b'); + }); + + it('places spanless diagnostics after all spanned ones', () => { + const sorted = sortDiagnostics([ + make('ears.no_match', undefined), + make('ears.no_match', span(100, 101)), + make('ears.missing_shall', undefined), + ]); + expect(startOf(sorted[0])).toBe(100); + expect(sorted[1].span).toBeUndefined(); + expect(sorted[2].span).toBeUndefined(); + // Spanless ties fall back to code order. + expect(sorted[1].code).toBe('ears.missing_shall'); + expect(sorted[2].code).toBe('ears.no_match'); + }); + + it('does not mutate the input array', () => { + const input = [make('ears.no_match', span(5, 6)), make('ears.no_match', span(1, 2))]; + const snapshot = input.slice(); + sortDiagnostics(input); + expect(input).toEqual(snapshot); + }); + + it('is order-independent: shuffled inputs produce identical output', () => { + const base = [ + make('ears.no_match', span(3, 4)), + make('expr.unknown_term', span(3, 4)), + make('ears.missing_shall', undefined), + make('lint.unparsed_tail', span(0, 2)), + ]; + const forward = sortDiagnostics(base); + const reversed = sortDiagnostics(base.slice().reverse()); + expect(forward).toEqual(reversed); + }); +}); + +describe('dedupeDiagnostics', () => { + const d = ( + code: DiagnosticCode, + s: Span | undefined, + severity: Diagnostic['severity'] = 'error', + message = 'm', + ): Diagnostic => (s ? { code, severity, message, span: s } : { code, severity, message }); + + it('drops an exact duplicate with matching code, span, severity, and message', () => { + const out = dedupeDiagnostics([ + d('ears.missing_shall', span(0, 5)), + d('ears.missing_shall', span(0, 5)), + ]); + expect(out).toHaveLength(1); + expect(out[0]).toEqual(d('ears.missing_shall', span(0, 5))); + }); + + it('collapses spanless duplicates too', () => { + const out = dedupeDiagnostics([ + d('ears.missing_shall', undefined), + d('ears.missing_shall', undefined), + ]); + expect(out).toHaveLength(1); + }); + + it('keeps findings that share a code and span but differ in message', () => { + const out = dedupeDiagnostics([ + d('catalog.state_unresolved', span(0, 4), 'warning', 'The state term "a" ...'), + d('catalog.state_unresolved', span(0, 4), 'warning', 'The state term "b" ...'), + ]); + expect(out).toHaveLength(2); + }); + + it('keeps findings that share code and message but differ in span', () => { + const out = dedupeDiagnostics([ + d('ears.missing_shall', span(0, 5)), + d('ears.missing_shall', span(6, 10)), + ]); + expect(out).toHaveLength(2); + }); + + it('does not treat a spanless finding as a duplicate of a spanned one', () => { + const out = dedupeDiagnostics([ + d('ears.missing_shall', undefined), + d('ears.missing_shall', span(0, 0)), + ]); + expect(out).toHaveLength(2); + }); + + it('keeps the first occurrence and preserves relative order', () => { + const first = d('ears.no_match', span(2, 3), 'error', 'first'); + const out = dedupeDiagnostics([ + first, + d('lint.unparsed_tail', span(0, 1), 'warning'), + d('ears.no_match', span(2, 3), 'error', 'first'), + ]); + expect(out).toHaveLength(2); + expect(out[0]).toBe(first); + expect(out[1].code).toBe('lint.unparsed_tail'); + }); + + it('does not mutate the input array', () => { + const input = [d('ears.no_match', span(0, 1)), d('ears.no_match', span(0, 1))]; + const snapshot = input.slice(); + dedupeDiagnostics(input); + expect(input).toEqual(snapshot); + }); + + it('composes with sortDiagnostics the same in either order', () => { + const input = [ + d('ears.no_match', span(5, 6), 'error', 'x'), + d('lint.unparsed_tail', span(0, 1), 'warning'), + d('ears.no_match', span(5, 6), 'error', 'x'), + ]; + expect(sortDiagnostics(dedupeDiagnostics(input))).toEqual( + dedupeDiagnostics(sortDiagnostics(input)), + ); + }); +}); + +describe('computeValid', () => { + it('is true when there are no diagnostics', () => { + expect(computeValid([])).toBe(true); + }); + + it('is true when every diagnostic is a warning or info', () => { + expect( + computeValid([ + { code: 'lint.unparsed_tail', severity: 'warning', message: 'm' }, + { code: 'lint.alias_used', severity: 'info', message: 'm' }, + ]), + ).toBe(true); + }); + + it('is false when any diagnostic is an error', () => { + expect( + computeValid([ + { code: 'lint.unparsed_tail', severity: 'warning', message: 'm' }, + { code: 'ears.missing_shall', severity: 'error', message: 'm' }, + ]), + ).toBe(false); + }); + + it('agrees with buildDiagnostic across modes', () => { + const strict = buildDiagnostic({ code: 'catalog.system_unresolved' }, 'strict'); + const guided = buildDiagnostic({ code: 'catalog.system_unresolved' }, 'guided'); + expect(computeValid([strict])).toBe(false); + expect(computeValid([guided])).toBe(true); + }); +}); diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts new file mode 100644 index 0000000..4029bb9 --- /dev/null +++ b/packages/core/src/diagnostics.ts @@ -0,0 +1,392 @@ +/** + * Diagnostic construction, severity mapping, ordering, and message wording for + * `@earsyntax/core`. + * + * The shell parser, expression parser, and catalog matcher discover raw + * findings (a {@link DiagnosticCode} plus an optional {@link Span}). This module + * owns turning those raw findings into fully formed {@link Diagnostic} values: + * it assigns severity from the code and the active {@link Mode}, interpolates a + * clear one-sentence message, sorts findings into a stable order, and derives + * the `valid` flag. + * + * Determinism: nothing here reads the clock, the file system, the network, or a + * random source. The same inputs always produce byte-identical output. + * + * Severity model (ported from the Go reference `ears-lint-go`): + * - Structural shell and expression failures, plus unresolved or ambiguous + * `system` catalog terms, are errors in `strict` mode and warnings in + * `guided` mode (partial-recovery downgrade). See {@link MODE_DEPENDENT_CODES}. + * - Every other code is always a warning. The Go reference emits no `info` + * diagnostics in v1, so `info` is reserved but unused by the builders here. + */ + +import type { Diagnostic, DiagnosticCode, Mode, Severity } from './types.js'; + +/** + * A raw finding emitted by a parser or the catalog matcher, before severity and + * message wording are applied. + * + * Producers report only the code and (where known) the source span. This module + * supplies the severity and message. + */ +export type RawFinding = Pick & Partial>; + +/** + * The full set of strings a message builder can interpolate. + * + * A {@link DiagnosticContext} is a partial view of this shape: any subset of + * these fields may be supplied. When a field is absent the message falls back + * to a generic phrasing that names no specific term or clause. + */ +export interface DiagnosticContextFields { + /** The offending term or system name (for catalog and expression codes). */ + term: string; + /** The clause keyword the finding relates to (for example `while`, `if`). */ + clause: string; + /** The alias that matched, for `lint.alias_used`. */ + alias: string; + /** The canonical name that should be preferred, for `lint.alias_used`. */ + canonical: string; +} + +/** + * Optional strings interpolated into a diagnostic message. + * + * Every field is optional; supply only what is known at the call site. + */ +export type DiagnosticContext = Partial; + +/** + * Codes whose severity depends on the active {@link Mode}. + * + * These are `error` in `strict` mode and `warning` in `guided` mode. They cover + * structural shell failures, structural expression failures, and unresolved or + * ambiguous `system` catalog terms. + * + * Judgment call: `ears.empty_clause` and `ears.empty_response` are not emitted + * by the Go reference, but they are structural shell defects of the same kind + * as the codes the reference does downgrade in guided mode, so they are treated + * as mode-dependent for consistency. + */ +export const MODE_DEPENDENT_CODES = new Set([ + // Structural shell failures. + 'ears.no_match', + 'ears.invalid_clause_order', + 'ears.missing_system', + 'ears.missing_shall', + 'ears.multiple_shall', + 'ears.invalid_if_then_form', + 'ears.empty_clause', + 'ears.empty_response', + // Structural expression failures. + 'expr.unbalanced_parentheses', + 'expr.invalid_operator_sequence', + 'expr.empty_subexpression', + // System-role catalog failures (non-system roles stay warnings). + 'catalog.system_unresolved', + 'catalog.system_ambiguous', + // Host-native grammar failures. Errors under the strict dialect; a relaxing + // dialect suppresses them at parse time (keyword case and leading comma) or + // legalizes the construct (prohibition), so they never reach this map there. + 'ears.keyword_case', + 'ears.missing_leading_comma', + 'ears.prohibition_not_allowed', +]); + +/** + * Map a {@link Mode} to the severity used for {@link MODE_DEPENDENT_CODES}. + * + * Ported from the Go reference `severityByMode`: `guided` yields `warning`, + * every other mode yields `error`. + * + * @param mode The active linting mode. + * @returns `warning` in guided mode, otherwise `error`. + */ +export function severityByMode(mode: Mode): Severity { + return mode === 'guided' ? 'warning' : 'error'; +} + +/** + * Resolve the severity of a diagnostic code under a given mode. + * + * Codes in {@link MODE_DEPENDENT_CODES} follow {@link severityByMode}. Every + * other registered code is always a `warning`. + * + * @param code The diagnostic code. + * @param mode The active linting mode. + * @returns The severity to assign. + */ +export function severityForCode(code: DiagnosticCode, mode: Mode): Severity { + return MODE_DEPENDENT_CODES.has(code) ? severityByMode(mode) : 'warning'; +} + +/** + * Message builders keyed by diagnostic code. + * + * Each builder returns one factual sentence. When the relevant + * {@link DiagnosticContext} field is present the sentence names the offending + * term or clause; otherwise it falls back to a generic phrasing. Messages never + * contain em dashes. + */ +const MESSAGE_BUILDERS = { + // EARS shell diagnostics. + 'ears.no_match': () => 'The requirement does not match any supported EARS shell pattern.', + 'ears.invalid_clause_order': (ctx) => + ctx.clause + ? `The '${ctx.clause}' clause appears in an unsupported position in the EARS shell.` + : 'The shell clauses appear in an unsupported order.', + 'ears.missing_system': () => "The requirement is missing the system name before 'shall'.", + 'ears.missing_shall': () => + "The requirement does not contain exactly one 'shall' response boundary.", + 'ears.multiple_shall': () => "The requirement contains more than one shell-level 'shall'.", + 'ears.invalid_if_then_form': () => "The 'If' clause is missing the required 'then' boundary.", + 'ears.empty_clause': (ctx) => + ctx.clause ? `The '${ctx.clause}' clause body is empty.` : 'A shell clause body is empty.', + 'ears.empty_response': () => "The response after 'shall' is empty.", + + // Expression diagnostics. + 'expr.unbalanced_parentheses': () => 'The clause expression has unbalanced parentheses.', + 'expr.invalid_operator_sequence': () => + 'The clause expression contains a malformed operator sequence.', + 'expr.empty_subexpression': () => 'The clause expression contains an empty subexpression.', + 'expr.operator_precedence_warning': () => + "The clause expression mixes 'and' and 'or' without grouping; add parentheses to make precedence explicit.", + 'expr.unknown_term': (ctx) => + ctx.term + ? `The clause term "${ctx.term}" does not match any known catalog entry.` + : 'A clause term does not match any known catalog entry.', + 'expr.ambiguous_term': (ctx) => + ctx.term + ? `The clause term "${ctx.term}" matches more than one catalog entry.` + : 'A clause term matches more than one catalog entry.', + 'expr.mixed_unresolved_terms': () => + 'The clause expression mixes resolved and unresolved catalog terms.', + + // Catalog diagnostics: system role. + 'catalog.system_unresolved': (ctx) => + ctx.term + ? `The system "${ctx.term}" does not match any known system.` + : 'The system name does not match any known system.', + 'catalog.system_ambiguous': (ctx) => + ctx.term + ? `The system "${ctx.term}" matches more than one known system.` + : 'The system name matches more than one known system.', + + // Catalog diagnostics: state role. + 'catalog.state_unresolved': (ctx) => + ctx.term + ? `The state term "${ctx.term}" does not match any known state.` + : 'A state term does not match any known state.', + 'catalog.state_ambiguous': (ctx) => + ctx.term + ? `The state term "${ctx.term}" matches more than one known state.` + : 'A state term matches more than one known state.', + + // Catalog diagnostics: event role. + 'catalog.event_unresolved': (ctx) => + ctx.term + ? `The event term "${ctx.term}" does not match any known event.` + : 'An event term does not match any known event.', + 'catalog.event_ambiguous': (ctx) => + ctx.term + ? `The event term "${ctx.term}" matches more than one known event.` + : 'An event term matches more than one known event.', + + // Catalog diagnostics: feature role. + 'catalog.feature_unresolved': (ctx) => + ctx.term + ? `The feature term "${ctx.term}" does not match any known feature.` + : 'A feature term does not match any known feature.', + 'catalog.feature_ambiguous': (ctx) => + ctx.term + ? `The feature term "${ctx.term}" matches more than one known feature.` + : 'A feature term matches more than one known feature.', + + // Catalog coverage. + 'catalog.term_unreferenced': (ctx) => + ctx.term + ? `The catalog term "${ctx.term}" is not referenced by any requirement.` + : 'A catalog term is not referenced by any requirement.', + + // Lint diagnostics. + 'lint.multiple_responses': () => + 'The response contains multiple responses; split them into separate requirements.', + 'lint.vague_response': (ctx) => + ctx.term + ? `The response contains the vague term "${ctx.term}".` + : 'The response contains a vague term.', + 'lint.unparsed_tail': () => 'Text remains after the parsed requirement.', + 'lint.alias_used': (ctx) => { + if (ctx.alias && ctx.canonical) { + return `The catalog alias "${ctx.alias}" matched; prefer the canonical name "${ctx.canonical}".`; + } + if (ctx.term) { + return `A catalog alias matched for "${ctx.term}"; prefer the canonical name.`; + } + return 'A catalog alias matched; prefer the canonical name.'; + }, + 'lint.suspicious_text_shape': () => 'The sentence shape is likely accidental or malformed.', + + // Host-native grammar diagnostics. + 'ears.keyword_case': (ctx) => + ctx.term + ? `The keyword "${ctx.term}" does not match its required canonical casing.` + : 'A keyword does not match its required canonical casing.', + 'ears.missing_leading_comma': (ctx) => + ctx.clause + ? `The leading '${ctx.clause}' clause is not followed by the required comma.` + : 'A leading clause is not followed by the required comma.', + 'ears.prohibition_not_allowed': () => + "The 'shall not' prohibition form is not allowed by this dialect.", +} satisfies Record string>; + +/** + * Build the message for a code from optional context. + * + * @param code The diagnostic code. + * @param context Optional strings to interpolate. + * @returns One factual sentence describing the finding. + */ +export function messageForCode(code: DiagnosticCode, context: DiagnosticContext = {}): string { + return MESSAGE_BUILDERS[code](context); +} + +/** + * Build a fully formed {@link Diagnostic} from a raw finding. + * + * Assigns severity from the code and mode ({@link severityForCode}) and a + * one-sentence message ({@link messageForCode}). The span is copied through + * unchanged when present. + * + * @param finding The raw finding: a code and optional span. + * @param mode The active linting mode, which drives mode-dependent severity. + * @param context Optional strings interpolated into the message. + * @returns The constructed diagnostic. + */ +export function buildDiagnostic( + finding: RawFinding, + mode: Mode, + context: DiagnosticContext = {}, +): Diagnostic { + const severity = severityForCode(finding.code, mode); + const message = messageForCode(finding.code, context); + return finding.span + ? { code: finding.code, severity, message, span: finding.span } + : { code: finding.code, severity, message }; +} + +/** A start/end pair used for span comparison; spanless findings sort last. */ +function spanStart(diagnostic: Diagnostic): number { + return diagnostic.span ? diagnostic.span.start : Number.MAX_SAFE_INTEGER; +} + +function spanEnd(diagnostic: Diagnostic): number { + return diagnostic.span ? diagnostic.span.end : Number.MAX_SAFE_INTEGER; +} + +/** + * Compare two diagnostics for stable ordering. + * + * Order, in priority: span start ascending, then span end ascending (so nested + * or shorter spans at the same start come first), then code, then message, then + * severity. Diagnostics without a span sort after all spanned ones. String + * fields compare by code-unit order to stay locale-independent and + * deterministic. + * + * This refines the Go reference `sortDiagnostics`, which orders only by span + * start before falling back to code, message, and severity. Adding span end as + * a secondary key makes the order fully determined by the diagnostics + * themselves rather than by their insertion order. + */ +function compareDiagnostics(a: Diagnostic, b: Diagnostic): number { + const aStart = spanStart(a); + const bStart = spanStart(b); + if (aStart !== bStart) { + return aStart - bStart; + } + const aEnd = spanEnd(a); + const bEnd = spanEnd(b); + if (aEnd !== bEnd) { + return aEnd - bEnd; + } + if (a.code !== b.code) { + return a.code < b.code ? -1 : 1; + } + if (a.message !== b.message) { + return a.message < b.message ? -1 : 1; + } + if (a.severity !== b.severity) { + return a.severity < b.severity ? -1 : 1; + } + return 0; +} + +/** + * Return a stably sorted copy of the diagnostics. + * + * The input array is not mutated. Sorting is total and deterministic: repeated + * calls on equal inputs produce identical output regardless of insertion order. + * + * @param diagnostics The diagnostics to sort. + * @returns A new array in stable diagnostic order. + */ +export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { + return diagnostics.slice().sort(compareDiagnostics); +} + +/** + * The identity key that defines an exact duplicate diagnostic. + * + * Two diagnostics are duplicates when their code, span, severity, and message + * all match. The message carries any interpolated context (term or clause), so + * findings that share a code and span but differ in context stay distinct. The + * `` separator cannot appear in a diagnostic message, so the joined key + * is unambiguous. A missing span uses an empty component that never collides + * with a real `[start, end)` pair. + */ +function diagnosticKey(diagnostic: Diagnostic): string { + const span = diagnostic.span ? `${diagnostic.span.start}:${diagnostic.span.end}` : ''; + return [diagnostic.code, span, diagnostic.severity, diagnostic.message].join(''); +} + +/** + * Return a copy of the diagnostics with exact duplicates removed. + * + * A duplicate is a diagnostic whose code, span start and end, severity, and + * message all match an earlier one. A parser can legitimately report the same + * finding twice (for example `ears.missing_shall` from a pre-check and again at + * parse time with the same span); this collapses those to a single diagnostic. + * + * The first occurrence is kept and relative order is preserved, so the result + * is deterministic for a given input. Compose with {@link sortDiagnostics} in + * either order; the sorted, deduplicated set is identical either way. + * + * @param diagnostics The diagnostics to deduplicate. + * @returns A new array with exact duplicates removed, first occurrence kept. + */ +export function dedupeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { + const seen = new Set(); + const out: Diagnostic[] = []; + for (const diagnostic of diagnostics) { + const key = diagnosticKey(diagnostic); + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(diagnostic); + } + return out; +} + +/** + * Derive the `valid` flag from a set of diagnostics. + * + * A requirement is valid unless at least one diagnostic has severity `error`. + * `warning` and `info` never affect validity. + * + * @param diagnostics The diagnostics to inspect. + * @returns `false` when any diagnostic is an error, otherwise `true`. + */ +export function computeValid(diagnostics: readonly Diagnostic[]): boolean { + return !diagnostics.some((diagnostic) => diagnostic.severity === 'error'); +} diff --git a/packages/core/src/expression-parser.test.ts b/packages/core/src/expression-parser.test.ts new file mode 100644 index 0000000..bad93a0 --- /dev/null +++ b/packages/core/src/expression-parser.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; + +import { parseClauseExpression } from './expression-parser.js'; +import type { AndExpr, ClauseExpr, GroupExpr, NotExpr, OrExpr, TermExpr } from './types.js'; + +function term(expr: ClauseExpr): TermExpr { + expect(expr.kind).toBe('term'); + return expr as TermExpr; +} + +describe('parseClauseExpression', () => { + it('parses a single term with its span and leaves catalog resolution unset', () => { + const { expr, findings } = parseClauseExpression('the user is signed in', 0); + const t = term(expr); + expect(t.text).toBe('the user is signed in'); + expect(t.span).toEqual({ start: 0, end: 21 }); + expect(t.term).toBeUndefined(); + expect(findings).toEqual([]); + }); + + it('applies precedence not > and > or for "not A and B or C"', () => { + const { expr, findings } = parseClauseExpression('not A and B or C', 0); + + // Top level is OR of [ (not A) and B, C ]. + expect(expr.kind).toBe('or'); + const or = expr as OrExpr; + expect(or.items).toHaveLength(2); + + const and = or.items[0] as AndExpr; + expect(and.kind).toBe('and'); + expect(and.items).toHaveLength(2); + + const not = and.items[0] as NotExpr; + expect(not.kind).toBe('not'); + expect(term(not.item).text).toBe('A'); + expect(term(and.items[1]).text).toBe('B'); + expect(term(or.items[1]).text).toBe('C'); + + // Mixed and/or without parentheses warns. + expect(findings.map((f) => f.code)).toContain('expr.operator_precedence_warning'); + }); + + it('does not warn on mixed and/or when parentheses group the operands', () => { + const { expr, findings } = parseClauseExpression('(A or B) and C', 0); + expect(expr.kind).toBe('and'); + const and = expr as AndExpr; + expect(and.items).toHaveLength(2); + + const group = and.items[0] as GroupExpr; + expect(group.kind).toBe('group'); + expect(group.item.kind).toBe('or'); + expect(term(and.items[1]).text).toBe('C'); + + expect(findings.map((f) => f.code)).not.toContain('expr.operator_precedence_warning'); + }); + + it('parses nested groups', () => { + const { expr, findings } = parseClauseExpression('((A or B) and C) or D', 0); + expect(expr.kind).toBe('or'); + const or = expr as OrExpr; + const outerGroup = or.items[0] as GroupExpr; + expect(outerGroup.kind).toBe('group'); + + const innerAnd = outerGroup.item as AndExpr; + expect(innerAnd.kind).toBe('and'); + const innerGroup = innerAnd.items[0] as GroupExpr; + expect(innerGroup.kind).toBe('group'); + expect(innerGroup.item.kind).toBe('or'); + expect(term(or.items[1]).text).toBe('D'); + + // Grouping suppresses the precedence warning even with mixed operators. + expect(findings.map((f) => f.code)).not.toContain('expr.operator_precedence_warning'); + }); + + it('reports an unbalanced closing parenthesis', () => { + const { findings } = parseClauseExpression('A and B)', 0); + const paren = findings.filter((f) => f.code === 'expr.unbalanced_parentheses'); + expect(paren.length).toBeGreaterThanOrEqual(1); + expect(paren[0].span).toEqual({ start: 7, end: 8 }); + }); + + it('reports an unbalanced opening parenthesis', () => { + const { findings } = parseClauseExpression('(A and B', 0); + const codes = findings.map((f) => f.code); + expect(codes).toContain('expr.unbalanced_parentheses'); + // The tokenizer flags the span of the whole clause body. + const openFinding = findings.find( + (f) => f.code === 'expr.unbalanced_parentheses' && f.span?.start === 0 && f.span.end === 8, + ); + expect(openFinding).toBeDefined(); + }); + + it('flags a repeated operator such as "A and and B"', () => { + const { findings } = parseClauseExpression('A and and B', 0); + expect(findings.map((f) => f.code)).toContain('expr.invalid_operator_sequence'); + }); + + it('emits only invalid_operator_sequence for "X or or Y" (fixture INV-010)', () => { + // Once an operator error truncates the parse, the follow-on unparsed tail + // is suppressed so a single root cause yields a single finding. + const { findings } = parseClauseExpression( + 'a webhook is received or or a refund is requested', + 0, + ); + const codes = findings.map((f) => f.code); + expect(codes).toContain('expr.invalid_operator_sequence'); + expect(codes).not.toContain('lint.unparsed_tail'); + }); + + it('treats infix "not" as part of a term (fixture VAL-034)', () => { + const { expr, findings } = parseClauseExpression( + 'the payment provider is available and (the retry queue is not full or the system is in maintenance mode)', + 0, + ); + expect(expr.kind).toBe('and'); + const and = expr as AndExpr; + expect(and.items).toHaveLength(2); + expect(term(and.items[0]).text).toBe('the payment provider is available'); + + const group = and.items[1] as GroupExpr; + expect(group.kind).toBe('group'); + const or = group.item as OrExpr; + expect(or.kind).toBe('or'); + expect(term(or.items[0]).text).toBe('the retry queue is not full'); + expect(term(or.items[1]).text).toBe('the system is in maintenance mode'); + + // Balanced input parses cleanly: no spurious unbalanced parens or tail. + expect(findings).toEqual([]); + }); + + it('flags a leading operator', () => { + const { findings } = parseClauseExpression('and A', 0); + expect(findings.map((f) => f.code)).toContain('expr.invalid_operator_sequence'); + }); + + it('flags a trailing operator', () => { + const { expr, findings } = parseClauseExpression('A and', 0); + expect(term(expr).text).toBe('A'); + expect(findings.map((f) => f.code)).toContain('expr.invalid_operator_sequence'); + }); + + it('flags an empty group "()" with a single empty_subexpression finding', () => { + // Approved deviation from the Go port: the empty-group branch emits exactly + // expr.empty_subexpression, not invalid_operator_sequence + unbalanced. + const { expr, findings } = parseClauseExpression('()', 0); + expect(expr.kind).toBe('group'); + expect((expr as GroupExpr).item.kind).toBe('free-text'); + const codes = findings.map((f) => f.code); + expect(codes).toEqual(['expr.empty_subexpression']); + }); + + it('emits only empty_subexpression for "A and ()" (fixture INV-012)', () => { + const { expr, findings } = parseClauseExpression('a webhook is received and ()', 0); + expect(expr.kind).toBe('and'); + const and = expr as AndExpr; + expect(and.items[1].kind).toBe('group'); + expect(findings.map((f) => f.code)).toEqual(['expr.empty_subexpression']); + }); + + it('emits expr.empty_subexpression for an unterminated empty group', () => { + const { expr, findings } = parseClauseExpression('(', 0); + expect(expr.kind).toBe('group'); + const codes = findings.map((f) => f.code); + expect(codes).toContain('expr.empty_subexpression'); + expect(codes).toContain('expr.unbalanced_parentheses'); + }); + + it('emits expr.empty_subexpression for a wholly empty body', () => { + const { expr, findings } = parseClauseExpression(' ', 0); + expect(expr.kind).toBe('free-text'); + expect(findings.map((f) => f.code)).toContain('expr.empty_subexpression'); + }); + + describe('commaAsAnd option', () => { + it('treats commas as "and" when commaAsAnd is true', () => { + const { expr, findings } = parseClauseExpression('A, B', 0, { commaAsAnd: true }); + expect(expr.kind).toBe('and'); + const and = expr as AndExpr; + expect(and.items).toHaveLength(2); + expect(term(and.items[0]).text).toBe('A'); + expect(term(and.items[1]).text).toBe('B'); + expect(findings.map((f) => f.code)).not.toContain('expr.invalid_operator_sequence'); + }); + + it('never treats a comma as "and" when commaAsAnd is false (default)', () => { + const { expr, findings } = parseClauseExpression('A, B', 0); + // The leading term parses; the comma and everything after it is left as + // an unparsed tail rather than joined with "and". + expect(term(expr).text).toBe('A'); + const codes = findings.map((f) => f.code); + expect(codes).toContain('lint.unparsed_tail'); + expect(codes).not.toContain('expr.invalid_operator_sequence'); + }); + }); + + it('produces absolute spans using the base offset', () => { + const baseOffset = 10; + const { expr } = parseClauseExpression('A and B', baseOffset); + const and = expr as AndExpr; + expect(term(and.items[0]).span).toEqual({ start: 10, end: 11 }); + expect(term(and.items[1]).span).toEqual({ start: 16, end: 17 }); + expect(and.span).toEqual({ start: 10, end: 17 }); + }); + + it('matches keywords case-insensitively', () => { + const { expr } = parseClauseExpression('A AND B Or C', 0); + expect(expr.kind).toBe('or'); + const or = expr as OrExpr; + const and = or.items[0] as AndExpr; + expect(and.kind).toBe('and'); + expect(term(and.items[0]).text).toBe('A'); + expect(term(and.items[1]).text).toBe('B'); + expect(term(or.items[1]).text).toBe('C'); + }); + + it('parses "not A" as a negation', () => { + const { expr } = parseClauseExpression('not the service is available', 0); + expect(expr.kind).toBe('not'); + const not = expr as NotExpr; + expect(term(not.item).text).toBe('the service is available'); + }); + + it('is deterministic across repeated parses', () => { + const first = parseClauseExpression('not A and B or (C or D)', 3, { commaAsAnd: true }); + const second = parseClauseExpression('not A and B or (C or D)', 3, { commaAsAnd: true }); + expect(second).toEqual(first); + }); +}); + +describe('parseClauseExpression hardening (does not throw on adversarial input)', () => { + it('handles a very long "not not not ..." chain without overflowing the stack', () => { + const raw = `${'not '.repeat(50_000)}A`; + let result: ReturnType | undefined; + expect(() => { + result = parseClauseExpression(raw, 0); + }).not.toThrow(); + // A leading chain of `not` still parses to a nested negation over the term. + expect(result?.expr.kind).toBe('not'); + }); + + it('handles deeply nested parentheses without overflowing the stack', () => { + const raw = `${'('.repeat(20_000)}A${')'.repeat(20_000)}`; + let result: ReturnType | undefined; + expect(() => { + result = parseClauseExpression(raw, 0); + }).not.toThrow(); + // Once nesting passes the depth guard, the over-deep subtree is reported as + // an empty subexpression rather than crashing the parser. + expect(result?.findings.map((f) => f.code)).toContain('expr.empty_subexpression'); + }); + + it('merges the span of a very long and/or chain without a spread RangeError', () => { + const count = 500_000; + const raw = Array.from({ length: count }, () => 'A').join(' and '); + let result: ReturnType | undefined; + expect(() => { + result = parseClauseExpression(raw, 0); + }).not.toThrow(); + expect(result?.expr.kind).toBe('and'); + // The merged span still spans the whole chain: first term start to last end. + expect(result?.expr.span).toEqual({ start: 0, end: raw.length }); + }); +}); + +describe('parseClauseExpression spans cover their keyword and delimiters', () => { + it('spans a "not" negation from the not keyword to the end of its operand', () => { + const { expr } = parseClauseExpression('not A', 0); + expect(expr.kind).toBe('not'); + // Span starts at the `not` keyword (offset 0), not at the operand `A`. + expect(expr.span).toEqual({ start: 0, end: 5 }); + const not = expr as NotExpr; + expect(term(not.item).span).toEqual({ start: 4, end: 5 }); + }); + + it('spans a nested "not not" chain from the outermost keyword', () => { + const { expr } = parseClauseExpression('not not A', 0); + expect(expr.kind).toBe('not'); + expect(expr.span).toEqual({ start: 0, end: 9 }); + const inner = (expr as NotExpr).item as NotExpr; + expect(inner.kind).toBe('not'); + expect(inner.span).toEqual({ start: 4, end: 9 }); + }); + + it('spans a non-empty group across its parentheses', () => { + const { expr } = parseClauseExpression('(A or B)', 0); + expect(expr.kind).toBe('group'); + // Span covers the opening and closing parentheses, not just the inner or. + expect(expr.span).toEqual({ start: 0, end: 8 }); + const inner = (expr as GroupExpr).item as OrExpr; + expect(inner.span).toEqual({ start: 1, end: 7 }); + }); +}); diff --git a/packages/core/src/expression-parser.ts b/packages/core/src/expression-parser.ts new file mode 100644 index 0000000..74cd053 --- /dev/null +++ b/packages/core/src/expression-parser.ts @@ -0,0 +1,527 @@ +/** + * Boolean clause expression parser for EARS clause bodies. + * + * Parses the boolean-like body of a `While`, `Where`, `When`, or `If` clause + * into a {@link ClauseExpr} tree. The grammar supports `and`, `or`, `not`, and + * parenthesized groups with precedence `not > and > or`. Keywords are matched + * case-insensitively. + * + * This module is a faithful port of the Go reference + * (`ears-lint-go/expression_parser.go`). It differs in one deliberate way: + * it emits raw {@link ExprFinding} values (code plus optional span) rather than + * fully formed diagnostics. Severity and message are assigned downstream by the + * diagnostics module. Term catalog resolution is left unset here; the catalog + * module fills {@link TermExpr.term} later. + * + * Determinism contract: no LLM, no network, no file system, no fuzzy matching. + * Given the same input the output tree and findings are always identical. + */ + +import type { + AndExpr, + ClauseExpr, + DiagnosticCode, + FreeTextExpr, + GroupExpr, + NotExpr, + Options, + OrExpr, + Span, + TermExpr, +} from './types.js'; + +/** + * A raw parser finding: a diagnostic code and the span it refers to. + * + * Unlike {@link Diagnostic}, a finding carries no severity or message. Those + * are owned by the diagnostics module, which maps findings onto the final + * diagnostic surface based on mode and context. + */ +export interface ExprFinding { + /** The diagnostic code the finding maps to. */ + code: DiagnosticCode; + /** Source span the finding refers to, when known. */ + span?: Span; +} + +/** + * The result of parsing a clause expression. + */ +export interface ParseExpressionResult { + /** The parsed expression tree. Never null; empty input yields free text. */ + expr: ClauseExpr; + /** Raw findings collected during tokenizing and parsing, stably ordered. */ + findings: ExprFinding[]; +} + +/** The subset of {@link Options} that influences parsing behavior. */ +type ExpressionOptions = Pick; + +enum TokenKind { + Word, + And, + Or, + Not, + LParen, + RParen, + Comma, +} + +interface ExprToken { + kind: TokenKind; + text: string; + start: number; + end: number; +} + +/** + * Parse a clause body into a {@link ClauseExpr} tree plus raw findings. + * + * @param raw The clause body text (already stripped of the clause keyword). + * @param baseOffset Absolute offset of `raw` within the original requirement + * text, so every emitted span points back into the source. + * @param options Parsing options; only `commaAsAnd` affects the parse. + * @returns The expression tree and the findings gathered while parsing. + */ +export function parseClauseExpression( + raw: string, + baseOffset: number, + options: ExpressionOptions = {}, +): ParseExpressionResult { + const { tokens, findings: tokenFindings } = tokenizeExpression(raw, baseOffset); + const parser = new ExprParser(tokens, options); + + let expr = parser.parseExpr(); + if (expr === null) { + const span: Span = { start: baseOffset, end: baseOffset + raw.length }; + const freeText: FreeTextExpr = { kind: 'free-text', text: raw.trim(), span }; + expr = freeText; + parser.addFinding('expr.empty_subexpression', span); + } + + if (parser.pos < tokens.length && !parser.operatorError) { + const start = tokens[parser.pos].start; + const end = tokens[tokens.length - 1].end; + parser.addFinding('lint.unparsed_tail', { start, end }); + } + + if (parser.hasAnd && parser.hasOr && !parser.hasGroup) { + parser.addFinding('expr.operator_precedence_warning', expr.span); + } + + const findings = sortFindings([...tokenFindings, ...parser.findings]); + return { expr, findings }; +} + +function tokenizeExpression( + raw: string, + baseOffset: number, +): { tokens: ExprToken[]; findings: ExprFinding[] } { + const tokens: ExprToken[] = []; + const findings: ExprFinding[] = []; + let depth = 0; + + let i = 0; + while (i < raw.length) { + const c = raw[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + i++; + continue; + } + if (c === '(') { + depth++; + tokens.push({ + kind: TokenKind.LParen, + text: '(', + start: baseOffset + i, + end: baseOffset + i + 1, + }); + i++; + continue; + } + if (c === ')') { + if (depth === 0) { + findings.push({ + code: 'expr.unbalanced_parentheses', + span: { start: baseOffset + i, end: baseOffset + i + 1 }, + }); + } else { + depth--; + } + tokens.push({ + kind: TokenKind.RParen, + text: ')', + start: baseOffset + i, + end: baseOffset + i + 1, + }); + i++; + continue; + } + if (c === ',') { + tokens.push({ + kind: TokenKind.Comma, + text: ',', + start: baseOffset + i, + end: baseOffset + i + 1, + }); + i++; + continue; + } + + const start = i; + while (i < raw.length) { + const x = raw[i]; + if ( + x === ' ' || + x === '\t' || + x === '\n' || + x === '\r' || + x === '(' || + x === ')' || + x === ',' + ) { + break; + } + i++; + } + const chunk = raw.slice(start, i); + let kind = TokenKind.Word; + switch (chunk.toLowerCase()) { + case 'and': + kind = TokenKind.And; + break; + case 'or': + kind = TokenKind.Or; + break; + case 'not': + kind = TokenKind.Not; + break; + } + tokens.push({ kind, text: chunk, start: baseOffset + start, end: baseOffset + i }); + } + + if (depth > 0) { + findings.push({ + code: 'expr.unbalanced_parentheses', + span: { start: baseOffset, end: baseOffset + raw.length }, + }); + } + + return { tokens, findings: sortFindings(findings) }; +} + +/** + * Maximum parenthesis-nesting depth the parser recurses through. Beyond this, + * the over-deep group is reported as an empty subexpression instead of + * recursing further, so pathological input (thousands of nested parentheses) + * cannot overflow the JS stack. Real requirements never approach this depth. + */ +const MAX_EXPR_DEPTH = 200; + +class ExprParser { + pos = 0; + readonly findings: ExprFinding[] = []; + hasAnd = false; + hasOr = false; + hasGroup = false; + /** Current parenthesis-nesting depth, guarded by {@link MAX_EXPR_DEPTH}. */ + private groupDepth = 0; + /** Set once an operator error truncated the parse, to suppress a follow-on tail finding. */ + operatorError = false; + + constructor( + private readonly tokens: ExprToken[], + private readonly options: ExpressionOptions, + ) {} + + parseExpr(): ClauseExpr | null { + return this.parseOr(); + } + + private parseOr(): ClauseExpr | null { + const left = this.parseAnd(); + if (left === null) { + return null; + } + const items: ClauseExpr[] = [left]; + while (this.match(TokenKind.Or)) { + this.hasOr = true; + const right = this.parseAnd(); + if (right === null) { + this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); + break; + } + items.push(right); + } + if (items.length === 1) { + return items[0]; + } + const orExpr: OrExpr = { kind: 'or', items, span: mergeSpan(items) }; + return orExpr; + } + + private parseAnd(): ClauseExpr | null { + const left = this.parseUnary(); + if (left === null) { + return null; + } + const items: ClauseExpr[] = [left]; + for (;;) { + if (this.match(TokenKind.And)) { + this.hasAnd = true; + } else if (this.options.commaAsAnd && this.match(TokenKind.Comma)) { + this.hasAnd = true; + } else { + break; + } + const right = this.parseUnary(); + if (right === null) { + this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); + break; + } + items.push(right); + } + if (items.length === 1) { + return items[0]; + } + const andExpr: AndExpr = { kind: 'and', items, span: mergeSpan(items) }; + return andExpr; + } + + private parseUnary(): ClauseExpr | null { + // Collect a leading run of `not` keywords iteratively rather than recursing + // once per keyword. A `not not not ...` chain of unbounded length would + // otherwise recurse until the JS stack overflows (core must never throw on + // user input). The tree is identical to the recursive form: each keyword + // wraps the operand in one more negation, innermost first. + const notStarts: number[] = []; + while (this.peek(TokenKind.Not)) { + notStarts.push(this.tokens[this.pos].start); + this.pos++; + } + if (notStarts.length === 0) { + return this.parsePrimary(); + } + + const inner = this.parsePrimary(); + if (inner === null) { + this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); + const dangling: FreeTextExpr = { kind: 'free-text', text: 'not' }; + return dangling; + } + + let node: ClauseExpr = inner; + for (let k = notStarts.length - 1; k >= 0; k--) { + // Each negation's span reaches from its own `not` keyword to the end of + // the operand it negates, so it covers the keyword rather than starting at + // the inner term. + const span: Span | undefined = + node.span === undefined ? undefined : { start: notStarts[k], end: node.span.end }; + const notExpr: NotExpr = span + ? { kind: 'not', item: node, span } + : { kind: 'not', item: node }; + node = notExpr; + } + return node; + } + + private parsePrimary(): ClauseExpr | null { + if (this.peek(TokenKind.LParen)) { + const open = this.tokens[this.pos]; + this.pos++; + this.hasGroup = true; + + // Empty group `()`: emit a single empty_subexpression finding and + // consume both parens. Handling it here keeps the closing paren from + // being mistaken for an invalid operator sequence. + if (this.peek(TokenKind.RParen)) { + const close = this.tokens[this.pos]; + this.pos++; + const span: Span = { start: open.start, end: close.end }; + this.addFinding('expr.empty_subexpression', span); + const emptyItem: FreeTextExpr = { kind: 'free-text', text: '', span }; + const emptyGroup: GroupExpr = { kind: 'group', item: emptyItem, span }; + return emptyGroup; + } + + // Guard the parse depth before recursing into the group body. Past the + // limit the over-deep group is collapsed to an empty subexpression, which + // keeps a pathologically nested input from overflowing the stack. + this.groupDepth++; + if (this.groupDepth > MAX_EXPR_DEPTH) { + this.groupDepth--; + return this.bailDeepGroup(open); + } + + let inner = this.parseExpr(); + let closeEnd = inner?.span?.end ?? open.end; + if (this.peek(TokenKind.RParen)) { + closeEnd = this.tokens[this.pos].end; + this.pos++; + } else { + this.addFinding('expr.unbalanced_parentheses', this.currentSpan()); + } + this.groupDepth--; + if (inner === null) { + const span = this.currentSpan(); + this.addFinding('expr.empty_subexpression', span); + const empty: FreeTextExpr = { kind: 'free-text', text: '', span }; + inner = empty; + } + // The group span covers its parentheses, from the opening paren to the + // closing one (or the end of the recovered body when it is unterminated). + const group: GroupExpr = { + kind: 'group', + item: inner, + span: { start: open.start, end: closeEnd }, + }; + return group; + } + + if ( + this.peek(TokenKind.RParen) || + this.peek(TokenKind.And) || + this.peek(TokenKind.Or) || + this.peek(TokenKind.Comma) + ) { + this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); + this.pos++; + const empty: FreeTextExpr = { kind: 'free-text', text: '' }; + return empty; + } + + return this.parseTerm(); + } + + private parseTerm(): ClauseExpr | null { + if (this.pos >= this.tokens.length) { + return null; + } + const first = this.tokens[this.pos]; + if (first.kind !== TokenKind.Word) { + return null; + } + const start = first.start; + let end = first.end; + const parts: string[] = [first.text]; + this.pos++; + // Fold trailing words into the term. A `not` reached here is infix (a term + // already started, e.g. "the queue is not full"), so it reads as an English + // word rather than a negation operator; a leading `not` is consumed earlier + // by parseUnary and never reaches this loop. + while (this.pos < this.tokens.length) { + const kind = this.tokens[this.pos].kind; + if (kind !== TokenKind.Word && kind !== TokenKind.Not) { + break; + } + const token = this.tokens[this.pos]; + parts.push(token.text); + end = token.end; + this.pos++; + } + const text = parts.join(' ').trim(); + const term: TermExpr = { kind: 'term', text, span: { start, end } }; + return term; + } + + private match(kind: TokenKind): boolean { + if (this.pos >= this.tokens.length || this.tokens[this.pos].kind !== kind) { + return false; + } + this.pos++; + return true; + } + + private peek(kind: TokenKind): boolean { + return this.pos < this.tokens.length && this.tokens[this.pos].kind === kind; + } + + addFinding(code: DiagnosticCode, span?: Span): void { + if (code === 'expr.invalid_operator_sequence') { + this.operatorError = true; + } + this.findings.push(span === undefined ? { code } : { code, span }); + } + + private currentSpan(): Span | undefined { + if (this.pos < this.tokens.length) { + const token = this.tokens[this.pos]; + return { start: token.start, end: token.end }; + } + if (this.tokens.length === 0) { + return undefined; + } + const last = this.tokens[this.tokens.length - 1]; + return { start: last.start, end: last.end }; + } + + /** + * Skip an over-deep group without recursing, given that its opening paren is + * already consumed. Consumes tokens up to the matching close paren (tracking + * nested parens iteratively), reports the group as an empty subexpression, and + * returns a placeholder group node spanning the skipped range. This is the + * depth-guard escape hatch that keeps deeply nested input from overflowing the + * stack. + */ + private bailDeepGroup(open: ExprToken): ClauseExpr { + let depth = 1; + let end = open.end; + while (this.pos < this.tokens.length && depth > 0) { + const token = this.tokens[this.pos]; + if (token.kind === TokenKind.LParen) { + depth++; + } else if (token.kind === TokenKind.RParen) { + depth--; + } + end = token.end; + this.pos++; + } + const span: Span = { start: open.start, end }; + this.addFinding('expr.empty_subexpression', span); + const item: FreeTextExpr = { kind: 'free-text', text: '', span }; + const group: GroupExpr = { kind: 'group', item, span }; + return group; + } +} + +function mergeSpan(items: ClauseExpr[]): Span | undefined { + // Accumulate the min start and max end incrementally. A spread into + // Math.min/Math.max would throw a RangeError once the operand list grows past + // the engine's argument limit (long `and`/`or` chains), so a plain loop is the + // only safe form here. + let start: number | undefined; + let end: number | undefined; + for (const item of items) { + if (item.span === undefined) { + continue; + } + if (start === undefined || item.span.start < start) { + start = item.span.start; + } + if (end === undefined || item.span.end > end) { + end = item.span.end; + } + } + return start === undefined || end === undefined ? undefined : { start, end }; +} + +/** + * Sort findings deterministically by span start, then span end, then code. + * Findings without a span sort after those with one. The sort is stable, so + * findings with equal keys keep their insertion order. + */ +function sortFindings(findings: ExprFinding[]): ExprFinding[] { + return [...findings].sort((a, b) => { + const aStart = a.span?.start ?? Number.POSITIVE_INFINITY; + const bStart = b.span?.start ?? Number.POSITIVE_INFINITY; + if (aStart !== bStart) { + return aStart - bStart; + } + const aEnd = a.span?.end ?? Number.POSITIVE_INFINITY; + const bEnd = b.span?.end ?? Number.POSITIVE_INFINITY; + if (aEnd !== bEnd) { + return aEnd - bEnd; + } + return a.code < b.code ? -1 : a.code > b.code ? 1 : 0; + }); +} diff --git a/packages/core/src/findings.test.ts b/packages/core/src/findings.test.ts new file mode 100644 index 0000000..26aee60 --- /dev/null +++ b/packages/core/src/findings.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest'; +import { + defaultSeverityForId, + toFindings, + type FindingsInput, + type SeverityOverrides, +} from './findings.js'; +import { idForCode } from './registry.js'; +import type { Diagnostic, LintResult } from './types.js'; + +function lint(diagnostics: Diagnostic[]): LintResult { + return { valid: !diagnostics.some((d) => d.severity === 'error'), references: [], diagnostics }; +} + +const vagueWarning: Diagnostic = { + code: 'lint.vague_response', + severity: 'warning', + message: 'The response contains a vague term.', + span: { start: 10, end: 20 }, +}; + +const missingShallError: Diagnostic = { + code: 'ears.missing_shall', + severity: 'error', + message: "The requirement does not contain exactly one 'shall' response boundary.", +}; + +const systemUnresolvedError: Diagnostic = { + code: 'catalog.system_unresolved', + severity: 'error', + message: 'The system name does not match any known system.', +}; + +describe('idForCode', () => { + it('maps legacy codes to their migration-table ids', () => { + expect(idForCode('ears.invalid_if_then_form')).toBe('EARS-E006'); + expect(idForCode('catalog.system_ambiguous')).toBe('EARS-E001'); + expect(idForCode('lint.vague_response')).toBe('EARS-W016'); + expect(idForCode('catalog.event_ambiguous')).toBe('EARS-W001'); + }); +}); + +describe('defaultSeverityForId', () => { + it('reads the default severity from the id band', () => { + expect(defaultSeverityForId('EARS-E006')).toBe('error'); + expect(defaultSeverityForId('EARS-W016')).toBe('warning'); + }); +}); + +describe('toFindings summary math', () => { + it('counts files, requirements, valid, errors, and warnings', () => { + const input: FindingsInput = [ + { + file: 'specs/a.ears', + items: [ + { input: { id: 'REQ-1', text: 'ok', source: { line: 1 } }, result: lint([]) }, + { + input: { id: 'REQ-2', text: 'bad', source: { line: 4 } }, + result: lint([missingShallError, vagueWarning]), + }, + ], + }, + { + file: 'specs/b.ears', + items: [{ input: { text: 'warn', source: { line: 2 } }, result: lint([vagueWarning]) }], + }, + ]; + + const findings = toFindings(input); + expect(findings.summary).toEqual({ + files: 2, + requirements: 3, + valid: 2, + errors: 1, + warnings: 2, + }); + expect(findings.ok).toBe(false); + }); + + it('returns an empty, ok result for empty input', () => { + expect(toFindings([])).toEqual({ + ok: true, + summary: { files: 0, requirements: 0, valid: 0, errors: 0, warnings: 0 }, + diagnostics: [], + }); + }); + + it('returns an ok result when a file has requirements but no diagnostics', () => { + const findings = toFindings([ + { file: 'specs/clean.ears', items: [{ input: { text: 'clean' }, result: lint([]) }] }, + ]); + expect(findings.ok).toBe(true); + expect(findings.summary).toEqual({ + files: 1, + requirements: 1, + valid: 1, + errors: 0, + warnings: 0, + }); + expect(findings.diagnostics).toEqual([]); + }); +}); + +describe('toFindings diagnostic shape', () => { + const input: FindingsInput = [ + { + file: 'specs/checkout.ears', + items: [ + { + input: { id: 'REQ-3', text: 'x', source: { line: 12, column: 5 } }, + result: lint([missingShallError]), + }, + ], + }, + ]; + + it('maps id, severity, file, line, col, message, and requirementId', () => { + const [diagnostic] = toFindings(input).diagnostics; + expect(diagnostic).toEqual({ + id: 'EARS-E007', + severity: 'error', + file: 'specs/checkout.ears', + line: 12, + col: 5, + message: "The requirement does not contain exactly one 'shall' response boundary.", + requirementId: 'REQ-3', + }); + }); + + it('orders keys as id, severity, file, line, col, message, requirementId', () => { + const [diagnostic] = toFindings(input).diagnostics; + expect(Object.keys(diagnostic as object)).toEqual([ + 'id', + 'severity', + 'file', + 'line', + 'col', + 'message', + 'requirementId', + ]); + }); + + it('defaults line to 1 and omits col when source position is absent', () => { + const findings = toFindings([ + { file: '-', items: [{ input: { text: 'x' }, result: lint([missingShallError]) }] }, + ]); + const [diagnostic] = findings.diagnostics; + expect(diagnostic.line).toBe(1); + expect(Object.hasOwn(diagnostic as object, 'col')).toBe(false); + expect(Object.hasOwn(diagnostic as object, 'requirementId')).toBe(false); + expect(Object.hasOwn(diagnostic as object, 'fix')).toBe(false); + }); +}); + +describe('toFindings severity resolution', () => { + const warnInput: FindingsInput = [ + { + file: 'specs/a.ears', + items: [{ input: { text: 'x', source: { line: 1 } }, result: lint([vagueWarning]) }], + }, + ]; + const errorInput: FindingsInput = [ + { + file: 'specs/a.ears', + items: [{ input: { text: 'x', source: { line: 1 } }, result: lint([missingShallError]) }], + }, + ]; + + it('uses the id-band default with no overrides and no strict', () => { + expect(toFindings(warnInput).diagnostics[0]?.severity).toBe('warning'); + expect(toFindings(errorInput).diagnostics[0]?.severity).toBe('error'); + }); + + it('upgrades a warning to error under --strict', () => { + const findings = toFindings(warnInput, { strict: true }); + expect(findings.diagnostics[0]?.severity).toBe('error'); + expect(findings.ok).toBe(false); + expect(findings.summary).toMatchObject({ errors: 1, warnings: 0, valid: 0 }); + }); + + it('leaves an error unchanged under --strict', () => { + expect(toFindings(errorInput, { strict: true }).diagnostics[0]?.severity).toBe('error'); + }); + + it('applies a profile override that downgrades an error to warning', () => { + const overrides: SeverityOverrides = { 'EARS-E007': 'warning' }; + const findings = toFindings(errorInput, { overrides }); + expect(findings.diagnostics[0]?.severity).toBe('warning'); + expect(findings.ok).toBe(true); + expect(findings.summary).toMatchObject({ errors: 0, warnings: 1, valid: 1 }); + }); + + it('applies a profile override that upgrades a warning to error', () => { + const overrides: SeverityOverrides = { 'EARS-W016': 'error' }; + expect(toFindings(warnInput, { overrides }).diagnostics[0]?.severity).toBe('error'); + }); + + it('drops a diagnostic whose override is off', () => { + const overrides: SeverityOverrides = { 'EARS-W016': 'off' }; + const findings = toFindings(warnInput, { overrides }); + expect(findings.diagnostics).toEqual([]); + expect(findings.summary).toMatchObject({ requirements: 1, valid: 1, errors: 0, warnings: 0 }); + expect(findings.ok).toBe(true); + }); + + it('lets an off override win over --strict', () => { + const overrides: SeverityOverrides = { 'EARS-W016': 'off' }; + const findings = toFindings(warnInput, { overrides, strict: true }); + expect(findings.diagnostics).toEqual([]); + expect(findings.ok).toBe(true); + }); + + it('applies an override then upgrades the result under --strict', () => { + // Override an error down to warning, then --strict pulls it back to error. + const overrides: SeverityOverrides = { 'EARS-E007': 'warning' }; + const findings = toFindings(errorInput, { overrides, strict: true }); + expect(findings.diagnostics[0]?.severity).toBe('error'); + expect(findings.ok).toBe(false); + }); +}); + +describe('toFindings ordering', () => { + it('sorts by file, line, col, id, then message', () => { + const input: FindingsInput = [ + { + file: 'specs/b.ears', + items: [{ input: { text: 'x', source: { line: 1 } }, result: lint([missingShallError]) }], + }, + { + file: 'specs/a.ears', + items: [ + { + input: { text: 'y', source: { line: 9 } }, + result: lint([systemUnresolvedError]), + }, + { + input: { text: 'z', source: { line: 2 } }, + result: lint([missingShallError, vagueWarning]), + }, + ], + }, + ]; + + const findings = toFindings(input); + expect(findings.diagnostics.map((d) => [d.file, d.line, d.id])).toEqual([ + ['specs/a.ears', 2, 'EARS-E007'], + ['specs/a.ears', 2, 'EARS-W016'], + ['specs/a.ears', 9, 'EARS-E002'], + ['specs/b.ears', 1, 'EARS-E007'], + ]); + }); + + it('sorts a finding with a col before one without on the same line', () => { + const withCol: Diagnostic = { ...missingShallError }; + const input: FindingsInput = [ + { + file: 'specs/a.ears', + items: [ + { input: { text: 'x', source: { line: 3 } }, result: lint([vagueWarning]) }, + { input: { text: 'y', source: { line: 3, column: 4 } }, result: lint([withCol]) }, + ], + }, + ]; + const findings = toFindings(input); + expect(findings.diagnostics.map((d) => [d.id, d.col ?? null])).toEqual([ + ['EARS-E007', 4], + ['EARS-W016', null], + ]); + }); +}); + +describe('toFindings determinism', () => { + it('produces byte-identical JSON for the same input', () => { + const input: FindingsInput = [ + { + file: 'specs/a.ears', + items: [ + { + input: { id: 'REQ-1', text: 'x', source: { line: 4, column: 2 } }, + result: lint([missingShallError, vagueWarning]), + }, + ], + }, + ]; + expect(JSON.stringify(toFindings(input))).toBe(JSON.stringify(toFindings(input))); + }); +}); diff --git a/packages/core/src/findings.ts b/packages/core/src/findings.ts new file mode 100644 index 0000000..3b514bc --- /dev/null +++ b/packages/core/src/findings.ts @@ -0,0 +1,296 @@ +/** + * The canonical Findings model for `@earsyntax/core`. + * + * Findings v1 is the single result every findings-bearing command returns. + * `validate` returns it directly; other commands embed it. This module owns the + * frozen shapes from `docs/contracts/findings.md` and the pure converter that + * turns the core linter's {@link LintResult} output (plus per-file source info) + * into a {@link Findings} value. + * + * Severity in a {@link FindingsDiagnostic} is the EFFECTIVE severity: the + * registry default for the diagnostic id, then any active profile override, then + * `--strict`. A profile override of `off` drops the diagnostic and wins over + * `--strict`. `ok` is `true` exactly when no diagnostic has effective severity + * `error`. + * + * Determinism contract: no LLM, no network, no file system, no clock, no random + * source. The same input always produces a deeply equal, stably ordered result. + */ + +import { getDiagnosticEntry, idForCode } from './registry.js'; +import type { LintResult, RequirementInput } from './types.js'; + +/** + * The effective severity a {@link FindingsDiagnostic} can carry. + * + * There is no `info` level in the Findings model: the core `Severity` union's + * `info` value never reaches this layer. + */ +export type FindingsSeverity = 'error' | 'warning'; + +/** + * A single finding in the Findings model. + * + * This is the `Diagnostic` shape from `docs/contracts/findings.md`, renamed to + * avoid colliding with the legacy core {@link import('./types.js').Diagnostic}. + * Object keys are constructed in the fixed order `id`, `severity`, `file`, + * `line`, `col?`, `message`, `fix?`, `requirementId?`. + */ +export interface FindingsDiagnostic { + /** The registry id: `EARS-E###` or `EARS-W###`. */ + id: string; + /** The EFFECTIVE severity after overrides and `--strict`. */ + severity: FindingsSeverity; + /** Source file path relative to `--cwd` (POSIX), or `-` for stdin. */ + file: string; + /** 1-based line in `file`. Always present. */ + line: number; + /** 1-based column, when the finding maps to a specific column. */ + col?: number; + /** One factual sentence describing the finding. */ + message: string; + /** One suggested remediation sentence, when a deterministic hint exists. */ + fix?: string; + /** The requirement's own id (for example `REQ-001`), when known. */ + requirementId?: string; +} + +/** Aggregate counts across a Findings result. Always all five keys. */ +export interface FindingsSummary { + /** Count of source files the pipeline located and read. */ + files: number; + /** Count of requirement candidates across all files. */ + requirements: number; + /** Count of requirements carrying no error-severity diagnostic. */ + valid: number; + /** Total diagnostics with effective severity `error`. */ + errors: number; + /** Total diagnostics with effective severity `warning`. */ + warnings: number; +} + +/** The canonical Findings result. */ +export interface Findings { + /** `true` when `summary.errors === 0`. */ + ok: boolean; + /** Aggregate counts. */ + summary: FindingsSummary; + /** Every finding, stably ordered. Always present; may be empty. */ + diagnostics: FindingsDiagnostic[]; +} + +/** + * A profile severity override for one diagnostic id. + * + * `off` drops the diagnostic entirely before `--strict` is considered. + */ +export type SeverityOverride = 'error' | 'warning' | 'off'; + +/** + * Profile severity overrides, keyed by current `EARS-*` id (never by an alias). + * + * Supplied by the active profile (Agent C3 owns profile data); an absent id + * uses the registry default severity. + */ +export type SeverityOverrides = Readonly>; + +/** + * One linted requirement: the requirement that went in and the result that came + * out. + */ +export interface FindingsInputItem { + /** The requirement that was linted (`id`, `text`, and optional `source`). */ + input: RequirementInput; + /** The result the core linter returned for {@link FindingsInputItem.input}. */ + result: LintResult; +} + +/** All linted requirements that share one source file. */ +export interface FindingsInputFile { + /** The source file path, relative to `--cwd` (POSIX), or `-` for stdin. */ + file: string; + /** The linted requirements, in the order they appeared in the file. */ + items: FindingsInputItem[]; +} + +/** + * The full converter input: files in processed order, each carrying its linted + * requirements in source order. + */ +export type FindingsInput = readonly FindingsInputFile[]; + +/** Options that tune {@link toFindings} severity resolution. */ +export interface ToFindingsOptions { + /** Active profile severity overrides, keyed by current `EARS-*` id. */ + overrides?: SeverityOverrides; + /** Upgrade every surviving `warning` to `error` at this layer. */ + strict?: boolean; +} + +/** + * The registry default severity for an `EARS-*` id. + * + * Reads {@link getDiagnosticEntry}'s `defaultSeverity` from Agent C1's + * diagnostic registry, the single source of truth for the id/severity migration + * table. This is the default only: a profile override or `--strict` can change + * the effective severity a diagnostic carries. An id with no registry entry + * (which should not occur for a resolved id) falls back to its band prefix. + * + * @param id The current `EARS-*` id. + * @returns The registry default severity for the id. + */ +export function defaultSeverityForId(id: string): FindingsSeverity { + const entry = getDiagnosticEntry(id); + if (entry) { + return entry.defaultSeverity; + } + return id.startsWith('EARS-E') ? 'error' : 'warning'; +} + +/** + * Compute the effective severity for a diagnostic id, or `undefined` when the + * diagnostic is dropped. + * + * Order: registry default, then the profile override (`error`, `warning`, or + * `off`), then `--strict`. An override of `off` returns `undefined` (dropped) + * and wins over `--strict`. + */ +function effectiveSeverity( + id: string, + overrides: SeverityOverrides, + strict: boolean, +): FindingsSeverity | undefined { + // `overrides` is a Record, whose index type omits `undefined`; guard with + // `hasOwn` so a missing id is honestly typed as absent. + const override: SeverityOverride | undefined = Object.hasOwn(overrides, id) + ? overrides[id] + : undefined; + if (override === 'off') { + return undefined; + } + const base: FindingsSeverity = override ?? defaultSeverityForId(id); + return strict && base === 'warning' ? 'error' : base; +} + +/** + * Build one {@link FindingsDiagnostic}, constructing keys in their fixed order. + * + * `fix` is never set yet: the legacy diagnostics carry no repair hint and the + * registry that will supply one is Agent C1's (RECONCILE WITH C1). + */ +function buildFindingsDiagnostic( + id: string, + severity: FindingsSeverity, + file: string, + item: FindingsInputItem, + message: string, +): FindingsDiagnostic { + // Keys constructed in fixed order: id, severity, file, line, col?, message, + // fix?, requirementId?. Local construction only; no input is mutated. + const diagnostic = {} as FindingsDiagnostic; + diagnostic.id = id; + diagnostic.severity = severity; + diagnostic.file = file; + diagnostic.line = item.input.source?.line ?? 1; + const col = item.input.source?.column; + if (col !== undefined) { + diagnostic.col = col; + } + diagnostic.message = message; + if (item.input.id !== undefined) { + diagnostic.requirementId = item.input.id; + } + return diagnostic; +} + +/** + * Compare two findings for stable ordering. + * + * Order, in priority: `file` (code-unit), `line` ascending, `col` ascending + * (a finding without `col` sorts after one with `col` on the same line), `id` + * (code-unit), then `message` (code-unit). String comparison uses code-unit + * order to stay locale-independent and deterministic. + */ +function compareFindings(a: FindingsDiagnostic, b: FindingsDiagnostic): number { + if (a.file !== b.file) { + return a.file < b.file ? -1 : 1; + } + if (a.line !== b.line) { + return a.line - b.line; + } + if (a.col !== undefined && b.col !== undefined) { + if (a.col !== b.col) { + return a.col - b.col; + } + } else if (a.col !== undefined) { + return -1; + } else if (b.col !== undefined) { + return 1; + } + if (a.id !== b.id) { + return a.id < b.id ? -1 : 1; + } + if (a.message !== b.message) { + return a.message < b.message ? -1 : 1; + } + return 0; +} + +/** + * Convert a linting run into the canonical {@link Findings} model. + * + * Each legacy {@link import('./types.js').Diagnostic} maps to a + * {@link FindingsDiagnostic}: its `code` becomes the new `EARS-*` id, its + * effective severity is resolved from the registry default, the profile + * overrides, and `--strict`, and its position comes from the file group plus the + * requirement's source location. Diagnostics whose override is `off` are + * dropped. Findings are stably sorted; the summary and `ok` derive from the + * emitted diagnostics. + * + * @param input The linted requirements grouped by source file. + * @param options Profile overrides and `--strict`. + * @returns The assembled Findings result. + */ +export function toFindings(input: FindingsInput, options: ToFindingsOptions = {}): Findings { + const overrides = options.overrides ?? {}; + const strict = options.strict ?? false; + + const collected: FindingsDiagnostic[] = []; + let files = 0; + let requirements = 0; + let valid = 0; + + for (const fileGroup of input) { + files += 1; + for (const item of fileGroup.items) { + requirements += 1; + let hasError = false; + for (const diagnostic of item.result.diagnostics) { + const id = idForCode(diagnostic.code); + const severity = effectiveSeverity(id, overrides, strict); + if (severity === undefined) { + continue; + } + if (severity === 'error') { + hasError = true; + } + collected.push( + buildFindingsDiagnostic(id, severity, fileGroup.file, item, diagnostic.message), + ); + } + if (!hasError) { + valid += 1; + } + } + } + + const diagnostics = collected.slice().sort(compareFindings); + const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length; + const warnings = diagnostics.length - errors; + + return { + ok: errors === 0, + summary: { files, requirements, valid, errors, warnings }, + diagnostics, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..086db0d --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,98 @@ +/** + * `@earsyntax/core` public API surface. + * + * This module re-exports every shared type from {@link ./types} and the public + * functions from {@link ./lint}. The function implementations wire the shell + * parser, expression parser, catalog matcher, and diagnostics module into the + * frozen public signatures. + * + * Determinism contract: every function here is deterministic. No LLM calls, no + * network, no file system access, no fuzzy matching. Diagnostics are stably + * sorted; batch order is preserved. + */ + +export type * from './types.js'; + +export { + lintEars, + lintEarsBatch, + parseEars, + lintCatalogCoverage, + isStoryWrapperLine, +} from './lint.js'; + +// Dialect resolution (Agent C4b). The strict dialect is the default applied by +// `lintEars`/`parseEars`; `resolveDialect` merges a partial `dialect` block over +// it so the pipeline and profile layers share one defaulting step. +export { STRICT_DIALECT, resolveDialect } from './options.js'; +export type { ResolvedDialect } from './options.js'; + +// The diagnostic registry (Agent C1) is the single source of truth for the +// id/alias/severity migration table. `idForCode` is exported here from +// './registry.js'; `findings.js` imports it (and default severity via +// `getDiagnosticEntry`) rather than redefining the table, so the mapping lives +// in exactly one place. `defaultSeverityForId` is a Findings-layer wrapper over +// the registry, exported once from './findings.js' below. +export { + DIAGNOSTIC_REGISTRY, + resolveDiagnosticId, + getDiagnosticEntry, + idForCode, +} from './registry.js'; +export type { DiagnosticRegistryEntry, RegistrySeverity } from './registry.js'; + +// --- Profiles subsystem (owned by Agent C3). Appended as a distinct block. --- +export type { + Profile, + ProfileName, + ProfileDialect, + ProfileLocator, + ProfileIdFormat, + LocatorRule, + LocatorRuleKind, + ListMarker, + KeywordCase, + CommaAfterLeadingClause, + CodeFences, + SeverityLevel, + ProfileValidationError, + ProfileValidationErrorCode, + ProfileValidationResult, + ResolveProfileResult, + UnknownProfileError, + ProfileDiff, +} from './profiles/index.js'; +export { + validateProfile, + resolveProfile, + diffProfile, + summarizeProfiles, + BUILTIN_PROFILES, + BUILTIN_PROFILE_NAMES, + KNOWN_DIAGNOSTIC_IDS, + isKnownDiagnosticId, +} from './profiles/index.js'; + +export type { + Findings, + FindingsDiagnostic, + FindingsInput, + FindingsInputFile, + FindingsInputItem, + FindingsSeverity, + FindingsSummary, + SeverityOverride, + SeverityOverrides, + ToFindingsOptions, +} from './findings.js'; +export { defaultSeverityForId, toFindings } from './findings.js'; + +// --- Host-native pipeline (Agent W2). Findings-assembly stage (locate/extract +// live in @earsyntax/extract). Appended as a distinct block. --- +export type { + Candidate, + CandidateFile, + PipelineNotice, + LintCandidatesOptions, +} from './pipeline.js'; +export { candidatesToFindings } from './pipeline.js'; diff --git a/packages/core/src/lint.test.ts b/packages/core/src/lint.test.ts new file mode 100644 index 0000000..a52528f --- /dev/null +++ b/packages/core/src/lint.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from 'vitest'; + +import { + isStoryWrapperLine, + lintCatalogCoverage, + lintEars, + lintEarsBatch, + parseEars, +} from './index.js'; +import { DEFAULT_VAGUE_TERMS, STRICT_DIALECT, withDefaults } from './options.js'; +import type { Catalog, Diagnostic, DiagnosticCode } from './types.js'; + +/** Collect the set of diagnostic codes present on a result. */ +function codes(diagnostics: Diagnostic[]): DiagnosticCode[] { + return diagnostics.map((diagnostic) => diagnostic.code); +} + +/** A small catalog aligned with the billing showcase used in the brief. */ +function billingCatalog(): Catalog { + return { + systems: [{ id: 'SYS-BILLING', name: 'billing service', aliases: ['billing svc'] }], + events: [{ id: 'EVT-WEBHOOK', name: 'a payment webhook is received' }], + }; +} + +describe('lintEars: canonical patterns', () => { + it('classifies a ubiquitous requirement', () => { + const result = lintEars('The billing service shall retain the audit log.'); + expect(result.pattern).toBe('ubiquitous'); + expect(result.valid).toBe(true); + expect(result.ast?.system.raw).toBe('billing service'); + expect(result.ast?.responses).toEqual(['retain the audit log']); + expect(result.diagnostics).toEqual([]); + }); + + it('classifies a state-driven requirement', () => { + const result = lintEars( + 'While the payment provider is unavailable, the billing service shall queue retryable events.', + ); + expect(result.pattern).toBe('state-driven'); + expect(result.valid).toBe(true); + expect(result.ast?.preconditions?.kind).toBe('term'); + }); + + it('classifies an event-driven requirement', () => { + const result = lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + ); + expect(result.pattern).toBe('event-driven'); + expect(result.valid).toBe(true); + expect(result.ast?.trigger?.kind).toBe('term'); + expect(result.ast?.responses).toEqual(['verify the HMAC signature']); + }); + + it('classifies an optional-feature requirement', () => { + const result = lintEars( + 'Where enterprise SSO is enabled, the billing service shall enforce single sign-on.', + ); + expect(result.pattern).toBe('optional-feature'); + expect(result.valid).toBe(true); + expect(result.ast?.feature?.kind).toBe('term'); + }); + + it('classifies an unwanted-behaviour requirement', () => { + const result = lintEars( + 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + ); + expect(result.pattern).toBe('unwanted-behaviour'); + expect(result.valid).toBe(true); + expect(result.ast?.unwanted?.kind).toBe('term'); + }); + + it('classifies a complex multi-clause requirement', () => { + const result = lintEars( + 'While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue retryable events.', + ); + expect(result.pattern).toBe('complex'); + expect(result.valid).toBe(true); + expect(result.ast?.preconditions).toBeDefined(); + expect(result.ast?.trigger).toBeDefined(); + }); +}); + +describe('lintEars: clause expression trees', () => { + it('parses boolean operators in a clause body into an expression tree', () => { + const result = lintEars( + 'When a webhook arrives and the queue is ready, the billing service shall process the event.', + ); + expect(result.ast?.trigger?.kind).toBe('and'); + // Every leaf term carries a span pointing back into the source text. + const trigger = result.ast?.trigger; + if (trigger?.kind === 'and') { + for (const item of trigger.items) { + expect(item.kind).toBe('term'); + expect(item.span).toBeDefined(); + } + } + }); + + it('flags mixed and/or without grouping', () => { + const result = lintEars('When a or b and c, the billing service shall process the event.'); + expect(codes(result.diagnostics)).toContain('expr.operator_precedence_warning'); + }); +}); + +describe('lintEars: structural failures', () => { + it('reports a missing shall as an error in strict mode', () => { + const result = lintEars('The billing service verifies the signature.'); + expect(result.valid).toBe(false); + expect(codes(result.diagnostics)).toContain('ears.missing_shall'); + expect(result.ast).toBeUndefined(); + expect(result.references).toEqual([]); + }); + + it('reports an invalid if/then form', () => { + const result = lintEars('If the signature is invalid, the billing service shall reject it.'); + expect(result.valid).toBe(false); + expect(codes(result.diagnostics)).toContain('ears.invalid_if_then_form'); + }); +}); + +describe('lintEars: guided vs strict severity', () => { + const text = 'maybe enable reverse thrust someday'; + + it('emits errors and no suspicious-shape hint in strict mode', () => { + const result = lintEars(text); + expect(result.valid).toBe(false); + const strictCodes = codes(result.diagnostics); + expect(strictCodes).toContain('ears.no_match'); + expect(strictCodes).not.toContain('lint.suspicious_text_shape'); + const noMatch = result.diagnostics.find((d) => d.code === 'ears.no_match'); + expect(noMatch?.severity).toBe('error'); + }); + + it('downgrades to warnings and adds a suspicious-shape hint in guided mode', () => { + const result = lintEars(text, undefined, { mode: 'guided' }); + const guidedCodes = codes(result.diagnostics); + expect(guidedCodes).toContain('lint.suspicious_text_shape'); + expect(guidedCodes).toContain('ears.no_match'); + const noMatch = result.diagnostics.find((d) => d.code === 'ears.no_match'); + expect(noMatch?.severity).toBe('warning'); + // With every structural failure downgraded, the result is valid. + expect(result.valid).toBe(true); + }); +}); + +describe('lintEars: response handling', () => { + it('splits semicolon-separated responses and flags multiple responses', () => { + const result = lintEars( + 'The billing service shall persist the event; enqueue a processing job.', + ); + expect(result.ast?.responses).toEqual(['persist the event', 'enqueue a processing job']); + expect(codes(result.diagnostics)).toContain('lint.multiple_responses'); + const multi = result.diagnostics.find((d) => d.code === 'lint.multiple_responses'); + expect(multi?.severity).toBe('warning'); + }); + + it('keeps a single response without a multiple-responses finding', () => { + const result = lintEars('The billing service shall persist the event.'); + expect(result.ast?.responses).toEqual(['persist the event']); + expect(codes(result.diagnostics)).not.toContain('lint.multiple_responses'); + }); + + it('flags a default vague term in a response', () => { + const result = lintEars('The billing service shall retry as needed.'); + expect(codes(result.diagnostics)).toContain('lint.vague_response'); + const vague = result.diagnostics.find((d) => d.code === 'lint.vague_response'); + expect(vague?.message).toContain('as needed'); + }); + + it('honors a configured vague-term list', () => { + const custom = lintEars('The billing service shall retry quickly.', undefined, { + vagueTerms: ['quickly'], + }); + expect(codes(custom.diagnostics)).toContain('lint.vague_response'); + + // The default terms no longer apply when a custom list is supplied. + const noDefault = lintEars('The billing service shall retry as needed.', undefined, { + vagueTerms: ['quickly'], + }); + expect(codes(noDefault.diagnostics)).not.toContain('lint.vague_response'); + }); +}); + +describe('lintEars: catalog matching end to end', () => { + it('resolves a system and event against the catalog', () => { + const result = lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + billingCatalog(), + ); + expect(result.valid).toBe(true); + expect(result.ast?.system.matched?.id).toBe('SYS-BILLING'); + const systemRef = result.references.find((ref) => ref.clause === 'system'); + expect(systemRef?.matched?.id).toBe('SYS-BILLING'); + }); + + it('warns when a system is matched via an alias', () => { + const result = lintEars('The billing svc shall retain the audit log.', billingCatalog()); + expect(result.valid).toBe(true); + expect(codes(result.diagnostics)).toContain('lint.alias_used'); + expect(result.ast?.system.viaAlias).toBe(true); + }); + + it('reports an unresolved system as an error in strict mode', () => { + const result = lintEars('The shipping service shall retain the audit log.', billingCatalog()); + expect(result.valid).toBe(false); + expect(codes(result.diagnostics)).toContain('catalog.system_unresolved'); + const diag = result.diagnostics.find((d) => d.code === 'catalog.system_unresolved'); + expect(diag?.severity).toBe('error'); + }); + + it('downgrades an unresolved system to a warning in guided mode', () => { + const result = lintEars('The shipping service shall retain the audit log.', billingCatalog(), { + mode: 'guided', + }); + expect(result.valid).toBe(true); + const diag = result.diagnostics.find((d) => d.code === 'catalog.system_unresolved'); + expect(diag?.severity).toBe('warning'); + }); + + it('reports an ambiguous system match', () => { + const ambiguous: Catalog = { + systems: [ + { id: 'SYS-A', name: 'billing service' }, + { id: 'SYS-B', name: 'billing service' }, + ], + }; + const result = lintEars('The billing service shall retain the audit log.', ambiguous); + expect(result.valid).toBe(false); + expect(codes(result.diagnostics)).toContain('catalog.system_ambiguous'); + expect(result.ast?.system.ambiguous?.length).toBe(2); + }); + + it('operates in no-catalog mode when no catalog is supplied', () => { + const result = lintEars('The billing service shall retain the audit log.'); + expect(result.ast?.system.matched).toBeUndefined(); + expect(result.ast?.system.unresolved).toBeUndefined(); + expect(codes(result.diagnostics)).not.toContain('catalog.system_unresolved'); + }); +}); + +describe('lintEarsBatch', () => { + it('preserves input order and echoes ids', () => { + const results = lintEarsBatch([ + { id: 'REQ-001', text: 'The billing service shall retain the audit log.' }, + { id: 'REQ-002', text: 'The billing service verifies the signature.' }, + { id: 'REQ-003', text: 'When a webhook arrives, the billing service shall process it.' }, + ]); + expect(results.map((r) => r.id)).toEqual(['REQ-001', 'REQ-002', 'REQ-003']); + expect(results[0].valid).toBe(true); + expect(results[1].valid).toBe(false); + expect(results[2].pattern).toBe('event-driven'); + }); + + it('omits id when the input item has none', () => { + const results = lintEarsBatch([{ text: 'The billing service shall retain the audit log.' }]); + expect('id' in results[0]).toBe(false); + }); +}); + +describe('parseEars', () => { + it('returns pattern, ast, and diagnostics without a references field', () => { + const result = parseEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + ); + expect(result.pattern).toBe('event-driven'); + expect(result.ast?.trigger?.kind).toBe('term'); + expect(Array.isArray(result.diagnostics)).toBe(true); + expect('references' in result).toBe(false); + }); + + it('fills catalog term matches on the ast without emitting catalog diagnostics', () => { + const result = parseEars('The shipping service shall retain the audit log.', billingCatalog()); + // The ast reflects the catalog match outcome. + expect(result.ast?.system.unresolved).toBe(true); + // But structural-only diagnostics exclude catalog findings. + expect(codes(result.diagnostics)).not.toContain('catalog.system_unresolved'); + }); + + it('reports structural diagnostics', () => { + const result = parseEars('The billing service verifies the signature.'); + expect(codes(result.diagnostics)).toContain('ears.missing_shall'); + expect(result.ast).toBeUndefined(); + }); +}); + +describe('lintCatalogCoverage', () => { + it('reports catalog entries no requirement references, in strict mode', () => { + const diagnostics = lintCatalogCoverage( + [{ text: 'The billing service shall retain the audit log.' }], + billingCatalog(), + ); + const coverageCodes = codes(diagnostics); + expect(coverageCodes).toContain('catalog.term_unreferenced'); + // The referenced system is covered; the unreferenced event is not. + const messages = diagnostics.map((d) => d.message); + expect(messages.some((m) => m.includes('EVT-WEBHOOK'))).toBe(true); + expect(messages.some((m) => m.includes('SYS-BILLING'))).toBe(false); + }); + + it('returns no coverage diagnostics in guided mode', () => { + const diagnostics = lintCatalogCoverage( + [{ text: 'The billing service shall retain the audit log.' }], + billingCatalog(), + { mode: 'guided' }, + ); + expect(diagnostics).toEqual([]); + }); +}); + +describe('determinism', () => { + it('produces deeply equal results for the same input', () => { + const text = + 'While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event; retry as needed.'; + const first = lintEars(text, billingCatalog()); + const second = lintEars(text, billingCatalog()); + expect(second).toEqual(first); + }); + + it('produces deeply equal batch results across runs', () => { + const items = [ + { id: 'a', text: 'The billing service shall retain the audit log.' }, + { id: 'b', text: 'If the signature is invalid, then the billing service shall reject it.' }, + ]; + expect(lintEarsBatch(items, billingCatalog())).toEqual(lintEarsBatch(items, billingCatalog())); + }); +}); + +describe('withDefaults', () => { + it('applies the documented defaults', () => { + expect(withDefaults()).toEqual({ + mode: 'strict', + commaAsAnd: false, + vagueTerms: [...DEFAULT_VAGUE_TERMS], + dialect: { ...STRICT_DIALECT }, + }); + }); + + it('falls back to default vague terms for an empty array', () => { + expect(withDefaults({ vagueTerms: [] }).vagueTerms).toEqual([...DEFAULT_VAGUE_TERMS]); + }); + + it('keeps a supplied non-empty vague-term list', () => { + expect(withDefaults({ vagueTerms: ['quickly'] }).vagueTerms).toEqual(['quickly']); + }); + + it('defaults the dialect to the strict dialect', () => { + expect(withDefaults().dialect).toEqual({ ...STRICT_DIALECT }); + }); + + it('merges a partial dialect over the strict defaults', () => { + const resolved = withDefaults({ dialect: { keywordCase: 'case-insensitive' } }).dialect; + expect(resolved.keywordCase).toBe('case-insensitive'); + expect(resolved.commaAfterLeadingClause).toBe('required'); + expect(resolved.allowProhibition).toBe(false); + }); +}); + +describe('isStoryWrapperLine', () => { + it('recognizes a user-story wrapper line', () => { + expect(isStoryWrapperLine('As a user, I want to reset my password so that I can log in')).toBe( + true, + ); + expect(isStoryWrapperLine('As an admin I want fast reports')).toBe(true); + }); + + it('does not treat an EARS requirement as a story wrapper', () => { + expect( + isStoryWrapperLine( + 'When a payment webhook is received, the billing service shall verify it.', + ), + ).toBe(false); + expect(isStoryWrapperLine('The billing service shall retain the audit log.')).toBe(false); + }); +}); diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts new file mode 100644 index 0000000..3fdd690 --- /dev/null +++ b/packages/core/src/lint.ts @@ -0,0 +1,436 @@ +/** + * Core integration layer for `@earsyntax/core`. + * + * This module wires the independent core stages into the public API: + * + * 1. {@link parseShell} recovers the shell AST and raw structural findings. + * 2. Each clause body ({@link FreeTextExpr}) is re-parsed with + * {@link parseClauseExpression} so the AST carries a real boolean expression + * tree with {@link TermExpr} leaves. + * 3. {@link resolveAndCollect} matches every term against the catalog and + * returns a new AST carrying the match results, plus catalog diagnostics + * and references. No stage mutates a shared or parameter object; each new + * AST shape is built by construction. + * 4. Responses are split on semicolons and linted for multiplicity and vague + * wording. + * 5. Every raw finding is turned into a {@link Diagnostic} through + * {@link buildDiagnostic}, merged with catalog diagnostics, deduplicated, + * and stably sorted. Validity is derived from error severity. + * + * The end-to-end flow mirrors the Go reference (`ears-lint-go/lint.go`, + * `linter.go`, `api.go`) with the documented TypeScript deviations: responses + * are split only on semicolons, and clause bodies are parsed into expression + * trees before catalog matching. + * + * Determinism contract: no LLM, no network, no file system, no clock, no random + * source. The same input always produces deeply equal output. + */ + +import { resolveAndCollect, catalogCoverageDiagnostics } from './catalog.js'; +import { + buildDiagnostic, + computeValid, + dedupeDiagnostics, + sortDiagnostics, + type RawFinding, +} from './diagnostics.js'; +import { parseClauseExpression } from './expression-parser.js'; +import { withDefaults, type ResolvedOptions } from './options.js'; +import { parseShell } from './shell-parser.js'; +import type { + Catalog, + ClauseExpr, + Diagnostic, + EarsAst, + LintResult, + Options, + ParseResult, + Pattern, + ReferenceMatch, + RequirementInput, +} from './types.js'; + +/** + * The internal outcome of running the full pipeline on one requirement. + * + * The public functions project this onto their respective result shapes: + * `lintEars` returns everything, `parseEars` returns only the structural view. + */ +interface PipelineResult { + pattern?: Pattern; + ast?: EarsAst; + references: ReferenceMatch[]; + /** Shell and expression parse findings, already built into diagnostics. */ + structural: Diagnostic[]; + /** Catalog match diagnostics and response lint diagnostics. */ + semantic: Diagnostic[]; +} + +/** + * Lint a single EARS requirement into a complete {@link LintResult}. + * + * @param text The requirement text to lint. + * @param catalog Optional catalog of known domain terms. + * @param options Optional parsing and linting options. + * @returns The lint result: `valid`, `pattern`, `ast`, `references`, and stably + * sorted `diagnostics`. + */ +export function lintEars(text: string, catalog?: Catalog, options?: Options): LintResult { + const opts = withDefaults(options); + return toLintResult(undefined, runPipeline(text, catalog, opts)); +} + +/** + * Lint a batch of EARS requirements, preserving input order. + * + * Each result corresponds positionally to its input item and echoes the item's + * `id`. Options are defaulted once and shared across the batch. + * + * @param items The requirements to lint, in order. + * @param catalog Optional catalog of known domain terms. + * @param options Optional parsing and linting options. + * @returns One {@link LintResult} per input item, in the same order. + */ +export function lintEarsBatch( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): LintResult[] { + const opts = withDefaults(options); + return items.map((item) => toLintResult(item.id, runPipeline(item.text, catalog, opts))); +} + +/** + * Parse a single EARS requirement into a {@link ParseResult}. + * + * Returns the classified `pattern`, the parsed `ast` (with catalog term matches + * filled when a catalog is supplied), and the structural `diagnostics` from + * shell and expression parsing. Catalog references and lint findings are not + * included, matching the frozen {@link ParseResult} contract. + * + * @param text The requirement text to parse. + * @param catalog Optional catalog used for term matching during parsing. + * @param options Optional parsing options. + * @returns The parse result for the requirement. + */ +export function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult { + const opts = withDefaults(options); + const pipeline = runPipeline(text, catalog, opts); + const diagnostics = sortDiagnostics(dedupeDiagnostics(pipeline.structural)); + const result: ParseResult = { diagnostics }; + if (pipeline.pattern !== undefined) { + result.pattern = pipeline.pattern; + } + if (pipeline.ast !== undefined) { + result.ast = pipeline.ast; + } + return result; +} + +/** + * Report catalog entries that no requirement text references. + * + * Maps each input item to its text and delegates to + * {@link catalogCoverageDiagnostics}, which runs only in strict mode. + * + * @param items The requirements whose text is scanned for coverage. + * @param catalog Optional catalog whose entries are checked for references. + * @param options Optional options; coverage runs only when mode is strict. + * @returns Coverage diagnostics, stably sorted. + */ +export function lintCatalogCoverage( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): Diagnostic[] { + const opts = withDefaults(options); + const texts = items.map((item) => item.text); + return catalogCoverageDiagnostics(texts, catalog, opts); +} + +/** + * Run the shared pipeline: shell parse, expression parse, catalog match, and + * response lint. Splits the produced diagnostics into a structural set (shell + * and expression) and a semantic set (catalog and response lint). + */ +function runPipeline( + text: string, + catalog: Catalog | undefined, + opts: ResolvedOptions, +): PipelineResult { + const shell = parseShell(text, { commaAsAnd: opts.commaAsAnd, dialect: opts.dialect }); + const structural: Diagnostic[] = []; + + if (!shell.ast) { + return { + references: [], + structural: recoverFailedShell(text, shell.findings, opts), + semantic: [], + }; + } + + // Re-parse each clause body into a real expression tree before catalog + // matching so the resolver sees TermExpr leaves rather than free text. The + // shell's AST is never mutated: a fresh copy carries the parsed clauses. + const exprFindings: RawFinding[] = []; + const parsedAst: EarsAst = { ...shell.ast }; + if (parsedAst.preconditions) { + parsedAst.preconditions = parseClauseBodies(parsedAst.preconditions, opts, exprFindings); + } + if (parsedAst.trigger) { + parsedAst.trigger = parseClauseBodies(parsedAst.trigger, opts, exprFindings); + } + if (parsedAst.feature) { + parsedAst.feature = parseClauseBodies(parsedAst.feature, opts, exprFindings); + } + if (parsedAst.unwanted) { + parsedAst.unwanted = parseClauseBodies(parsedAst.unwanted, opts, exprFindings); + } + + for (const finding of shell.findings) { + structural.push(buildDiagnostic(finding, opts.mode)); + } + for (const finding of exprFindings) { + structural.push(buildDiagnostic(finding, opts.mode)); + } + + // Catalog matching fills the term match results; it must run after expression + // parsing so the TermExpr leaves exist. It is pure, returning a new AST whose + // system and term nodes carry their matches. + const { + ast: resolvedAst, + references, + diagnostics: catalogDiagnostics, + } = resolveAndCollect(parsedAst, catalog, opts); + + // Split and lint the response; the AST gains its final responses through + // construction rather than mutation. + const { responses, diagnostics: responseDiagnostics } = lintResponses( + resolvedAst.responses, + opts, + ); + // Re-attach the prohibition flag: catalog resolution rebuilds the AST and does + // not carry it, but it is a shell-level classification decided during parsing. + const ast: EarsAst = { + ...resolvedAst, + responses, + ...(parsedAst.prohibition ? { prohibition: true } : {}), + }; + + const semantic: Diagnostic[] = [...catalogDiagnostics, ...responseDiagnostics]; + + return { pattern: ast.pattern, ast, references, structural, semantic }; +} + +/** + * Produce diagnostics for a requirement whose shell parse failed to recover an + * AST. + * + * The frozen shell parser does not run a sentence-level parenthesis balance + * check (the Go reference does, in `parseShell`), so an unbalanced group + * surfaces only as a misleading `ears.missing_system` or `ears.no_match`. When + * the raw text is actually unbalanced, that root cause is reported on its own. + * + * Otherwise the raw findings are mapped through, collapsing redundant + * structural notes to their root cause so the output aligns with the fixture + * corpus: + * - When `ears.no_match` is present the whole text is unrecognized, so the + * narrower `ears.missing_shall` and `ears.missing_system` notes are dropped. + * - When `ears.missing_shall` is present (without `ears.no_match`) there is no + * `shall` boundary, so a system boundary cannot be determined either and the + * redundant `ears.missing_system` is dropped. + * - In guided mode a `lint.suspicious_text_shape` hint is added alongside + * `ears.no_match`, matching the Go reference's guided-mode behavior. + */ +function recoverFailedShell( + text: string, + findings: readonly RawFinding[], + opts: ResolvedOptions, +): Diagnostic[] { + const unbalanced = unbalancedParenFinding(text); + if (unbalanced) { + return [buildDiagnostic(unbalanced, opts.mode)]; + } + + const hasNoMatch = findings.some((finding) => finding.code === 'ears.no_match'); + const hasMissingShall = findings.some((finding) => finding.code === 'ears.missing_shall'); + let kept = findings; + if (hasNoMatch) { + kept = findings.filter( + (finding) => finding.code !== 'ears.missing_shall' && finding.code !== 'ears.missing_system', + ); + } else if (hasMissingShall) { + kept = findings.filter((finding) => finding.code !== 'ears.missing_system'); + } + + const diagnostics = kept.map((finding) => buildDiagnostic(finding, opts.mode)); + if (hasNoMatch && opts.mode === 'guided') { + diagnostics.push(buildDiagnostic({ code: 'lint.suspicious_text_shape' }, opts.mode)); + } + return diagnostics; +} + +/** + * Detect an unbalanced parenthesis in the raw requirement text and return a + * matching `expr.unbalanced_parentheses` finding, or `undefined` when the text + * is balanced. Ports the Go reference `balanceDiagnostics` scan: the first + * stray closing parenthesis is reported at its position; an unclosed opening + * run is reported over the whole text. + */ +function unbalancedParenFinding(text: string): RawFinding | undefined { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + if (depth === 0) { + return { code: 'expr.unbalanced_parentheses', span: { start: i, end: i + 1 } }; + } + depth--; + } + } + if (depth > 0) { + return { code: 'expr.unbalanced_parentheses', span: { start: 0, end: text.length } }; + } + return undefined; +} + +/** + * Split and lint a shell requirement's single raw response. + * + * Splits on semicolons, drops empty parts, and returns both the final response + * list and the response-level lint diagnostics. Pure: it neither reads nor + * writes any AST, so the caller owns building the response list into the AST. + */ +function lintResponses( + rawResponses: readonly string[], + opts: ResolvedOptions, +): { responses: string[]; diagnostics: Diagnostic[] } { + const diagnostics: Diagnostic[] = []; + const raw = rawResponses[0] ?? ''; + + // A sentence break inside the response (a period followed by more text) means + // the shell captured trailing prose after the requirement. Keep only the + // first sentence as the response and flag the remainder as an unparsed tail. + let body = raw; + const sentenceBreak = raw.search(/\.\s+\S/); + if (sentenceBreak >= 0) { + body = raw.slice(0, sentenceBreak).trim(); + diagnostics.push(buildDiagnostic({ code: 'lint.unparsed_tail' }, opts.mode)); + } + + const parts = body + .split(';') + .map((part) => part.trim()) + .filter((part) => part !== ''); + + const responses = parts.length > 0 ? parts : [body]; + + if (parts.length > 1) { + diagnostics.push(buildDiagnostic({ code: 'lint.multiple_responses' }, opts.mode)); + } + + for (const response of responses) { + const lower = response.toLowerCase(); + for (const term of opts.vagueTerms) { + const needle = term.trim().toLowerCase(); + if (needle !== '' && lower.includes(needle)) { + diagnostics.push( + buildDiagnostic({ code: 'lint.vague_response' }, opts.mode, { term: needle }), + ); + } + } + } + + return { responses, diagnostics }; +} + +/** + * Replace every {@link FreeTextExpr} in a clause node with the expression tree + * produced by {@link parseClauseExpression}, preserving the surrounding + * structure. The shell parser only ever emits free-text leaves, optionally + * wrapped in an `and` when the same clause keyword appears more than once, so + * this walk covers every shape the shell parser can produce. + */ +function parseClauseBodies( + node: ClauseExpr | undefined, + opts: ResolvedOptions, + findings: RawFinding[], +): ClauseExpr | undefined { + return node === undefined ? undefined : parseClauseNode(node, opts, findings); +} + +/** Recurse over a defined clause node, replacing every free-text leaf. */ +function parseClauseNode( + node: ClauseExpr, + opts: ResolvedOptions, + findings: RawFinding[], +): ClauseExpr { + if (node.kind === 'free-text') { + // An empty clause body is already reported by the shell parser as + // `ears.empty_clause`; re-parsing it would add a redundant + // `expr.empty_subexpression`, so leave it as free text. + if (node.text.trim() === '') { + return node; + } + const baseOffset = node.span?.start ?? 0; + const { expr, findings: exprFindings } = parseClauseExpression(node.text, baseOffset, { + commaAsAnd: opts.commaAsAnd, + }); + findings.push(...exprFindings); + return expr; + } + if (node.kind === 'and' || node.kind === 'or') { + return { ...node, items: node.items.map((item) => parseClauseNode(item, opts, findings)) }; + } + if (node.kind === 'not' || node.kind === 'group') { + return { ...node, item: parseClauseNode(node.item, opts, findings) }; + } + return node; +} + +/** + * Matches a user-story wrapper line, for example `As a user, I want to reset my + * password` or `As an admin I want fast reports so that ...`. The role phrase + * after `As a`/`As an` and the `I want` goal are the load-bearing markers. + */ +const STORY_WRAPPER_RE = /^\s*as\s+an?\s+.+\bi\s+want\b/i; + +/** + * Whether a line is a user-story frame wrapper rather than an EARS requirement. + * + * Story wrappers (`As a , I want [so that ]`) are frame + * content a host document carries around its requirements. A dialect with + * `allowStoryWrapper` treats such lines as non-requirement content to skip; this + * predicate is the deterministic test the extraction pipeline uses to skip them. + * It never parses or lints, and it is independent of any dialect setting. + * + * @param text A single candidate line. + * @returns `true` when the line is shaped as a user-story wrapper. + */ +export function isStoryWrapperLine(text: string): boolean { + return STORY_WRAPPER_RE.test(text); +} + +/** Project a pipeline result onto the public {@link LintResult} shape. */ +function toLintResult(id: string | undefined, pipeline: PipelineResult): LintResult { + const diagnostics = sortDiagnostics( + dedupeDiagnostics([...pipeline.structural, ...pipeline.semantic]), + ); + const result: LintResult = { + valid: computeValid(diagnostics), + references: pipeline.references, + diagnostics, + }; + if (id !== undefined) { + result.id = id; + } + if (pipeline.pattern !== undefined) { + result.pattern = pipeline.pattern; + } + if (pipeline.ast !== undefined) { + result.ast = pipeline.ast; + } + return result; +} diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts new file mode 100644 index 0000000..f9d3c94 --- /dev/null +++ b/packages/core/src/options.ts @@ -0,0 +1,130 @@ +/** + * Option defaulting for `@earsyntax/core`. + * + * The public {@link Options} shape is fully optional; every function in the core + * API resolves it into a complete {@link ResolvedOptions} before running. This + * module owns that single defaulting step so `lintEars`, `lintEarsBatch`, + * `parseEars`, and `lintCatalogCoverage` all apply identical defaults. + * + * Defaults follow the Go reference `withDefaults` (`ears-lint-go/api.go`): + * strict mode, commas are not treated as `and`, and the vague-term list is + * `appropriate`, `sufficient`, `as needed`. An explicitly empty `vagueTerms` + * array falls back to the defaults, matching the Go behavior. + * + * Determinism note: no dependencies, no clock, no file system, no network. + */ + +import type { DialectOptions, Mode, Options } from './types.js'; + +/** + * The default vague terms flagged in responses when the caller supplies none. + * + * Ported verbatim from the Go reference default vague-term list. + */ +export const DEFAULT_VAGUE_TERMS: readonly string[] = ['appropriate', 'sufficient', 'as needed']; + +/** The default linting mode when none is supplied. */ +export const DEFAULT_MODE: Mode = 'strict'; + +/** + * A fully resolved dialect with every tolerance decided. + * + * The parser and linter branch only on these fields, never on a profile name. + * See `docs/contracts/profile.md` ("dialect") for the semantics of each knob. + */ +export interface ResolvedDialect { + /** Whether EARS keywords must match canonical casing or any casing is accepted. */ + keywordCase: 'strict' | 'case-insensitive'; + /** Literal system phrases accepted in place of the canonical `the ` form. */ + allowLiteralSystemName: string[]; + /** Whether a leading clause must be comma-delimited from the main clause. */ + commaAfterLeadingClause: 'required' | 'optional'; + /** Whether user-story frame lines are treated as skippable frame content. */ + allowStoryWrapper: boolean; + /** Whether `REQ-###` ids and `[source: path:line]` tags are accepted as frame metadata. */ + allowFrameMetadata: boolean; + /** Whether `shall not` is accepted as a prohibition kind. */ + allowProhibition: boolean; +} + +/** + * The canonical strict dialect: Mavin's ruleset with nothing relaxed. + * + * This is the default dialect for {@link withDefaults}, so `lintEars` and + * `parseEars` apply the strict tightenings (keyword casing, a required leading + * comma, no prohibition, no frame metadata, no story wrapper) unless a caller + * passes a relaxing {@link DialectOptions} block. + */ +export const STRICT_DIALECT: ResolvedDialect = { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: false, + allowProhibition: false, +}; + +/** + * Merge a partial {@link DialectOptions} block over {@link STRICT_DIALECT}. + * + * Every absent field takes its strict default, so an omitted `dialect` yields + * the fully strict dialect. Arrays are copied so the resolved dialect never + * aliases the caller's input. + * + * @param options The caller-supplied options, or `undefined`. + * @returns A resolved dialect with every tolerance decided. + */ +export function resolveDialect(options?: Options): ResolvedDialect { + const dialect: DialectOptions | undefined = options?.dialect; + return { + keywordCase: dialect?.keywordCase ?? STRICT_DIALECT.keywordCase, + allowLiteralSystemName: dialect?.allowLiteralSystemName + ? [...dialect.allowLiteralSystemName] + : [], + commaAfterLeadingClause: + dialect?.commaAfterLeadingClause ?? STRICT_DIALECT.commaAfterLeadingClause, + allowStoryWrapper: dialect?.allowStoryWrapper ?? STRICT_DIALECT.allowStoryWrapper, + allowFrameMetadata: dialect?.allowFrameMetadata ?? STRICT_DIALECT.allowFrameMetadata, + allowProhibition: dialect?.allowProhibition ?? STRICT_DIALECT.allowProhibition, + }; +} + +/** + * A fully resolved option set with every field present. + * + * The core pipeline works against this shape so it never has to re-check for + * absent option fields. + */ +export interface ResolvedOptions { + /** Linting strictness. */ + mode: Mode; + /** Whether unambiguous commas inside clause bodies are treated as `and`. */ + commaAsAnd: boolean; + /** Terms flagged as vague when they appear in a response. */ + vagueTerms: string[]; + /** The resolved dialect tolerances applied while parsing and linting. */ + dialect: ResolvedDialect; +} + +/** + * Resolve a partial {@link Options} into a complete {@link ResolvedOptions}. + * + * Applies the core defaults: `mode` defaults to `strict`, `commaAsAnd` defaults + * to `false`, and `vagueTerms` defaults to {@link DEFAULT_VAGUE_TERMS} when the + * caller supplies no terms (or an empty array). + * + * @param options The caller-supplied options, or `undefined`. + * @returns A resolved option set with every field present. + */ +export function withDefaults(options?: Options): ResolvedOptions { + const vagueTerms = + options?.vagueTerms && options.vagueTerms.length > 0 + ? [...options.vagueTerms] + : [...DEFAULT_VAGUE_TERMS]; + return { + mode: options?.mode ?? DEFAULT_MODE, + commaAsAnd: options?.commaAsAnd ?? false, + vagueTerms, + dialect: resolveDialect(options), + }; +} diff --git a/packages/core/src/pipeline.test.ts b/packages/core/src/pipeline.test.ts new file mode 100644 index 0000000..c42a3df --- /dev/null +++ b/packages/core/src/pipeline.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for the host-native findings-assembly stage (`candidatesToFindings`). + * + * These cover the parse + lint + findings half of the pipeline: candidate + * positions map through to diagnostics, every file group is counted, profile + * severity overrides drop diagnostics, and `strict` upgrades warnings. + */ + +import { describe, expect, it } from 'vitest'; +import { candidatesToFindings, type Candidate, type CandidateFile } from './pipeline.js'; +import { BUILTIN_PROFILES, type Profile } from './profiles/index.js'; + +const STRICT = BUILTIN_PROFILES.strict; + +function candidate(over: Partial & Pick): Candidate { + return { + file: 'requirements.ears', + col: 1, + profile: 'strict', + locatorRuleId: 'strict.every-line', + ...over, + }; +} + +describe('candidatesToFindings', () => { + it('counts every file group, even one with no candidates', () => { + const files: CandidateFile[] = [ + { file: 'a.ears', candidates: [candidate({ text: 'The system shall stop.', line: 1 })] }, + { file: 'b.ears', candidates: [] }, + ]; + const findings = candidatesToFindings(files, STRICT); + expect(findings.summary.files).toBe(2); + expect(findings.summary.requirements).toBe(1); + }); + + it('maps candidate line and col through to the diagnostic', () => { + const files: CandidateFile[] = [ + { + file: 'spec.ears', + candidates: [ + candidate({ + text: 'This is not a requirement at all.', + line: 12, + col: 7, + file: 'spec.ears', + }), + ], + }, + ]; + const findings = candidatesToFindings(files, STRICT); + expect(findings.ok).toBe(false); + expect(findings.diagnostics).toHaveLength(1); + const [diag] = findings.diagnostics; + expect(diag.id).toBe('EARS-E010'); + expect(diag.file).toBe('spec.ears'); + expect(diag.line).toBe(12); + expect(diag.col).toBe(7); + }); + + it('omits col when the candidate carries none', () => { + const files: CandidateFile[] = [ + { + file: 'spec.ears', + candidates: [ + { + file: 'spec.ears', + line: 3, + text: 'Nonsense line.', + locatorRuleId: 'r', + profile: 'strict', + }, + ], + }, + ]; + const findings = candidatesToFindings(files, STRICT); + expect(findings.diagnostics[0].col).toBeUndefined(); + }); + + it('reports a clean requirement as valid with no diagnostics', () => { + const files: CandidateFile[] = [ + { + file: 'a.ears', + candidates: [ + candidate({ text: 'The billing service shall verify the signature.', line: 1 }), + ], + }, + ]; + const findings = candidatesToFindings(files, STRICT); + expect(findings.ok).toBe(true); + expect(findings.summary.valid).toBe(1); + expect(findings.diagnostics).toHaveLength(0); + }); + + it('drops a diagnostic the profile turns off', () => { + const vague = candidate({ text: 'The system shall respond appropriately.', line: 1 }); + const base = candidatesToFindings([{ file: 'a.ears', candidates: [vague] }], STRICT); + expect(base.diagnostics.map((d) => d.id)).toEqual(['EARS-W016']); + + const silenced: Profile = { ...STRICT, severity: { 'EARS-W016': 'off' } }; + const off = candidatesToFindings([{ file: 'a.ears', candidates: [vague] }], silenced); + expect(off.diagnostics).toHaveLength(0); + expect(off.ok).toBe(true); + }); + + it('upgrades warnings to errors under strict', () => { + const vague = candidate({ text: 'The system shall respond appropriately.', line: 1 }); + const findings = candidatesToFindings([{ file: 'a.ears', candidates: [vague] }], STRICT, { + strict: true, + }); + expect(findings.diagnostics[0].severity).toBe('error'); + expect(findings.ok).toBe(false); + expect(findings.summary.errors).toBe(1); + }); + + it('carries the requirement id onto the diagnostic', () => { + const files: CandidateFile[] = [ + { + file: 'a.ears', + candidates: [ + candidate({ requirementId: 'REQ-007', text: 'Bad requirement text.', line: 2 }), + ], + }, + ]; + const findings = candidatesToFindings(files, STRICT); + expect(findings.diagnostics[0].requirementId).toBe('REQ-007'); + }); +}); diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts new file mode 100644 index 0000000..fc748b2 --- /dev/null +++ b/packages/core/src/pipeline.ts @@ -0,0 +1,154 @@ +/** + * Host-native validation pipeline, findings-assembly stage (`@earsyntax/core`). + * + * The full pipeline is `locate -> extract -> parse -> lint -> findings`. Stages + * 1 and 2 (locate host document regions and extract candidate requirement text + * with source positions) live in `@earsyntax/extract`, the layer that may read + * files and depend on `js-yaml`. This module owns stages 3 and 4, which are pure + * core logic: + * + * 3. parse + lint: feed each candidate's text to the core linter under the + * active profile's dialect and an optional catalog. + * 4. findings: fold the per-candidate {@link LintResult}s into the frozen + * {@link Findings} model through {@link toFindings}, applying the profile's + * severity overrides and the `--strict` flag, with every diagnostic mapped + * back to the candidate's original `file:line:col`. + * + * The {@link Candidate} and {@link PipelineNotice} shapes are defined here so the + * two packages agree on a single type: `@earsyntax/extract` produces them and + * this module consumes them. + * + * Determinism contract: no LLM, no network, no file system, no clock, no random + * source. The same candidates always produce a deeply equal, stably ordered + * Findings value. + */ + +import { + toFindings, + type Findings, + type FindingsInputFile, + type FindingsInputItem, +} from './findings.js'; +import { lintEarsBatch } from './lint.js'; +import type { Profile, ProfileName } from './profiles/index.js'; +import type { Catalog, RequirementInput } from './types.js'; + +/** + * One requirement candidate located in a host document, with its original + * source position and the locator rule that selected it. + * + * This is the unit `extract` reports and the unit the lint stage consumes. The + * position (`line`, optional `col`) is 1-based and refers to the ORIGINAL host + * document, preserved through every stage so a diagnostic points at the text the + * agent edits. Object keys follow the fixed facade order `file`, `line`, `col?`, + * `text`, `profile`, `locatorRuleId`, `requirementId?` when serialized by the + * CLI (see `docs/refactor/host-native-facade.md`). + */ +export interface Candidate { + /** Source file path, relative to `--cwd` (POSIX), or `-` for stdin. */ + file: string; + /** 1-based line of the candidate's first character in the host document. */ + line: number; + /** 1-based column of the candidate text's first character, when known. */ + col?: number; + /** The candidate requirement text. A markdown bold id label is removed; an ears-x frame prefix is retained (the linter strips it at parse time). */ + text: string; + /** The active profile's name. */ + profile: ProfileName; + /** The id of the {@link import('./profiles/index.js').LocatorRule} that selected this candidate. */ + locatorRuleId: string; + /** The requirement's own id (for example `REQ-001` or `FR-001`), when the locator found one. */ + requirementId?: string; +} + +/** + * A recoverable problem raised while locating or extracting candidates. + * + * Extraction never throws: a malformed structured document (bad YAML/JSON, wrong + * top-level shape) or an unreadable file produces an empty candidate list plus a + * notice here. Notices are the pipeline's environment/usage channel; they are + * NOT lint findings and never enter the {@link Findings} model. The CLI projects + * them onto the facade-level `diagnostics` array (see + * `docs/refactor/host-native-facade.md`). + */ +export interface PipelineNotice { + /** A stable dotted code (for example `extract.malformed_yaml`). */ + code: string; + /** Notice severity. Never `off`; that concept is severity-override only. */ + severity: 'error' | 'warning'; + /** One factual sentence describing the problem. */ + message: string; + /** The source file the problem relates to, when known. */ + file?: string; + /** 1-based line the problem relates to, when known. */ + line?: number; +} + +/** All candidates located in one source file, in document order. */ +export interface CandidateFile { + /** The source file path, relative to `--cwd` (POSIX), or `-` for stdin. */ + file: string; + /** The candidates located in the file, in document order. */ + candidates: Candidate[]; +} + +/** Options that tune {@link candidatesToFindings}. */ +export interface LintCandidatesOptions { + /** Upgrade every surviving `warning` to `error` at the findings layer. */ + strict?: boolean; + /** Optional catalog of known domain terms, passed to the core linter. */ + catalog?: Catalog; +} + +/** + * Convert located candidates into the canonical {@link Findings} model. + * + * Every file group is preserved (even an empty one) so `summary.files` counts + * every located and read file. Each candidate becomes a {@link RequirementInput} + * carrying its original `file:line:col`; the batch is linted under the profile's + * dialect and the optional catalog; the results are folded through + * {@link toFindings} with the profile's `severity` overrides and `strict`. + * + * The profile's `idFormat` is not enforced here: the diagnostic registry carries + * no id-format code yet, so id-format findings are out of scope for this stage. + * + * @param files Candidates grouped by source file, in processed order. + * @param profile The active profile (supplies dialect and severity overrides). + * @param options `strict` and an optional catalog. + * @returns The assembled Findings result. + */ +export function candidatesToFindings( + files: readonly CandidateFile[], + profile: Profile, + options: LintCandidatesOptions = {}, +): Findings { + const input: FindingsInputFile[] = files.map((fileGroup) => { + const inputs: RequirementInput[] = fileGroup.candidates.map((candidate) => + candidateToRequirementInput(fileGroup.file, candidate), + ); + const results = lintEarsBatch(inputs, options.catalog, { dialect: profile.dialect }); + const items: FindingsInputItem[] = inputs.map((requirement, index) => ({ + input: requirement, + result: results[index], + })); + return { file: fileGroup.file, items }; + }); + + return toFindings(input, { overrides: profile.severity, strict: options.strict }); +} + +/** + * Build the {@link RequirementInput} for one candidate, carrying its original + * source position so diagnostics map back to the host document. + */ +function candidateToRequirementInput(file: string, candidate: Candidate): RequirementInput { + const source: RequirementInput['source'] = { file, line: candidate.line }; + if (candidate.col !== undefined) { + source.column = candidate.col; + } + return { + ...(candidate.requirementId === undefined ? {} : { id: candidate.requirementId }), + text: candidate.text, + source, + }; +} diff --git a/packages/core/src/profiles/builtins.test.ts b/packages/core/src/profiles/builtins.test.ts new file mode 100644 index 0000000..c99ca1f --- /dev/null +++ b/packages/core/src/profiles/builtins.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for the built-in profile data. + * + * Every built-in must be schema-valid, the render order is fixed, and `ears-x` + * must be a strict superset: each dialect field equal to or looser than strict. + */ + +import { describe, expect, test } from 'vitest'; +import { BUILTIN_PROFILES, BUILTIN_PROFILE_NAMES } from './builtins.js'; +import { validateProfile, type Profile } from './schema.js'; + +describe('built-in profiles', () => { + test('render order is the frozen order', () => { + expect(BUILTIN_PROFILE_NAMES).toEqual(['strict', 'ears-x', 'kiro', 'speckit', 'openspec']); + }); + + test('every built-in is schema-valid', () => { + for (const name of BUILTIN_PROFILE_NAMES) { + const result = validateProfile(BUILTIN_PROFILES[name]); + expect(result.ok, `profile ${name} should validate`).toBe(true); + } + }); + + test('each built-in name matches its key', () => { + for (const name of BUILTIN_PROFILE_NAMES) { + expect(BUILTIN_PROFILES[name].name).toBe(name); + } + }); + + test('locator rule ids are unique across include and exclude within a profile', () => { + for (const name of BUILTIN_PROFILE_NAMES) { + const profile = BUILTIN_PROFILES[name]; + const ids = [...profile.locator.include, ...profile.locator.exclude].map((rule) => rule.id); + expect(new Set(ids).size, `profile ${name} rule ids unique`).toBe(ids.length); + } + }); +}); + +/** Whether ears-x's value for one dialect field is equal to or looser than strict's. */ +function fieldIsLooserOrEqual( + strict: Profile, + earsx: Profile, + field: keyof Profile['dialect'], +): boolean { + const s = strict.dialect; + const x = earsx.dialect; + switch (field) { + case 'keywordCase': + // strict casing is the tightest; case-insensitive is looser. + return s.keywordCase === 'strict' || x.keywordCase === s.keywordCase; + case 'commaAfterLeadingClause': + // required is the tightest; optional is looser. + return ( + s.commaAfterLeadingClause === 'required' || + x.commaAfterLeadingClause === s.commaAfterLeadingClause + ); + case 'allowLiteralSystemName': + // Superset of accepted literals is looser. + return s.allowLiteralSystemName.every((literal) => + x.allowLiteralSystemName.includes(literal), + ); + case 'allowStoryWrapper': + case 'allowFrameMetadata': + case 'allowProhibition': + // A false->true move accepts strictly more; true->false would be tighter. + return x[field] || !s[field]; + default: + return true; + } +} + +describe('ears-x superset invariant', () => { + const strict = BUILTIN_PROFILES.strict; + const earsx = BUILTIN_PROFILES['ears-x']; + const fields: (keyof Profile['dialect'])[] = [ + 'keywordCase', + 'allowLiteralSystemName', + 'commaAfterLeadingClause', + 'allowStoryWrapper', + 'allowFrameMetadata', + 'allowProhibition', + ]; + + for (const field of fields) { + test(`ears-x '${field}' is equal or looser than strict`, () => { + expect(fieldIsLooserOrEqual(strict, earsx, field)).toBe(true); + }); + } + + test('ears-x shares strict document kinds and every-line locator', () => { + expect(earsx.locator.documentKinds).toEqual(strict.locator.documentKinds); + expect(earsx.locator.include[0]?.kind).toBe('every-line'); + }); +}); diff --git a/packages/core/src/profiles/builtins.ts b/packages/core/src/profiles/builtins.ts new file mode 100644 index 0000000..b6a1718 --- /dev/null +++ b/packages/core/src/profiles/builtins.ts @@ -0,0 +1,233 @@ +/** + * Built-in profiles as data for `@earsyntax/core`. + * + * Each profile is a plain const object conforming to profile schema v1 + * (`docs/contracts/profile.md`). There is no behavior here: adding a host means + * adding a data object, not editing a parser conditional. Every object in this + * module is validated by {@link validateProfile} in the profile test suite. + * + * These are sane, schema-valid first versions. The host profile agents (plan + * Agents 09-12) refine the concrete locator patterns and severity maps against + * fixture pairs; the shapes and the superset relationship they must preserve + * are fixed here. + * + * Determinism note: pure data. No clock, file system, or network. + */ + +import type { Profile, ProfileName } from './schema.js'; + +/** + * `strict`: canonical Mavin EARS. The default profile. Locates every non-empty + * line of `.ears` and plain-text files. No dialect tolerances, no id + * requirement, no severity overrides. + */ +const STRICT: Profile = { + name: 'strict', + notation: 'ears', + dialect: { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: false, + allowProhibition: false, + }, + locator: { + documentKinds: ['ears', 'text'], + include: [{ id: 'strict.every-line', kind: 'every-line' }], + exclude: [], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false }, +}; + +/** + * `ears-x`: a strict superset. Every strict-valid requirement is ears-x-valid + * unchanged; ears-x only adds tolerances (frame metadata, `[source:]` tags, + * `shall not` prohibition) and an optional `REQ-###` id shape. Same every-line + * locator over `.ears` and plain text. + */ +const EARS_X: Profile = { + name: 'ears-x', + notation: 'ears', + dialect: { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: true, + allowProhibition: true, + }, + locator: { + documentKinds: ['ears', 'text'], + include: [{ id: 'ears-x.every-line', kind: 'every-line' }], + exclude: [], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false, pattern: '^REQ-\\d+$' }, +}; + +/** + * `kiro`: EARS embedded in Kiro `requirements.md`. Relaxes casing, literal + * system name, and the leading comma, and skips user-story wrapper lines as + * frame content. Locates bullet and numbered list items under + * `#### Acceptance Criteria` headings. Severity is tuned so Kiro house style + * validates clean while the same document fails under strict. + */ +const KIRO: Profile = { + name: 'kiro', + notation: 'ears', + dialect: { + keywordCase: 'case-insensitive', + allowLiteralSystemName: ['THE SYSTEM'], + commaAfterLeadingClause: 'optional', + allowStoryWrapper: true, + allowFrameMetadata: false, + allowProhibition: false, + }, + locator: { + documentKinds: ['markdown'], + include: [ + { + id: 'kiro.acceptance-criteria-item', + kind: 'list-item', + underHeading: '^acceptance criteria$', + listMarker: 'any', + note: 'Bullet and numbered items under #### Acceptance Criteria headings in requirements.md.', + }, + ], + exclude: [], + codeFences: 'ignore', + }, + severity: { + 'EARS-W011': 'off', + 'EARS-W014': 'off', + }, + idFormat: { required: false }, +}; + +/** + * `speckit`: EARS in Spec Kit `specs/**\/spec.md`. Near-strict dialect. The + * grammar is identical to strict; the profile differs only in its markdown + * locator. The include rule targets the `## Requirements` / + * `### Functional Requirements` section, whose Spec Kit body is a bullet list of + * `- **FR-###**: ` items. The exclude rule drops sibling prose + * subsections (`### Key Entities`, design, background, and similar) that Spec Kit + * nests inside or beside the Requirements section, so a `- **[Entity]**: ...` + * bullet or a narrative sentence that merely opens with an EARS keyword never + * becomes a candidate. Narrative sections such as `## User Scenarios & Testing` + * are skipped simply by not matching the include heading. + * + * FR-### interplay: the `FR-###` label is markdown list-item structure that the + * extractor strips, not a grammar tolerance. `speckit` therefore keeps + * `allowFrameMetadata: false` (the `REQ-###` / `[source:]` frame-metadata form + * stays a strict error) and sets no `idFormat.pattern`; a malformed FR label is + * a locator/extractor concern, not a dialect one. See + * `fixtures/profiles/speckit/NOTES.md`. + */ +const SPECKIT: Profile = { + name: 'speckit', + notation: 'ears', + dialect: { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: false, + allowProhibition: false, + }, + locator: { + documentKinds: ['markdown'], + include: [ + { + id: 'speckit.requirements-section', + kind: 'heading-section', + headingPattern: '^(functional )?requirements$', + note: 'Body lines of Requirements / Functional Requirements sections in specs/**/spec.md.', + }, + ], + exclude: [ + { + id: 'speckit.non-requirement-section', + kind: 'heading-section', + headingPattern: + '^(design|background|context|overview|non-goals?|key entities|success criteria|assumptions)$', + note: 'Prose subsections nested in or beside Requirements that must not produce candidates (notably Spec Kit Key Entities).', + }, + ], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false }, +}; + +/** + * `openspec`: EARS in OpenSpec specs and changes. Near-strict dialect (identical + * grammar to strict; the only differences are the markdown locator and + * `documentKinds`). Locates `### Requirement:` bodies and `#### Scenario:` + * blocks inside `openspec/specs/**` and `openspec/changes/**`. The candidate a + * block contributes is its first EARS-shaped body line: an OpenSpec requirement + * declares one statement (`The shall ...` or a `When`/`While`/`Where`/ + * `If` line) directly under its `### Requirement:` heading, while `#### Scenario:` + * blocks hold only Gherkin WHEN, THEN, and AND steps, which are frame + * content and contribute no candidates. Extraction is delta-section-agnostic: + * `## ADDED`/`## MODIFIED`/`## REMOVED` are H2 organizational headers, not + * locator targets, so a `### Requirement:` block is a candidate under any of + * them. See `fixtures/profiles/openspec/NOTES.md`. + */ +const OPENSPEC: Profile = { + name: 'openspec', + notation: 'ears', + dialect: { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: false, + allowProhibition: false, + }, + locator: { + documentKinds: ['markdown'], + include: [ + { + id: 'openspec.requirement', + kind: 'block', + blockPrefix: '### Requirement:', + note: 'First EARS-shaped body line under a ### Requirement: heading: the single requirement statement OpenSpec places directly beneath the heading.', + }, + { + id: 'openspec.scenario', + kind: 'block', + blockPrefix: '#### Scenario:', + note: 'A #### Scenario: block if it carries a stray EARS-shaped line; standard Gherkin WHEN / THEN / AND steps are frame content and yield no candidate.', + }, + ], + exclude: [], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false }, +}; + +/** + * The built-in profiles keyed by name, in the frozen order `strict`, `ears-x`, + * `kiro`, `speckit`, `openspec` (the order the `profiles` command renders). + */ +export const BUILTIN_PROFILES: Readonly> = { + strict: STRICT, + 'ears-x': EARS_X, + kiro: KIRO, + speckit: SPECKIT, + openspec: OPENSPEC, +}; + +/** The five built-in profile names in render order. */ +export const BUILTIN_PROFILE_NAMES: readonly ProfileName[] = [ + 'strict', + 'ears-x', + 'kiro', + 'speckit', + 'openspec', +]; diff --git a/packages/core/src/profiles/diff.test.ts b/packages/core/src/profiles/diff.test.ts new file mode 100644 index 0000000..9a47887 --- /dev/null +++ b/packages/core/src/profiles/diff.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for profile diffing. + * + * The diff is derived from profile data, so these assertions double as a guard + * that the built-in data keeps the intended relationship to strict. A snapshot + * locks the full rendered set for the `profiles` command. + */ + +import { describe, expect, test } from 'vitest'; +import { BUILTIN_PROFILES } from './builtins.js'; +import { diffProfile, summarizeProfiles } from './diff.js'; + +describe('diffProfile', () => { + test('strict diffed against itself is empty', () => { + const diff = diffProfile(BUILTIN_PROFILES.strict); + expect(diff.relaxes).toEqual([]); + expect(diff.adds).toEqual([]); + expect(diff.severityOverrides).toEqual({}); + }); + + test('ears-x only adds, never relaxes', () => { + const diff = diffProfile(BUILTIN_PROFILES['ears-x']); + expect(diff.relaxes).toEqual([]); + expect(diff.adds).toContain('frame metadata'); + expect(diff.adds).toContain('prohibition (shall not)'); + expect(diff.adds.some((entry) => entry.startsWith('id format'))).toBe(true); + }); + + test('kiro relaxes casing, comma, literal system name, and story wrappers', () => { + const diff = diffProfile(BUILTIN_PROFILES.kiro); + expect(diff.relaxes).toEqual( + expect.arrayContaining([ + 'keyword case', + 'leading comma', + expect.stringContaining('literal system name'), + 'user-story wrappers', + ]), + ); + expect(diff.adds).toEqual([]); + expect(diff.severityOverrides['EARS-W011']).toBe('off'); + }); + + test('locates is generated from locator data', () => { + expect(diffProfile(BUILTIN_PROFILES.strict).locates).toBe( + 'every non-empty line in ears, text files.', + ); + expect(diffProfile(BUILTIN_PROFILES.openspec).locates).toContain('### Requirement: blocks'); + expect(diffProfile(BUILTIN_PROFILES.openspec).locates).toContain('#### Scenario: blocks'); + }); +}); + +describe('summarizeProfiles', () => { + test('returns all five profiles in render order', () => { + expect(summarizeProfiles().map((diff) => diff.name)).toEqual([ + 'strict', + 'ears-x', + 'kiro', + 'speckit', + 'openspec', + ]); + }); + + test('snapshot of the full rendered summary set', () => { + expect(summarizeProfiles()).toMatchInlineSnapshot(` + [ + { + "adds": [], + "locates": "every non-empty line in ears, text files.", + "name": "strict", + "relaxes": [], + "severityOverrides": {}, + }, + { + "adds": [ + "frame metadata", + "prohibition (shall not)", + "id format (^REQ-\\d+$)", + ], + "locates": "every non-empty line in ears, text files.", + "name": "ears-x", + "relaxes": [], + "severityOverrides": {}, + }, + { + "adds": [], + "locates": "list items under /^acceptance criteria$/ in markdown files.", + "name": "kiro", + "relaxes": [ + "keyword case", + "leading comma", + "literal system name (THE SYSTEM)", + "user-story wrappers", + ], + "severityOverrides": { + "EARS-W011": "off", + "EARS-W014": "off", + }, + }, + { + "adds": [], + "locates": "sections matching /^(functional )?requirements$/ in markdown files.", + "name": "speckit", + "relaxes": [], + "severityOverrides": {}, + }, + { + "adds": [], + "locates": "### Requirement: blocks and #### Scenario: blocks in markdown files.", + "name": "openspec", + "relaxes": [], + "severityOverrides": {}, + }, + ] + `); + }); +}); diff --git a/packages/core/src/profiles/diff.ts b/packages/core/src/profiles/diff.ts new file mode 100644 index 0000000..9b4842b --- /dev/null +++ b/packages/core/src/profiles/diff.ts @@ -0,0 +1,125 @@ +/** + * Profile diffing for `@earsyntax/core`. + * + * For each built-in profile this computes a structured diff against `strict`: + * which dialect tolerances it relaxes, which capabilities it adds, and which + * severities it overrides. It also renders a one-line `locates` summary from the + * profile's locator data. Everything here is derived from the profile objects, + * so the future `profiles` command cannot drift from the data (there is no + * hand-written per-profile description). + * + * Determinism note: pure functions over profile data. No clock, file system, or + * network. + */ + +import { BUILTIN_PROFILES, BUILTIN_PROFILE_NAMES } from './builtins.js'; +import type { LocatorRule, Profile, ProfileName, SeverityLevel } from './schema.js'; + +/** + * A profile's difference from `strict`, plus a generated locator summary. + * + * This is the shape the `profiles` command renders (`ProfileSummary` in the + * facade contract): `{ name, locates, relaxes, adds, severityOverrides }`. + */ +export interface ProfileDiff { + /** The profile name. */ + name: ProfileName; + /** One-line locator summary generated from locator data. */ + locates: string; + /** Dialect tolerances this profile loosens relative to strict. */ + relaxes: string[]; + /** Capabilities this profile adds relative to strict. */ + adds: string[]; + /** Severity overrides this profile applies relative to strict (keyed by diagnostic id). */ + severityOverrides: Record; +} + +function describeRule(rule: LocatorRule): string { + switch (rule.kind) { + case 'every-line': + return 'every non-empty line'; + case 'heading-section': + return rule.headingPattern ? `sections matching /${rule.headingPattern}/` : 'sections'; + case 'list-item': + return rule.underHeading ? `list items under /${rule.underHeading}/` : 'list items'; + case 'block': + return rule.blockPrefix ? `${rule.blockPrefix} blocks` : 'blocks'; + default: + return rule.kind; + } +} + +function generateLocates(profile: Profile): string { + const kinds = profile.locator.documentKinds.join(', '); + if (profile.locator.include.length === 0) { + return `no regions in ${kinds} files.`; + } + const included = profile.locator.include.map(describeRule).join(' and '); + return `${included} in ${kinds} files.`; +} + +/** + * Compute the diff of `profile` against the `strict` baseline. + * + * `strict` diffed against itself yields empty `relaxes`, `adds`, and + * `severityOverrides`. + */ +export function diffProfile(profile: Profile): ProfileDiff { + const strict = BUILTIN_PROFILES.strict; + const relaxes: string[] = []; + const adds: string[] = []; + + const dialect = profile.dialect; + + if ( + dialect.keywordCase !== strict.dialect.keywordCase && + dialect.keywordCase === 'case-insensitive' + ) { + relaxes.push('keyword case'); + } + if ( + dialect.commaAfterLeadingClause !== strict.dialect.commaAfterLeadingClause && + dialect.commaAfterLeadingClause === 'optional' + ) { + relaxes.push('leading comma'); + } + if (dialect.allowLiteralSystemName.length > strict.dialect.allowLiteralSystemName.length) { + relaxes.push(`literal system name (${dialect.allowLiteralSystemName.join(', ')})`); + } + if (dialect.allowStoryWrapper && !strict.dialect.allowStoryWrapper) { + relaxes.push('user-story wrappers'); + } + + if (dialect.allowFrameMetadata && !strict.dialect.allowFrameMetadata) { + adds.push('frame metadata'); + } + if (dialect.allowProhibition && !strict.dialect.allowProhibition) { + adds.push('prohibition (shall not)'); + } + if (profile.idFormat.pattern && !strict.idFormat.pattern) { + adds.push(`id format (${profile.idFormat.pattern})`); + } + + const severityOverrides: Record = {}; + for (const [id, level] of Object.entries(profile.severity)) { + if (strict.severity[id] !== level) { + severityOverrides[id] = level; + } + } + + return { + name: profile.name, + locates: generateLocates(profile), + relaxes, + adds, + severityOverrides, + }; +} + +/** + * The diffs of all five built-in profiles against `strict`, in render order + * (`strict`, `ears-x`, `kiro`, `speckit`, `openspec`). + */ +export function summarizeProfiles(): ProfileDiff[] { + return BUILTIN_PROFILE_NAMES.map((name) => diffProfile(BUILTIN_PROFILES[name])); +} diff --git a/packages/core/src/profiles/index.ts b/packages/core/src/profiles/index.ts new file mode 100644 index 0000000..d4534e5 --- /dev/null +++ b/packages/core/src/profiles/index.ts @@ -0,0 +1,36 @@ +/** + * `@earsyntax/core` profiles subsystem barrel. + * + * Re-exports the profile schema types and validator, the built-in profile data, + * resolution, and diffing. Profiles are data, not code: nothing here branches on + * a profile name beyond validation and diffing. + */ + +export type { + Profile, + ProfileName, + ProfileDialect, + ProfileLocator, + ProfileIdFormat, + LocatorRule, + LocatorRuleKind, + ListMarker, + KeywordCase, + CommaAfterLeadingClause, + CodeFences, + SeverityLevel, + ProfileValidationError, + ProfileValidationErrorCode, + ProfileValidationResult, +} from './schema.js'; +export { validateProfile } from './schema.js'; + +export { KNOWN_DIAGNOSTIC_IDS, isKnownDiagnosticId } from './registry-ids.js'; + +export { BUILTIN_PROFILES, BUILTIN_PROFILE_NAMES } from './builtins.js'; + +export { resolveProfile } from './resolve.js'; +export type { ResolveProfileResult, UnknownProfileError } from './resolve.js'; + +export { diffProfile, summarizeProfiles } from './diff.js'; +export type { ProfileDiff } from './diff.js'; diff --git a/packages/core/src/profiles/registry-ids.ts b/packages/core/src/profiles/registry-ids.ts new file mode 100644 index 0000000..9f7d5eb --- /dev/null +++ b/packages/core/src/profiles/registry-ids.ts @@ -0,0 +1,35 @@ +/** + * The set of diagnostic ids a profile `severity` map is allowed to key on. + * + * A profile classifies diagnostics by their stable registry id + * (`EARS-E###` / `EARS-W###`), never by the old dotted `DiagnosticCode`. The + * authoritative id space is the diagnostic registry + * (`packages/core/src/registry.ts`); this module derives the legal-key set from + * it so the two can never drift. + * + * Only current ids are legal severity keys. Deprecated dotted aliases resolve in + * `explain` but are rejected as override keys (see `docs/contracts/profile.md`, + * validation rule 4). {@link isKnownDiagnosticId} therefore treats an id as + * known only when it is a current registry id, not an alias. + * + * Determinism note: pure data derived once from the frozen registry. No clock, + * file system, or network. + */ + +import { DIAGNOSTIC_REGISTRY } from '../registry.js'; + +/** + * Every current diagnostic id, in the registry's ascending id order (errors, + * then warnings). Deprecated aliases are deliberately absent. + */ +export const KNOWN_DIAGNOSTIC_IDS: readonly string[] = DIAGNOSTIC_REGISTRY.map((entry) => entry.id); + +const KNOWN_DIAGNOSTIC_ID_SET = new Set(KNOWN_DIAGNOSTIC_IDS); + +/** + * Whether `id` is a current diagnostic id and therefore a legal profile + * `severity` key. Deprecated aliases return `false`. + */ +export function isKnownDiagnosticId(id: string): boolean { + return KNOWN_DIAGNOSTIC_ID_SET.has(id); +} diff --git a/packages/core/src/profiles/resolve.test.ts b/packages/core/src/profiles/resolve.test.ts new file mode 100644 index 0000000..3bb4664 --- /dev/null +++ b/packages/core/src/profiles/resolve.test.ts @@ -0,0 +1,34 @@ +/** + * Tests for resolveProfile. + */ + +import { describe, expect, test } from 'vitest'; +import { BUILTIN_PROFILE_NAMES } from './builtins.js'; +import { resolveProfile } from './resolve.js'; + +describe('resolveProfile', () => { + test('resolves every built-in name to its profile', () => { + for (const name of BUILTIN_PROFILE_NAMES) { + const result = resolveProfile(name); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.profile.name).toBe(name); + } + } + }); + + test('returns a typed unknown-profile error for an unknown name', () => { + const result = resolveProfile('nope'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('unknown-profile'); + expect(result.error.name).toBe('nope'); + expect(result.error.available).toEqual(BUILTIN_PROFILE_NAMES); + expect(result.error.message).toContain('nope'); + } + }); + + test('is case-sensitive: STRICT does not resolve', () => { + expect(resolveProfile('STRICT').ok).toBe(false); + }); +}); diff --git a/packages/core/src/profiles/resolve.ts b/packages/core/src/profiles/resolve.ts new file mode 100644 index 0000000..bd3d7d5 --- /dev/null +++ b/packages/core/src/profiles/resolve.ts @@ -0,0 +1,54 @@ +/** + * Profile resolution for `@earsyntax/core`. + * + * `resolveProfile` maps a `--profile` name to a built-in profile or a typed + * unknown-profile error. The error feeds the CLI's exit `2` path (see the + * facade contract); this function never throws. + * + * Determinism note: pure lookup over {@link BUILTIN_PROFILES}. No clock, file + * system, or network. + */ + +import { BUILTIN_PROFILES, BUILTIN_PROFILE_NAMES } from './builtins.js'; +import type { Profile, ProfileName } from './schema.js'; + +/** The reason a name did not resolve to a built-in profile. */ +export interface UnknownProfileError { + code: 'unknown-profile'; + /** The name the caller passed. */ + name: string; + /** The available built-in names, for a helpful message. */ + available: readonly ProfileName[]; + /** Human-readable explanation. */ + message: string; +} + +/** The outcome of {@link resolveProfile}. */ +export type ResolveProfileResult = + { ok: true; profile: Profile } | { ok: false; error: UnknownProfileError }; + +function isProfileName(name: string): name is ProfileName { + return (BUILTIN_PROFILE_NAMES as readonly string[]).includes(name); +} + +/** + * Resolve a profile name to its built-in profile. + * + * @param name The `--profile` value (defaults to `strict` at the CLI layer, not + * here). + * @returns The built-in profile, or a typed unknown-profile error. + */ +export function resolveProfile(name: string): ResolveProfileResult { + if (isProfileName(name)) { + return { ok: true, profile: BUILTIN_PROFILES[name] }; + } + return { + ok: false, + error: { + code: 'unknown-profile', + name, + available: BUILTIN_PROFILE_NAMES, + message: `Unknown profile '${name}'. Available profiles: ${BUILTIN_PROFILE_NAMES.join(', ')}.`, + }, + }; +} diff --git a/packages/core/src/profiles/schema.test.ts b/packages/core/src/profiles/schema.test.ts new file mode 100644 index 0000000..877037a --- /dev/null +++ b/packages/core/src/profiles/schema.test.ts @@ -0,0 +1,199 @@ +/** + * Unit tests for profile schema v1 validation. + * + * Covers closed-schema rejection (unknown keys at every level, recursively), + * enum and type checks, regex-pattern checks, severity-id resolution, and the + * no-throw contract on arbitrary input. + */ + +import { describe, expect, test } from 'vitest'; +import { validateProfile, type Profile, type ProfileValidationError } from './schema.js'; + +function baseProfile(): Profile { + return { + name: 'strict', + notation: 'ears', + dialect: { + keywordCase: 'strict', + allowLiteralSystemName: [], + commaAfterLeadingClause: 'required', + allowStoryWrapper: false, + allowFrameMetadata: false, + allowProhibition: false, + }, + locator: { + documentKinds: ['ears', 'text'], + include: [{ id: 'strict.every-line', kind: 'every-line' }], + exclude: [], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false }, + }; +} + +function errorAt( + errors: ProfileValidationError[], + path: string, +): ProfileValidationError | undefined { + return errors.find((error) => error.path === path); +} + +// A typed profile sub-object has no index signature, so writing an out-of-schema +// key onto it (to exercise closed-schema rejection) needs a widening view. This +// is a single, deliberate assertion to `Record`, not a data-shape claim. +function mutable(obj: object): Record { + return obj as Record; +} + +describe('validateProfile', () => { + test('accepts a well-formed profile', () => { + const result = validateProfile(baseProfile()); + expect(result.ok).toBe(true); + }); + + test('never throws on non-object input and reports not-object', () => { + for (const bad of [null, undefined, 42, 'x', [] as unknown]) { + const result = validateProfile(bad); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('not-object'); + } + } + }); + + test('rejects an unknown top-level key', () => { + const input = { ...baseProfile(), extra: true }; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'extra')?.code).toBe('unknown-key'); + } + }); + + test('rejects an unknown key inside dialect', () => { + const input = baseProfile(); + mutable(input.dialect).surprise = 1; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'dialect.surprise')?.code).toBe('unknown-key'); + } + }); + + test('rejects an unknown key inside a nested locator rule (recursive)', () => { + const input = baseProfile(); + mutable(input.locator.include[0]).bogus = 'x'; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'locator.include[0].bogus')?.code).toBe('unknown-key'); + } + }); + + test('rejects an unknown key inside idFormat', () => { + const input = baseProfile(); + mutable(input.idFormat).flavor = 'x'; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'idFormat.flavor')?.code).toBe('unknown-key'); + } + }); + + test('rejects a bad name enum value', () => { + const input = { ...baseProfile(), name: 'custom' }; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'name')?.code).toBe('invalid-enum'); + } + }); + + test('rejects a notation other than ears', () => { + const input = { ...baseProfile(), notation: 'gherkin' }; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'notation')?.code).toBe('invalid-enum'); + } + }); + + test('rejects a bad locator-rule kind', () => { + const input = baseProfile(); + mutable(input.locator.include[0]).kind = 'paragraph'; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'locator.include[0].kind')?.code).toBe('invalid-enum'); + } + }); + + test('rejects a severity key that is not a resolvable diagnostic id', () => { + const input = baseProfile(); + input.severity = { 'ears.no_match': 'off' }; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'severity.ears.no_match')?.code).toBe('unknown-diagnostic-id'); + } + }); + + test('accepts a resolvable severity id', () => { + const input = baseProfile(); + input.severity = { 'EARS-W011': 'off' }; + const result = validateProfile(input); + expect(result.ok).toBe(true); + }); + + test('rejects a bad severity value', () => { + const input = baseProfile(); + (input.severity as Record)['EARS-W011'] = 'silence'; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'severity.EARS-W011')?.code).toBe('invalid-enum'); + } + }); + + test('rejects an uncompilable idFormat pattern', () => { + const input = baseProfile(); + input.idFormat = { required: false, pattern: '(' }; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'idFormat.pattern')?.code).toBe('invalid-pattern'); + } + }); + + test('rejects an uncompilable headingPattern', () => { + const input = baseProfile(); + input.locator.include = [{ id: 'r', kind: 'heading-section', headingPattern: '[' }]; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'locator.include[0].headingPattern')?.code).toBe( + 'invalid-pattern', + ); + } + }); + + test('reports missing required keys', () => { + const result = validateProfile({ name: 'strict', notation: 'ears' }); + expect(result.ok).toBe(false); + if (!result.ok) { + const paths = result.errors.map((error) => error.path); + expect(paths).toEqual(expect.arrayContaining(['dialect', 'locator', 'severity', 'idFormat'])); + } + }); + + test('rejects a non-string entry in allowLiteralSystemName', () => { + const input = baseProfile(); + mutable(input.dialect).allowLiteralSystemName = ['THE SYSTEM', 3]; + const result = validateProfile(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(errorAt(result.errors, 'dialect.allowLiteralSystemName')?.code).toBe('wrong-type'); + } + }); +}); diff --git a/packages/core/src/profiles/schema.ts b/packages/core/src/profiles/schema.ts new file mode 100644 index 0000000..0cadd7d --- /dev/null +++ b/packages/core/src/profiles/schema.ts @@ -0,0 +1,569 @@ +/** + * Profile schema v1: types and strict validation for `@earsyntax/core`. + * + * A profile is data, not code (see `docs/contracts/profile.md`). It tells the + * pipeline which host document regions to locate, which EARS dialect to accept, + * and how to classify each diagnostic. Parser and linter logic never branch on + * a profile name; they read these fields. This module owns the frozen shapes + * and the closed-schema validator. + * + * The schema is closed: unknown keys anywhere (top level, `dialect`, `locator`, + * `idFormat`, or any `LocatorRule`) are a validation error, never silently + * ignored. {@link validateProfile} is pure: it returns typed errors and never + * throws on bad caller input. + * + * Determinism note: no clock, file system, or network. The one impurity is + * `new RegExp(...)` used solely to check that pattern fields compile; it is + * wrapped and never leaks an exception. + */ + +import { isKnownDiagnosticId } from './registry-ids.js'; + +/** The five closed profile identities. There are no user-defined names. */ +export type ProfileName = 'strict' | 'ears-x' | 'kiro' | 'speckit' | 'openspec'; + +/** EARS keyword casing tolerance. */ +export type KeywordCase = 'strict' | 'case-insensitive'; + +/** Whether a leading clause must be comma-terminated before the main clause. */ +export type CommaAfterLeadingClause = 'required' | 'optional'; + +/** Whether fenced code blocks are eligible as candidate regions. */ +export type CodeFences = 'ignore' | 'include'; + +/** Effective classification a profile can assign a diagnostic id. */ +export type SeverityLevel = 'error' | 'warning' | 'off'; + +/** + * The region-selection strategy of a {@link LocatorRule}. + * + * These four values are frozen: `extract` output and profile fixtures depend on + * them (see `docs/contracts/profile.md`). + */ +export type LocatorRuleKind = 'every-line' | 'heading-section' | 'list-item' | 'block'; + +/** Which markdown list markers a `list-item` rule accepts. */ +export type ListMarker = 'bullet' | 'ordered' | 'any'; + +/** + * One region-selection rule. The extractor reports the matching rule's `id` for + * each candidate (`extract` returns `locatorRuleId`). + * + * `id` and `kind` are the frozen core. The remaining fields are the minimal set + * the built-in markdown profiles need; each field applies to specific kinds: + * + * - `every-line` (strict, ears-x): uses no extra fields. Every non-empty line + * of a `documentKinds` file is a candidate. + * - `heading-section` (speckit): `headingPattern` selects the section whose body + * lines become candidates (include) or are removed (exclude). + * - `list-item` (kiro): `underHeading` names the ancestor heading a list must + * sit under; `listMarker` restricts which markers qualify. + * - `block` (openspec): `blockPrefix` is the literal heading line that opens a + * candidate block; the block body runs until the next heading of equal or + * higher level. + * + * `headingPattern` and `underHeading` are JavaScript regular-expression source + * strings matched case-insensitively against a heading's trimmed text. + * `blockPrefix` is a literal string matched against a trimmed line, not a regex. + */ +export interface LocatorRule { + /** Stable rule identity, surfaced as `extract`'s `locatorRuleId`. */ + id: string; + /** The region-selection strategy. */ + kind: LocatorRuleKind; + /** `heading-section`: regex selecting the heading whose section this rule targets. */ + headingPattern?: string; + /** `list-item`: regex selecting the ancestor heading a candidate list sits under. */ + underHeading?: string; + /** `list-item`: which list markers qualify. Defaults to `any` when omitted. */ + listMarker?: ListMarker; + /** `block`: literal heading line that opens a candidate block. */ + blockPrefix?: string; + /** Human note documenting intent in the data file. Rendered nowhere. */ + note?: string; +} + +/** Grammar tolerances the parser and linter apply under a profile. */ +export interface ProfileDialect { + /** Casing rule for EARS keywords. */ + keywordCase: KeywordCase; + /** Literal system phrases accepted in place of `the `. Empty allows only the canonical form. */ + allowLiteralSystemName: string[]; + /** Whether a leading clause must be comma-terminated. */ + commaAfterLeadingClause: CommaAfterLeadingClause; + /** Whether user-story frame lines are skipped as non-requirement content. */ + allowStoryWrapper: boolean; + /** Whether `REQ-###` ids and `[source: path:line]` tags are accepted as metadata. */ + allowFrameMetadata: boolean; + /** Whether `shall not` is accepted as a prohibition kind. */ + allowProhibition: boolean; +} + +/** Which regions of which host document kinds become requirement candidates. */ +export interface ProfileLocator { + /** File kinds this profile locates over (for example `['ears','text']`). */ + documentKinds: string[]; + /** Ordered rules selecting candidate regions. */ + include: LocatorRule[]; + /** Ordered rules removing regions from the candidate set. */ + exclude: LocatorRule[]; + /** Whether fenced code blocks are eligible. */ + codeFences: CodeFences; +} + +/** + * Whether requirements must carry an id and its shape. + * + * RESERVED: `validateProfile` accepts and type-checks this field (required + * boolean, pattern compiles as a regex), but no pipeline stage enforces it + * yet. Locating, extracting, parsing, and linting do not read `idFormat`, so + * a requirement missing an id or mismatching `pattern` produces no + * diagnostic today. Enforcement needs a future registry diagnostic code + * (see `docs/contracts/profile.md`, `idFormat` section) before this field + * has any runtime effect. + */ +export interface ProfileIdFormat { + /** When `true`, a requirement without an id is a finding. */ + required: boolean; + /** Optional regex a present id must match (for example `^REQ-\\d+$`). */ + pattern?: string; +} + +/** A validated profile. */ +export interface Profile { + /** The profile identity. */ + name: ProfileName; + /** Always `'ears'`. Reserved against a future notation being silently added. */ + notation: 'ears'; + /** Grammar tolerances. */ + dialect: ProfileDialect; + /** Region selection. */ + locator: ProfileLocator; + /** Partial per-id severity overrides keyed by current registry id. */ + severity: Record; + /** Id presence and shape requirements. */ + idFormat: ProfileIdFormat; +} + +/** The category of a {@link ProfileValidationError}. */ +export type ProfileValidationErrorCode = + | 'not-object' + | 'unknown-key' + | 'missing-key' + | 'wrong-type' + | 'invalid-enum' + | 'unknown-diagnostic-id' + | 'invalid-pattern'; + +/** One typed reason a candidate profile failed validation. */ +export interface ProfileValidationError { + /** Dotted path to the offending value (for example `locator.include[0].kind`). */ + path: string; + /** The failure category. */ + code: ProfileValidationErrorCode; + /** Human-readable explanation. */ + message: string; +} + +/** The outcome of {@link validateProfile}. */ +export type ProfileValidationResult = + { ok: true; profile: Profile } | { ok: false; errors: ProfileValidationError[] }; + +const PROFILE_NAMES: readonly ProfileName[] = ['strict', 'ears-x', 'kiro', 'speckit', 'openspec']; +const KEYWORD_CASES: readonly KeywordCase[] = ['strict', 'case-insensitive']; +const COMMA_MODES: readonly CommaAfterLeadingClause[] = ['required', 'optional']; +const CODE_FENCES: readonly CodeFences[] = ['ignore', 'include']; +const SEVERITY_LEVELS: readonly SeverityLevel[] = ['error', 'warning', 'off']; +const LOCATOR_KINDS: readonly LocatorRuleKind[] = [ + 'every-line', + 'heading-section', + 'list-item', + 'block', +]; +const LIST_MARKERS: readonly ListMarker[] = ['bullet', 'ordered', 'any']; + +const TOP_KEYS = ['name', 'notation', 'dialect', 'locator', 'severity', 'idFormat'] as const; +const DIALECT_KEYS = [ + 'keywordCase', + 'allowLiteralSystemName', + 'commaAfterLeadingClause', + 'allowStoryWrapper', + 'allowFrameMetadata', + 'allowProhibition', +] as const; +const LOCATOR_KEYS = ['documentKinds', 'include', 'exclude', 'codeFences'] as const; +const ID_FORMAT_KEYS = ['required', 'pattern'] as const; +const LOCATOR_RULE_KEYS = [ + 'id', + 'kind', + 'headingPattern', + 'underHeading', + 'listMarker', + 'blockPrefix', + 'note', +] as const; + +/** A mutable error sink threaded through the validators. */ +type Errors = ProfileValidationError[]; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function reportUnknownKeys( + value: Record, + known: readonly string[], + path: string, + errors: Errors, +): void { + for (const key of Object.keys(value)) { + if (!known.includes(key)) { + errors.push({ + path: path ? `${path}.${key}` : key, + code: 'unknown-key', + message: `Unknown key '${key}' is not permitted by the closed profile schema.`, + }); + } + } +} + +function requireEnum( + value: unknown, + allowed: readonly T[], + path: string, + errors: Errors, +): value is T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + errors.push({ + path, + code: 'invalid-enum', + message: `Expected one of ${allowed.map((v) => `'${v}'`).join(', ')}.`, + }); + return false; + } + return true; +} + +function requireBoolean(value: unknown, path: string, errors: Errors): value is boolean { + if (typeof value !== 'boolean') { + errors.push({ path, code: 'wrong-type', message: 'Expected a boolean.' }); + return false; + } + return true; +} + +function requireStringArray(value: unknown, path: string, errors: Errors): value is string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + errors.push({ path, code: 'wrong-type', message: 'Expected an array of strings.' }); + return false; + } + return true; +} + +function requireRegexSource(value: unknown, path: string, errors: Errors): boolean { + if (typeof value !== 'string') { + errors.push({ path, code: 'wrong-type', message: 'Expected a regular-expression string.' }); + return false; + } + try { + RegExp(value); + return true; + } catch { + errors.push({ + path, + code: 'invalid-pattern', + message: `Not a valid regular expression: '${value}'.`, + }); + return false; + } +} + +function validateLocatorRule(value: unknown, path: string, errors: Errors): void { + if (!isPlainObject(value)) { + errors.push({ path, code: 'not-object', message: 'Expected a locator-rule object.' }); + return; + } + reportUnknownKeys(value, LOCATOR_RULE_KEYS, path, errors); + + if (!('id' in value)) { + errors.push({ path: `${path}.id`, code: 'missing-key', message: "Missing required key 'id'." }); + } else if (typeof value.id !== 'string') { + errors.push({ path: `${path}.id`, code: 'wrong-type', message: 'Expected a string.' }); + } + + if (!('kind' in value)) { + errors.push({ + path: `${path}.kind`, + code: 'missing-key', + message: "Missing required key 'kind'.", + }); + } else { + requireEnum(value.kind, LOCATOR_KINDS, `${path}.kind`, errors); + } + + if ('headingPattern' in value) { + requireRegexSource(value.headingPattern, `${path}.headingPattern`, errors); + } + if ('underHeading' in value) { + requireRegexSource(value.underHeading, `${path}.underHeading`, errors); + } + if ('listMarker' in value) { + requireEnum(value.listMarker, LIST_MARKERS, `${path}.listMarker`, errors); + } + if ('blockPrefix' in value && typeof value.blockPrefix !== 'string') { + errors.push({ path: `${path}.blockPrefix`, code: 'wrong-type', message: 'Expected a string.' }); + } + if ('note' in value && typeof value.note !== 'string') { + errors.push({ path: `${path}.note`, code: 'wrong-type', message: 'Expected a string.' }); + } +} + +function validateLocatorRuleList(value: unknown, path: string, errors: Errors): void { + if (!Array.isArray(value)) { + errors.push({ path, code: 'wrong-type', message: 'Expected an array of locator rules.' }); + return; + } + value.forEach((rule, index) => { + validateLocatorRule(rule, `${path}[${index}]`, errors); + }); +} + +function validateDialect(value: unknown, errors: Errors): void { + const path = 'dialect'; + if (!isPlainObject(value)) { + errors.push({ path, code: 'not-object', message: 'Expected a dialect object.' }); + return; + } + reportUnknownKeys(value, DIALECT_KEYS, path, errors); + + if ('keywordCase' in value) { + requireEnum(value.keywordCase, KEYWORD_CASES, `${path}.keywordCase`, errors); + } else { + errors.push({ + path: `${path}.keywordCase`, + code: 'missing-key', + message: "Missing required key 'keywordCase'.", + }); + } + + if ('allowLiteralSystemName' in value) { + requireStringArray(value.allowLiteralSystemName, `${path}.allowLiteralSystemName`, errors); + } else { + errors.push({ + path: `${path}.allowLiteralSystemName`, + code: 'missing-key', + message: "Missing required key 'allowLiteralSystemName'.", + }); + } + + if ('commaAfterLeadingClause' in value) { + requireEnum( + value.commaAfterLeadingClause, + COMMA_MODES, + `${path}.commaAfterLeadingClause`, + errors, + ); + } else { + errors.push({ + path: `${path}.commaAfterLeadingClause`, + code: 'missing-key', + message: "Missing required key 'commaAfterLeadingClause'.", + }); + } + + for (const key of ['allowStoryWrapper', 'allowFrameMetadata', 'allowProhibition'] as const) { + if (key in value) { + requireBoolean(value[key], `${path}.${key}`, errors); + } else { + errors.push({ + path: `${path}.${key}`, + code: 'missing-key', + message: `Missing required key '${key}'.`, + }); + } + } +} + +function validateLocator(value: unknown, errors: Errors): void { + const path = 'locator'; + if (!isPlainObject(value)) { + errors.push({ path, code: 'not-object', message: 'Expected a locator object.' }); + return; + } + reportUnknownKeys(value, LOCATOR_KEYS, path, errors); + + if ('documentKinds' in value) { + requireStringArray(value.documentKinds, `${path}.documentKinds`, errors); + } else { + errors.push({ + path: `${path}.documentKinds`, + code: 'missing-key', + message: "Missing required key 'documentKinds'.", + }); + } + + if ('include' in value) { + validateLocatorRuleList(value.include, `${path}.include`, errors); + } else { + errors.push({ + path: `${path}.include`, + code: 'missing-key', + message: "Missing required key 'include'.", + }); + } + + if ('exclude' in value) { + validateLocatorRuleList(value.exclude, `${path}.exclude`, errors); + } else { + errors.push({ + path: `${path}.exclude`, + code: 'missing-key', + message: "Missing required key 'exclude'.", + }); + } + + if ('codeFences' in value) { + requireEnum(value.codeFences, CODE_FENCES, `${path}.codeFences`, errors); + } else { + errors.push({ + path: `${path}.codeFences`, + code: 'missing-key', + message: "Missing required key 'codeFences'.", + }); + } +} + +function validateSeverity(value: unknown, errors: Errors): void { + const path = 'severity'; + if (!isPlainObject(value)) { + errors.push({ path, code: 'not-object', message: 'Expected a severity map.' }); + return; + } + for (const [key, level] of Object.entries(value)) { + if (!isKnownDiagnosticId(key)) { + errors.push({ + path: `${path}.${key}`, + code: 'unknown-diagnostic-id', + message: `'${key}' is not a resolvable current diagnostic id (EARS-E### / EARS-W###).`, + }); + } + requireEnum(level, SEVERITY_LEVELS, `${path}.${key}`, errors); + } +} + +function validateIdFormat(value: unknown, errors: Errors): void { + const path = 'idFormat'; + if (!isPlainObject(value)) { + errors.push({ path, code: 'not-object', message: 'Expected an idFormat object.' }); + return; + } + reportUnknownKeys(value, ID_FORMAT_KEYS, path, errors); + + if ('required' in value) { + requireBoolean(value.required, `${path}.required`, errors); + } else { + errors.push({ + path: `${path}.required`, + code: 'missing-key', + message: "Missing required key 'required'.", + }); + } + + if ('pattern' in value) { + requireRegexSource(value.pattern, `${path}.pattern`, errors); + } +} + +/** + * Validate an untrusted value against profile schema v1. + * + * Collects every error rather than stopping at the first, so a fixture can + * assert the full set. Never throws: malformed input yields `{ ok: false }` + * with typed errors, which a caller maps to exit `2` (see the facade contract). + * + * @param input The candidate profile (any shape). + * @returns A typed result carrying either the validated profile or all errors. + */ +export function validateProfile(input: unknown): ProfileValidationResult { + const errors: Errors = []; + + if (!isPlainObject(input)) { + return { + ok: false, + errors: [{ path: '', code: 'not-object', message: 'Expected a profile object.' }], + }; + } + + reportUnknownKeys(input, TOP_KEYS, '', errors); + + if ('name' in input) { + requireEnum(input.name, PROFILE_NAMES, 'name', errors); + } else { + errors.push({ path: 'name', code: 'missing-key', message: "Missing required key 'name'." }); + } + + if ('notation' in input) { + requireEnum(input.notation, ['ears'] as const, 'notation', errors); + } else { + errors.push({ + path: 'notation', + code: 'missing-key', + message: "Missing required key 'notation'.", + }); + } + + if ('dialect' in input) { + validateDialect(input.dialect, errors); + } else { + errors.push({ + path: 'dialect', + code: 'missing-key', + message: "Missing required key 'dialect'.", + }); + } + + if ('locator' in input) { + validateLocator(input.locator, errors); + } else { + errors.push({ + path: 'locator', + code: 'missing-key', + message: "Missing required key 'locator'.", + }); + } + + if ('severity' in input) { + validateSeverity(input.severity, errors); + } else { + errors.push({ + path: 'severity', + code: 'missing-key', + message: "Missing required key 'severity'.", + }); + } + + if ('idFormat' in input) { + validateIdFormat(input.idFormat, errors); + } else { + errors.push({ + path: 'idFormat', + code: 'missing-key', + message: "Missing required key 'idFormat'.", + }); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + // Every field validated above; assemble the typed profile from the checked + // members (single assertions from `unknown`, no forced double cast). + const profile: Profile = { + name: input.name as ProfileName, + notation: 'ears', + dialect: input.dialect as ProfileDialect, + locator: input.locator as ProfileLocator, + severity: input.severity as Record, + idFormat: input.idFormat as ProfileIdFormat, + }; + return { ok: true, profile }; +} diff --git a/packages/core/src/registry.test.ts b/packages/core/src/registry.test.ts new file mode 100644 index 0000000..ff9e14e --- /dev/null +++ b/packages/core/src/registry.test.ts @@ -0,0 +1,206 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + DIAGNOSTIC_REGISTRY, + getDiagnosticEntry, + idForCode, + resolveDiagnosticId, + type DiagnosticRegistryEntry, +} from './registry.js'; +import type { DiagnosticCode } from './types.js'; + +/** + * The expected migration table, duplicated here as inline test data so drift in + * the registry fails the test. It mirrors the table in + * `docs/refactor/host-native-facade.md` and the fixture + * `fixtures/diagnostics/migration-table.json`, which is also cross-checked + * below. Kept in the frozen alphabetical-by-old-code order within each band. + */ +const EXPECTED: readonly { id: string; oldCode: DiagnosticCode }[] = [ + { id: 'EARS-E001', oldCode: 'catalog.system_ambiguous' }, + { id: 'EARS-E002', oldCode: 'catalog.system_unresolved' }, + { id: 'EARS-E003', oldCode: 'ears.empty_clause' }, + { id: 'EARS-E004', oldCode: 'ears.empty_response' }, + { id: 'EARS-E005', oldCode: 'ears.invalid_clause_order' }, + { id: 'EARS-E006', oldCode: 'ears.invalid_if_then_form' }, + { id: 'EARS-E007', oldCode: 'ears.missing_shall' }, + { id: 'EARS-E008', oldCode: 'ears.missing_system' }, + { id: 'EARS-E009', oldCode: 'ears.multiple_shall' }, + { id: 'EARS-E010', oldCode: 'ears.no_match' }, + { id: 'EARS-E011', oldCode: 'expr.empty_subexpression' }, + { id: 'EARS-E012', oldCode: 'expr.invalid_operator_sequence' }, + { id: 'EARS-E013', oldCode: 'expr.unbalanced_parentheses' }, + { id: 'EARS-E014', oldCode: 'ears.keyword_case' }, + { id: 'EARS-E015', oldCode: 'ears.missing_leading_comma' }, + { id: 'EARS-E016', oldCode: 'ears.prohibition_not_allowed' }, + { id: 'EARS-W001', oldCode: 'catalog.event_ambiguous' }, + { id: 'EARS-W002', oldCode: 'catalog.event_unresolved' }, + { id: 'EARS-W003', oldCode: 'catalog.feature_ambiguous' }, + { id: 'EARS-W004', oldCode: 'catalog.feature_unresolved' }, + { id: 'EARS-W005', oldCode: 'catalog.state_ambiguous' }, + { id: 'EARS-W006', oldCode: 'catalog.state_unresolved' }, + { id: 'EARS-W007', oldCode: 'catalog.term_unreferenced' }, + { id: 'EARS-W008', oldCode: 'expr.ambiguous_term' }, + { id: 'EARS-W009', oldCode: 'expr.mixed_unresolved_terms' }, + { id: 'EARS-W010', oldCode: 'expr.operator_precedence_warning' }, + { id: 'EARS-W011', oldCode: 'expr.unknown_term' }, + { id: 'EARS-W012', oldCode: 'lint.alias_used' }, + { id: 'EARS-W013', oldCode: 'lint.multiple_responses' }, + { id: 'EARS-W014', oldCode: 'lint.suspicious_text_shape' }, + { id: 'EARS-W015', oldCode: 'lint.unparsed_tail' }, + { id: 'EARS-W016', oldCode: 'lint.vague_response' }, +]; + +/** Every raw code in the frozen union, so we can prove the registry is total. */ +const ALL_CODES: readonly DiagnosticCode[] = EXPECTED.map((row) => row.oldCode); + +interface MigrationTableFixture { + rows: { id: string; oldCode: string; defaultSeverity: 'error' | 'warning' }[]; +} + +function loadMigrationFixture(): MigrationTableFixture { + const fixturesRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../fixtures'); + const path = resolve(fixturesRoot, 'diagnostics/migration-table.json'); + return JSON.parse(readFileSync(path, 'utf8')) as MigrationTableFixture; +} + +describe('diagnostic registry', () => { + it('has 32 entries, one per old code', () => { + expect(DIAGNOSTIC_REGISTRY).toHaveLength(32); + expect(EXPECTED).toHaveLength(32); + expect(ALL_CODES).toHaveLength(32); + }); + + it('assigns unique ids', () => { + const ids = DIAGNOSTIC_REGISTRY.map((entry) => entry.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('assigns unique old-code aliases', () => { + const codes = DIAGNOSTIC_REGISTRY.map((entry) => entry.oldCode); + expect(new Set(codes).size).toBe(codes.length); + }); + + it('matches the frozen migration table exactly (drift guard)', () => { + const actual = DIAGNOSTIC_REGISTRY.map((entry) => ({ id: entry.id, oldCode: entry.oldCode })); + expect(actual).toEqual(EXPECTED); + }); + + it('agrees with the committed migration-table.json fixture', () => { + const fixture = loadMigrationFixture(); + const actual = DIAGNOSTIC_REGISTRY.map((entry) => ({ + id: entry.id, + oldCode: entry.oldCode, + defaultSeverity: entry.defaultSeverity, + })); + expect(actual).toEqual(fixture.rows); + }); + + it('maps every one of the 32 old codes to a current id', () => { + for (const code of ALL_CODES) { + const id = resolveDiagnosticId(code); + expect(id, `no id for old code ${code}`).toBeDefined(); + expect(id).toMatch(/^EARS-[EW]\d{3}$/); + } + }); + + it("aligns each id's E/W band with its default severity", () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const band = entry.id.startsWith('EARS-E') ? 'error' : 'warning'; + expect(entry.defaultSeverity, `${entry.id} band vs defaultSeverity`).toBe(band); + expect(entry.id).toMatch(/^EARS-[EW]\d{3}$/); + } + }); + + it('carries complete human-facing metadata on every entry', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + for (const field of [ + 'title', + 'meaning', + 'rationale', + 'badExample', + 'goodExample', + 'profileNotes', + ] as const) { + expect(entry[field].length, `${entry.id}.${field} is empty`).toBeGreaterThan(0); + } + } + }); + + it('never emits an em dash in metadata', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + for (const value of Object.values(entry)) { + expect(value).not.toContain('—'); + } + } + }); +}); + +describe('resolveDiagnosticId', () => { + it('resolves a deprecated alias to its current id', () => { + expect(resolveDiagnosticId('ears.missing_shall')).toBe('EARS-E007'); + expect(resolveDiagnosticId('lint.vague_response')).toBe('EARS-W016'); + }); + + it('resolves a current id to itself', () => { + expect(resolveDiagnosticId('EARS-E001')).toBe('EARS-E001'); + expect(resolveDiagnosticId('EARS-W011')).toBe('EARS-W011'); + }); + + it('returns undefined for an unknown id or code', () => { + expect(resolveDiagnosticId('EARS-E999')).toBeUndefined(); + expect(resolveDiagnosticId('ears.not_a_code')).toBeUndefined(); + expect(resolveDiagnosticId('')).toBeUndefined(); + }); +}); + +describe('getDiagnosticEntry', () => { + it('fetches an entry by current id', () => { + const entry = getDiagnosticEntry('EARS-E006'); + expect(entry?.oldCode).toBe('ears.invalid_if_then_form'); + expect(entry?.title).toBe('Malformed If/then unwanted-behaviour form'); + }); + + it('fetches an entry by deprecated alias', () => { + const entry = getDiagnosticEntry('ears.invalid_if_then_form'); + expect(entry?.id).toBe('EARS-E006'); + }); + + it('returns undefined for an unknown id', () => { + expect(getDiagnosticEntry('nope')).toBeUndefined(); + }); +}); + +describe('idForCode', () => { + it('maps a raw code to its current id', () => { + expect(idForCode('catalog.system_ambiguous')).toBe('EARS-E001'); + expect(idForCode('lint.alias_used')).toBe('EARS-W012'); + }); +}); + +describe('registry immutability', () => { + it('deeply freezes the registry array and every entry', () => { + expect(Object.isFrozen(DIAGNOSTIC_REGISTRY)).toBe(true); + for (const entry of DIAGNOSTIC_REGISTRY) { + expect(Object.isFrozen(entry)).toBe(true); + } + }); + + it('rejects mutation of an entry at runtime', () => { + const entry = DIAGNOSTIC_REGISTRY[0]; + expect(() => { + (entry as { title: string }).title = 'mutated'; + }).toThrow(TypeError); + }); + + it('rejects mutation of the array at runtime', () => { + const array = DIAGNOSTIC_REGISTRY as DiagnosticRegistryEntry[]; + expect(() => { + array.push(array[0]); + }).toThrow(TypeError); + }); +}); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts new file mode 100644 index 0000000..7ad9e3e --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,564 @@ +/** + * The append-only diagnostic registry for the host-native `earsyntax` facade. + * + * Every finding `@earsyntax/core` reports carries a raw {@link DiagnosticCode} + * (a dotted string such as `ears.missing_shall`). The facade surfaces findings + * under stable public ids instead: `EARS-E###` for defaults in the error band, + * `EARS-W###` for defaults in the warning band. This module is the single + * source of truth mapping each id to its deprecated old code, title, default + * severity, meaning, rationale, examples, and profile notes. It is the data the + * `explain` command renders and the metadata the SARIF rule list reads. + * + * APPEND-ONLY CONTRACT. The id assignment is frozen and grows in one direction + * only: + * - Never renumber an existing id. + * - Never reuse a retired number. + * - Never delete an id. + * - A new diagnostic takes the next free number in its severity band. + * Old dotted codes remain resolvable forever as deprecated aliases through + * {@link resolveDiagnosticId}; they are never removed. The `E`/`W` band is the + * DEFAULT severity only. A profile severity override or `--strict` can change + * the effective severity a diagnostic carries in a findings result without + * changing its id (see `docs/contracts/findings.md`). + * + * The exported registry data is deeply frozen: consumers read it, they never + * mutate it. Determinism: nothing here reads the clock, the file system, the + * network, or a random source. + */ + +import type { DiagnosticCode } from './types.js'; + +/** The default severity band an id belongs to. Effective severity may differ. */ +export type RegistrySeverity = 'error' | 'warning'; + +/** + * One entry in the diagnostic registry: the stable public id, its deprecated + * old-code alias, and the human-facing metadata the `explain` command renders. + */ +export interface DiagnosticRegistryEntry { + /** Stable public id: `EARS-E###` (error band) or `EARS-W###` (warning band). */ + id: string; + /** The deprecated old code this id replaces; resolvable forever as an alias. */ + oldCode: DiagnosticCode; + /** Short human-readable title of the diagnostic. */ + title: string; + /** Default severity, implied by the id band. Not the effective severity. */ + defaultSeverity: RegistrySeverity; + /** One factual sentence describing what the diagnostic means. */ + meaning: string; + /** Why the rule exists, in one or two sentences. */ + rationale: string; + /** A short requirement that triggers the diagnostic. */ + badExample: string; + /** The corrected form of {@link badExample}. */ + goodExample: string; + /** How profiles and `--strict` affect this diagnostic's effective severity. */ + profileNotes: string; +} + +/** Profile-note phrasing shared by every error-band entry. */ +const ERROR_PROFILE_NOTE = + 'Error by default under every built-in profile. --strict has no further effect on an error; only an explicit profile severity override can change its effective severity.'; + +/** Profile-note phrasing shared by most warning-band entries. */ +const WARNING_PROFILE_NOTE = + 'Warning by default. --strict upgrades it to error at the findings layer; a profile severity override can set it to error or off.'; + +/** + * Profile note for a coverage diagnostic. Coverage codes are produced by + * `lintCatalogCoverage` over a set of requirements and their catalog, not by + * linting a single requirement, so the bad example is read as one requirement in + * a coverage scan whose catalog still holds a term no requirement references. + */ +const COVERAGE_PROFILE_NOTE = + 'Warning by default, emitted by the catalog-coverage pass (`lintCatalogCoverage`), not by linting a single requirement. It fires when a cataloged term is referenced by no requirement text; the bad example is one such requirement whose catalog still holds an unreferenced term. --strict upgrades it to error at the findings layer.'; + +/** + * Profile note for a diagnostic that only the legacy guided mode produces. + * Guided mode is retained on the core API for compatibility but is never + * selected by the host-native CLI, so this code does not surface through any + * profile; the strict default reports EARS-E010 for the same text. + */ +const GUIDED_ONLY_PROFILE_NOTE = + 'Emitted only under the legacy guided mode (`lintEars` with `mode: "guided"`), retained for core API compatibility. The host-native CLI never selects guided mode, so this code does not surface through any built-in profile; under the strict default the same text reports EARS-E010 instead.'; + +/** + * The registry, in ascending id order within each band (errors, then + * warnings). Ids are assigned per the frozen migration table in + * `docs/refactor/host-native-facade.md`: alphabetically by old code within each + * severity, errors `E001+`, warnings `W001+`. + */ +const ENTRIES: DiagnosticRegistryEntry[] = [ + // Errors: EARS-E001 - EARS-E016. + { + id: 'EARS-E001', + oldCode: 'catalog.system_ambiguous', + title: 'Ambiguous system term', + defaultSeverity: 'error', + meaning: 'The system name matches more than one known system.', + rationale: + 'A requirement constrains exactly one system. A system name that resolves to several catalog entries leaves the constrained system undetermined.', + badExample: 'The controller shall stop the motor.', + goodExample: 'The brake controller shall stop the motor.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E002', + oldCode: 'catalog.system_unresolved', + title: 'Unresolved system term', + defaultSeverity: 'error', + meaning: 'The system name matches no known system.', + rationale: + 'When a catalog is supplied, the system a requirement constrains must be one of its known systems, so an unknown system usually signals a typo or a missing catalog entry.', + badExample: 'The invoicing engine shall retry the charge.', + goodExample: 'The billing service shall retry the charge.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E003', + oldCode: 'ears.empty_clause', + title: 'Empty clause body', + defaultSeverity: 'error', + meaning: 'A While, Where, When, or If clause body is empty.', + rationale: + 'A clause keyword with no body states no condition, so the requirement it introduces has no meaning.', + badExample: 'When , the system shall reset the timer.', + goodExample: 'When the timer fires, the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E004', + oldCode: 'ears.empty_response', + title: 'Empty response', + defaultSeverity: 'error', + meaning: 'The response after shall is empty.', + rationale: + 'The response is the behaviour the requirement mandates. An empty response mandates nothing.', + badExample: 'The system shall .', + goodExample: 'The system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E005', + oldCode: 'ears.invalid_clause_order', + title: 'Invalid clause order', + defaultSeverity: 'error', + meaning: 'Shell clauses appear in an unsupported order.', + rationale: + 'Canonical EARS orders leading clauses as While, then Where, then When, then If. Out-of-order clauses do not match a supported shell pattern.', + badExample: 'When the timer fires, while idle, the system shall reset the timer.', + goodExample: 'While idle, when the timer fires, the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E006', + oldCode: 'ears.invalid_if_then_form', + title: 'Malformed If/then unwanted-behaviour form', + defaultSeverity: 'error', + meaning: 'An If clause is missing its required then boundary.', + rationale: + "Canonical EARS requires 'If , then the shall .' Without the then boundary the unwanted-behaviour form is incomplete.", + badExample: 'If the signature is invalid, the system shall reject the webhook.', + goodExample: 'If the signature is invalid, then the system shall reject the webhook.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E007', + oldCode: 'ears.missing_shall', + title: 'Missing shall boundary', + defaultSeverity: 'error', + meaning: 'The requirement does not contain exactly one shall response boundary.', + rationale: + 'The shall keyword separates the system from the response it must perform. Without it the sentence is a statement, not a requirement.', + badExample: 'The system resets the timer.', + goodExample: 'The system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E008', + oldCode: 'ears.missing_system', + title: 'Missing system name', + defaultSeverity: 'error', + meaning: 'The requirement is missing the system name before shall.', + rationale: + 'Every requirement names the system it constrains. An empty system leaves the responsible component unstated.', + badExample: 'When the timer fires, shall reset the timer.', + goodExample: 'When the timer fires, the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E009', + oldCode: 'ears.multiple_shall', + title: 'Multiple shall boundaries', + defaultSeverity: 'error', + meaning: 'The requirement contains more than one shell-level shall.', + rationale: + 'A single requirement states a single obligation. More than one shall packs several requirements into one sentence, which cannot be traced or tested independently.', + badExample: 'The system shall reset the timer and shall log the event.', + goodExample: 'The system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E010', + oldCode: 'ears.no_match', + title: 'Unrecognized EARS shell', + defaultSeverity: 'error', + meaning: 'The text does not match any supported EARS shell pattern.', + rationale: + 'The text has no recognizable EARS shape at all, so no more precise structural cause can be reported.', + badExample: 'quick brown fox', + goodExample: 'The system shall log the event.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E011', + oldCode: 'expr.empty_subexpression', + title: 'Empty subexpression', + defaultSeverity: 'error', + meaning: 'A clause expression contains an empty subexpression.', + rationale: + 'A grouped or operator operand with no content, such as an empty pair of parentheses, contributes no condition and is almost always a mistake.', + badExample: 'While A and (), the system shall reset the timer.', + goodExample: 'While A and B, the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E012', + oldCode: 'expr.invalid_operator_sequence', + title: 'Invalid operator sequence', + defaultSeverity: 'error', + meaning: 'A clause expression contains a malformed operator sequence.', + rationale: + 'Operators such as and, or, and not must join operands. A doubled or dangling operator has no operand to act on.', + badExample: 'While A or or B, the system shall reset the timer.', + goodExample: 'While A or B, the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E013', + oldCode: 'expr.unbalanced_parentheses', + title: 'Unbalanced parentheses', + defaultSeverity: 'error', + meaning: 'A clause expression has unbalanced parentheses.', + rationale: + 'Every opening parenthesis needs a matching close. An unbalanced group cannot be parsed into a determinate expression tree.', + badExample: 'When (A and B, the system shall reset the timer.', + goodExample: 'When (A and B), the system shall reset the timer.', + profileNotes: ERROR_PROFILE_NOTE, + }, + { + id: 'EARS-E014', + oldCode: 'ears.keyword_case', + title: 'Non-canonical keyword casing', + defaultSeverity: 'error', + meaning: 'A keyword violates strict canonical casing.', + rationale: + 'Strict core follows the Mavin templates: keyword-initial capitalization for While, Where, When, and If, and a lowercase shall. Off-canonical casing signals text that was not written to the strict dialect.', + badExample: 'when the timer fires, the system Shall reset the timer.', + goodExample: 'When the timer fires, the system shall reset the timer.', + profileNotes: + 'Error by default. The kiro profile sets keywordCase to case-insensitive, which suppresses this diagnostic at parse time; strict, ears-x, speckit, and openspec keep it an error. --strict has no further effect on an error.', + }, + { + id: 'EARS-E015', + oldCode: 'ears.missing_leading_comma', + title: 'Missing leading-clause comma', + defaultSeverity: 'error', + meaning: + 'A leading While, Where, When, or If clause is not comma-delimited where the dialect requires it.', + rationale: + 'A required comma marks the boundary between a leading clause and the main clause. Without it the two run together, so the clause boundary is not delimited as the dialect requires.', + badExample: 'When the timer fires the system shall reset the timer.', + goodExample: 'When the timer fires, the system shall reset the timer.', + profileNotes: + 'Error by default. The kiro profile sets commaAfterLeadingClause to optional, which suppresses this diagnostic at parse time; strict, ears-x, speckit, and openspec keep it an error. --strict has no further effect on an error.', + }, + { + id: 'EARS-E016', + oldCode: 'ears.prohibition_not_allowed', + title: 'Prohibition not allowed', + defaultSeverity: 'error', + meaning: 'A shall not prohibition is used where the dialect forbids it.', + rationale: + 'Canonical Mavin EARS has no prohibition template because a negative requirement is not conventionally verifiable: a tester cannot confirm that a system never does something across all inputs and all time. Dialects that forbid prohibition reject shall not for that reason.', + badExample: 'The system shall not log the payment token.', + goodExample: 'The system shall redact the payment token before logging.', + profileNotes: + 'Error by default. The ears-x profile sets allowProhibition to true, which legalizes shall not so this diagnostic is never raised; strict, kiro, speckit, and openspec keep it an error. --strict has no further effect on an error.', + }, + + // Warnings: EARS-W001 - EARS-W016. + { + id: 'EARS-W001', + oldCode: 'catalog.event_ambiguous', + title: 'Ambiguous event term', + defaultSeverity: 'warning', + meaning: 'An event term matches more than one known event.', + rationale: + 'An event that resolves to several catalog entries leaves the trigger undetermined, though a non-system term never blocks validity on its own.', + badExample: 'When the request arrives, the system shall respond.', + goodExample: 'When the payment webhook arrives, the system shall respond.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W002', + oldCode: 'catalog.event_unresolved', + title: 'Unresolved event term', + defaultSeverity: 'warning', + meaning: 'An event term matches no known event.', + rationale: + 'An event absent from the catalog is often a typo or a term the catalog has yet to define.', + badExample: 'When a refund is requested, the system shall notify the operator.', + goodExample: 'When a refund event occurs, the system shall notify the operator.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W003', + oldCode: 'catalog.feature_ambiguous', + title: 'Ambiguous feature term', + defaultSeverity: 'warning', + meaning: 'A feature term matches more than one known feature.', + rationale: + 'A feature name shared by several catalog entries leaves the optional feature undetermined.', + badExample: 'Where retries are enabled, the system shall back off.', + goodExample: 'Where automatic retries are enabled, the system shall back off.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W004', + oldCode: 'catalog.feature_unresolved', + title: 'Unresolved feature term', + defaultSeverity: 'warning', + meaning: 'A feature term matches no known feature.', + rationale: + 'A feature absent from the catalog is often a typo or a term the catalog has yet to define.', + badExample: 'Where premium mode is enabled, the system shall unlock the report.', + goodExample: 'Where the premium tier is enabled, the system shall unlock the report.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W005', + oldCode: 'catalog.state_ambiguous', + title: 'Ambiguous state term', + defaultSeverity: 'warning', + meaning: 'A state term matches more than one known state.', + rationale: + 'A state name shared by several catalog entries leaves the precondition undetermined.', + badExample: 'While draining, the system shall reject new work.', + goodExample: 'While the queue is draining, the system shall reject new work.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W006', + oldCode: 'catalog.state_unresolved', + title: 'Unresolved state term', + defaultSeverity: 'warning', + meaning: 'A state term matches no known state.', + rationale: + 'A state absent from the catalog is often a typo or a term the catalog has yet to define.', + badExample: 'While the queue is draining, the system shall reject new work.', + goodExample: 'While the queue is full, the system shall reject new work.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W007', + oldCode: 'catalog.term_unreferenced', + title: 'Unreferenced catalog term', + defaultSeverity: 'warning', + meaning: 'A cataloged term is never referenced by any requirement text.', + rationale: + 'A catalog entry that no requirement mentions is either dead vocabulary or a sign that a requirement is missing.', + badExample: 'The billing service shall retain the audit log.', + goodExample: + 'When a payment webhook is received, the billing service shall retain the audit log.', + profileNotes: COVERAGE_PROFILE_NOTE, + }, + { + id: 'EARS-W008', + oldCode: 'expr.ambiguous_term', + title: 'Ambiguous clause term', + defaultSeverity: 'warning', + meaning: 'A clause term matches more than one catalog entry.', + rationale: + 'A clause term whose name collides across catalog groups is undetermined; the accompanying role-specific catalog diagnostic names the collision.', + badExample: 'When the reset is triggered, the system shall clear the buffer.', + goodExample: 'When the watchdog reset is triggered, the system shall clear the buffer.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W009', + oldCode: 'expr.mixed_unresolved_terms', + title: 'Mixed resolved and unresolved terms', + defaultSeverity: 'warning', + meaning: 'One clause mixes resolved and unresolved terms.', + rationale: + 'A clause where some terms resolve and others do not often hides a typo in the unresolved operand, since the author clearly intended cataloged terms.', + badExample: 'While A and B, the system shall reset the timer.', + goodExample: 'While A and C, the system shall reset the timer.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W010', + oldCode: 'expr.operator_precedence_warning', + title: 'Implicit operator precedence', + defaultSeverity: 'warning', + meaning: 'A mixed and/or expression may need parentheses.', + rationale: + 'Mixing and with or without grouping relies on implicit precedence that a reader can misjudge. Explicit parentheses remove the ambiguity.', + badExample: 'While A and B or C, the system shall reset the timer.', + goodExample: 'While (A and B) or C, the system shall reset the timer.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W011', + oldCode: 'expr.unknown_term', + title: 'Unknown clause term', + defaultSeverity: 'warning', + meaning: 'A clause term matches no catalog entry for its role.', + rationale: + 'A clause term absent from the catalog is often a typo or a term the catalog has yet to define.', + badExample: 'When the doorbell rings, the system shall log the event.', + goodExample: 'When the entry sensor triggers, the system shall log the event.', + profileNotes: + 'Warning by default. --strict upgrades it to error at the findings layer; a profile severity override can set it to error or off. The kiro profile sets EARS-W011 to off.', + }, + { + id: 'EARS-W012', + oldCode: 'lint.alias_used', + title: 'Catalog alias used', + defaultSeverity: 'warning', + meaning: 'A catalog alias matched; the canonical name is preferred.', + rationale: + 'An alias resolves correctly but hides the canonical name, so different requirements can refer to the same concept by different spellings.', + badExample: 'When db is unavailable, the system shall queue the write.', + goodExample: 'When Postgres is unavailable, the system shall queue the write.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W013', + oldCode: 'lint.multiple_responses', + title: 'Multiple responses', + defaultSeverity: 'warning', + meaning: 'The response holds several semicolon-joined responses.', + rationale: + 'Several responses in one requirement cannot be traced or tested independently; splitting them keeps each obligation atomic.', + badExample: 'The system shall log the event; notify the operator.', + goodExample: 'The system shall log the event.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W014', + oldCode: 'lint.suspicious_text_shape', + title: 'Suspicious text shape', + defaultSeverity: 'warning', + meaning: 'The sentence shape is likely accidental or malformed (legacy guided mode only).', + rationale: + 'Text that resembles no EARS shell but was submitted as a requirement is flagged so it is not silently ignored in guided processing.', + badExample: 'timer reset maybe when idle', + goodExample: 'While idle, the system shall reset the timer.', + profileNotes: GUIDED_ONLY_PROFILE_NOTE, + }, + { + id: 'EARS-W015', + oldCode: 'lint.unparsed_tail', + title: 'Unparsed trailing text', + defaultSeverity: 'warning', + meaning: 'Text remains after the parsed requirement.', + rationale: + 'Tokens the parser could not consume usually mean the sentence ran two requirements together or trailed off into prose.', + badExample: 'The system shall reset the timer. Also it logs the event.', + goodExample: 'The system shall reset the timer.', + profileNotes: WARNING_PROFILE_NOTE, + }, + { + id: 'EARS-W016', + oldCode: 'lint.vague_response', + title: 'Vague response', + defaultSeverity: 'warning', + meaning: 'The response contains a configured vague term.', + rationale: + 'A vague response such as "as needed" is not verifiable, so a tester cannot confirm the system met it.', + badExample: 'The system shall respond as needed.', + goodExample: 'The system shall respond within 200 milliseconds.', + profileNotes: WARNING_PROFILE_NOTE, + }, +]; + +/** + * Recursively freeze a value and every object it reaches, returning it typed as + * deeply readonly. Used to lock the registry data so consumers cannot mutate a + * shared export. Frozen input is returned untouched. + */ +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const key of Object.keys(value)) { + deepFreeze((value as Record)[key]); + } + } + return value; +} + +/** + * The full diagnostic registry, in ascending id order (errors, then warnings). + * Deeply frozen: read it, do not mutate it. + */ +export const DIAGNOSTIC_REGISTRY: readonly DiagnosticRegistryEntry[] = deepFreeze(ENTRIES); + +/** Lookup by current id, built once from the frozen registry. */ +const BY_ID = new Map( + DIAGNOSTIC_REGISTRY.map((entry): [string, DiagnosticRegistryEntry] => [entry.id, entry]), +); + +/** Lookup by deprecated old code (alias), built once from the frozen registry. */ +const BY_OLD_CODE = new Map( + DIAGNOSTIC_REGISTRY.map((entry): [string, DiagnosticRegistryEntry] => [entry.oldCode, entry]), +); + +/** + * Resolve a current id or a deprecated old code to its current id. + * + * `resolveDiagnosticId('ears.missing_shall')` returns `'EARS-E007'`; + * `resolveDiagnosticId('EARS-E007')` returns itself. Aliases resolve forever. + * An unrecognized id or code returns `undefined` so the caller can report it as + * an unknown diagnostic. + * + * @param idOrAlias A current `EARS-*` id or a deprecated dotted old code. + * @returns The current id, or `undefined` when nothing matches. + */ +export function resolveDiagnosticId(idOrAlias: string): string | undefined { + if (BY_ID.has(idOrAlias)) { + return idOrAlias; + } + return BY_OLD_CODE.get(idOrAlias)?.id; +} + +/** + * Fetch the full registry entry for a current id or a deprecated old code. + * + * Resolves aliases the same way as {@link resolveDiagnosticId}, then returns the + * entry. Returns `undefined` when nothing matches. + * + * @param idOrAlias A current `EARS-*` id or a deprecated dotted old code. + * @returns The registry entry, or `undefined` when nothing matches. + */ +export function getDiagnosticEntry(idOrAlias: string): DiagnosticRegistryEntry | undefined { + return BY_ID.get(idOrAlias) ?? BY_OLD_CODE.get(idOrAlias); +} + +/** + * Look up the current id for a raw {@link DiagnosticCode}. + * + * The typed counterpart to {@link resolveDiagnosticId} for callers that already + * hold a core diagnostic code. Every registered code maps, so the result is + * always defined for a valid {@link DiagnosticCode}. + * + * @param code A core diagnostic code. + * @returns The current `EARS-*` id for the code. + */ +export function idForCode(code: DiagnosticCode): string { + const entry = BY_OLD_CODE.get(code); + // Every DiagnosticCode has a registry entry by construction; the drift test + // in registry.test.ts fails if a code is ever left unmapped. + return entry ? entry.id : code; +} diff --git a/packages/core/src/shell-parser.ts b/packages/core/src/shell-parser.ts new file mode 100644 index 0000000..06529b8 --- /dev/null +++ b/packages/core/src/shell-parser.ts @@ -0,0 +1,740 @@ +/** + * The outer EARS sentence parser. + * + * This module owns shell-level concerns only: tokenizing the leading clause + * keywords (`While`, `Where`, `When`, `If ... then`), detecting clause + * boundaries, classifying the shell {@link Pattern}, and extracting the + * `the shall ` tail. It deliberately does NOT parse the + * boolean internals of a clause body (the expression parser owns that) and + * does NOT touch a catalog (the catalog matcher owns that). + * + * Clause bodies are emitted as {@link FreeTextExpr} nodes carrying a span into + * the original input. A later integration layer may re-parse those bodies into + * boolean expressions and split responses on semicolons. + * + * Findings are returned as raw {@link ShellFinding} records (code plus optional + * span and context). Severity, message wording, and stable ordering are the + * responsibility of the separate diagnostics module. + * + * Accepted clause order: `While* -> Where* -> When* -> If*`, followed by the + * mandatory `the shall ` tail. + * + * Determinism note: no dependencies, no file system, no network, no fuzzy + * matching. The same input always yields the same result. + */ + +import { STRICT_DIALECT, type ResolvedDialect } from './options.js'; +import type { + ClauseExpr, + DiagnosticCode, + EarsAst, + FreeTextExpr, + Pattern, + Span, + TermMatch, +} from './types.js'; + +/** + * A raw structural finding produced by the shell parser. + * + * The diagnostics module maps `code` to a severity and message; the shell + * parser only reports what it observed and where. + */ +export interface ShellFinding { + /** The registered diagnostic code for the observed condition. */ + code: DiagnosticCode; + /** Source span the finding refers to, when known. */ + span?: Span; + /** Optional extra context (for example the raw text the finding is about). */ + context?: string; +} + +/** + * The result of a shell parse. + * + * `ast` is present when a `the shall ` tail was recovered, + * even if soft findings (such as `ears.invalid_clause_order`) were also + * reported. It is absent when parsing failed structurally. + */ +export interface ShellParseResult { + /** The recovered partial AST, when a system-and-response tail was found. */ + ast?: EarsAst; + /** Every raw structural finding, in the order they were observed. */ + findings: ShellFinding[]; +} + +/** The four EARS shell clause keywords. */ +type ClauseKind = 'while' | 'where' | 'when' | 'if'; + +/** An extracted clause: its keyword kind, body text, and body span. */ +interface ClauseSlot { + kind: ClauseKind; + text: string; + span: Span; +} + +/** + * Options that influence shell tokenization. + */ +export interface ShellParseOptions { + /** + * Treat commas inside a clause body as `and` rather than clause separators. + * + * When `true`, a comma followed by `the` ends the clause only if that `the` + * begins the mandatory system tail (`the ... shall`, with no comma between + * `the` and `shall`). A comma followed by another shell keyword (`while`, + * `when`, `where`, `if`) always ends the clause. When `false` (the default), + * a comma followed by any shell keyword (including `the`) ends the clause. + */ + commaAsAnd?: boolean; + /** + * The resolved dialect tolerances the parser applies. When absent the parser + * uses {@link STRICT_DIALECT}: canonical keyword casing, a required leading + * comma, no literal system names, no frame metadata, and no prohibition. + */ + dialect?: ResolvedDialect; +} + +/** + * Parse one EARS requirement into a partial AST plus raw structural findings. + * + * @param input The requirement text. Spans in the result are 0-based offsets + * into this exact string (`end` exclusive). + * @param options Optional shell tokenization options. + */ +export function parseShell(input: string, options?: ShellParseOptions): ShellParseResult { + const parser = new ShellParser( + input, + options?.commaAsAnd ?? false, + options?.dialect ?? STRICT_DIALECT, + ); + return parser.run(); +} + +class ShellParser { + private readonly text: string; + private readonly commaAsAnd: boolean; + private readonly dialect: ResolvedDialect; + private readonly findings: ShellFinding[] = []; + private readonly clauses: ClauseSlot[] = []; + private pos = 0; + /** Effective start of the EARS sentence, after any stripped frame-metadata prefix. */ + private start = 0; + + constructor(input: string, commaAsAnd: boolean, dialect: ResolvedDialect) { + this.text = input; + this.commaAsAnd = commaAsAnd; + this.dialect = dialect; + } + + run(): ShellParseResult { + let firstNonWs = skipWS(this.text, 0); + if (firstNonWs >= this.text.length) { + this.add('ears.no_match'); + return { findings: this.findings }; + } + + // A frame-metadata `REQ-###` id prefix is not part of the EARS sentence. + // When the dialect accepts frame metadata, skip it so the sentence that + // follows parses on its own terms; otherwise it flows through as ordinary + // leading text and surfaces whatever diagnostic naturally results. + if (this.dialect.allowFrameMetadata) { + firstNonWs = skipFrameMetadataPrefix(this.text, firstNonWs); + } + + const shallCount = countShall(this.text); + if (shallCount === 0) { + this.add('ears.missing_shall'); + } else if (shallCount > 1) { + this.add('ears.multiple_shall'); + } + + this.start = firstNonWs; + this.pos = firstNonWs; + const ast = this.parse(); + return ast ? { ast, findings: this.findings } : { findings: this.findings }; + } + + private parse(): EarsAst | undefined { + // Leading clauses: While* -> Where* -> When* -> If*, in any observed order. + // Order is validated after the tail is found; here we only tokenize. + for (;;) { + this.pos = skipWS(this.text, this.pos); + if (this.peekKeyword('while')) { + this.consumeClauseKeyword('while'); + this.pushClause(this.scanCommaClause('while')); + } else if (this.peekKeyword('when')) { + this.consumeClauseKeyword('when'); + this.pushClause(this.scanCommaClause('when')); + } else if (this.peekKeyword('where')) { + this.consumeClauseKeyword('where'); + this.pushClause(this.scanCommaClause('where')); + } else if (this.peekKeyword('if')) { + this.consumeClauseKeyword('if'); + const ifClause = this.scanIfClause(); + if (!ifClause) { + return undefined; + } + this.pushClause(ifClause); + } else { + break; + } + } + + // Mandatory tail: the shall . A dialect that accepts a + // literal system name (for example `THE SYSTEM`) may substitute it for the + // canonical `the ` form. + let systemStart: number; + const literalLen = this.matchLiteralSystemName(); + if (literalLen > 0) { + systemStart = this.pos; + } else if (this.consumeKeyword('the')) { + systemStart = this.pos; + } else { + this.add(this.clauses.length === 0 ? 'ears.no_match' : 'ears.missing_system'); + return undefined; + } + + const shallIdx = findWordFrom(this.text, systemStart, 'shall'); + if (shallIdx < 0) { + this.add('ears.missing_shall'); + return undefined; + } + this.checkKeywordCase('shall', shallIdx, false); + + const systemSpan = trimmedSpan(this.text, systemStart, shallIdx); + const systemRaw = this.text.slice(systemSpan.start, systemSpan.end); + if (systemRaw === '') { + this.add('ears.missing_system', systemSpan); + } + + this.pos = shallIdx + 'shall'.length; + let responseRaw = this.text.slice(this.pos).trim(); + // A trailing `[source: path:line]` tag is frame metadata, not part of the + // response; strip it when the dialect accepts frame metadata. + if (this.dialect.allowFrameMetadata) { + responseRaw = stripTrailingSourceTag(responseRaw); + } + if (responseRaw.endsWith('.')) { + responseRaw = responseRaw.slice(0, -1).trimEnd(); + } + + // A `shall not` response is a prohibition. Canonical EARS has no prohibition + // template, so a strict dialect rejects it; a dialect that permits it marks + // the requirement so downstream verification can treat it as an absence. + let prohibition = false; + if (/^not\b/i.test(responseRaw)) { + if (this.dialect.allowProhibition) { + prohibition = true; + } else { + const notStart = skipWS(this.text, this.pos); + this.add('ears.prohibition_not_allowed', { start: shallIdx, end: notStart + 'not'.length }); + } + } + + if (responseRaw === '') { + const responseSpan = trimmedSpan(this.text, this.pos, this.text.length); + this.add('ears.empty_response', responseSpan); + return undefined; + } + + if (!validClauseOrder(this.clauses)) { + this.add('ears.invalid_clause_order'); + } + this.validateCardinality(); + + // `then` is the discriminator for the unwanted-behaviour form; outside an + // `If ... then` requirement it is not part of the EARS grammar. + if ( + !this.clauses.some((clause) => clause.kind === 'if') && + hasTopLevelThen(this.text, this.start) + ) { + this.add('ears.invalid_if_then_form'); + } + + return { + pattern: classifyPattern(this.clauses), + ...buildClauseFields(this.clauses), + system: { raw: systemRaw, role: 'system' } satisfies TermMatch, + responses: [responseRaw], + raw: this.text, + ...(prohibition ? { prohibition: true } : {}), + }; + } + + /** + * Extract a leading clause body, tracking parenthesis depth. + * + * A leading clause is delimited from the main clause by a comma. When the + * comma is absent the clause runs into the system tail; this method locates + * that tail so it can still recover a clause and, when the dialect requires + * the comma, report `ears.missing_leading_comma`. + */ + private scanCommaClause(kind: ClauseKind): ClauseSlot { + const start = this.pos; + const { end, next, comma } = scanUntilClauseBoundary(this.text, this.pos, this.commaAsAnd); + if (comma) { + this.pos = next; + return this.makeClause(kind, start, end); + } + + // No comma delimiter was found. Recover the clause boundary at the system + // tail (`the shall`) when one is present, so a missing comma still + // yields a parseable requirement rather than a swallowed tail. + const tail = findSystemTailThe(this.text, start); + if (tail > start) { + if (this.dialect.commaAfterLeadingClause === 'required') { + this.add('ears.missing_leading_comma', { start, end: tail }); + } + this.pos = tail; + return this.makeClause(kind, start, tail); + } + + this.pos = next; + return this.makeClause(kind, start, end); + } + + /** + * Extract an `If ... then ...` clause body. + * + * Returns `undefined` (fatal) only when a `then` boundary exists but is not + * followed by `the`. A missing `then` is reported as a soft finding and the + * clause is recovered by scanning to the next clause boundary. + */ + private scanIfClause(): ClauseSlot | undefined { + const start = this.pos; + const thenIdx = findThenBoundary(this.text, this.pos); + if (thenIdx < 0) { + this.add('ears.invalid_if_then_form', { start, end: this.text.length }); + const { end, next } = scanUntilClauseBoundary(this.text, this.pos, this.commaAsAnd); + this.pos = next; + return this.makeClause('if', start, end); + } + + this.checkKeywordCase('then', thenIdx, false); + let end = thenIdx; + // Drop a trailing comma before `then`, if present. + const beforeThen = this.text.slice(start, thenIdx).trimEnd(); + if (beforeThen.endsWith(',')) { + end = start + beforeThen.length - 1; + } + + this.pos = skipWS(this.text, thenIdx + 'then'.length); + if (!this.peekKeyword('the')) { + this.add('ears.invalid_if_then_form', { start, end: thenIdx }); + return undefined; + } + return this.makeClause('if', start, end); + } + + private makeClause(kind: ClauseKind, start: number, end: number): ClauseSlot { + const span = trimmedSpan(this.text, start, end); + const body = this.text.slice(span.start, span.end); + if (body === '') { + this.add('ears.empty_clause', span); + } + return { kind, text: body, span }; + } + + private pushClause(clause: ClauseSlot): void { + this.clauses.push(clause); + } + + private validateCardinality(): void { + const counts: Record = { while: 0, where: 0, when: 0, if: 0 }; + for (const clause of this.clauses) { + counts[clause.kind]++; + } + // `when` and `if` are distinct trigger kinds. A `when` followed by an `if` + // is a valid complex requirement; only a repeat of the same kind is invalid. + if (counts.when > 1) { + this.add('ears.invalid_clause_order'); + } + if (counts.where > 1) { + this.add('ears.invalid_clause_order'); + } + if (counts.if > 1) { + this.add('ears.invalid_if_then_form'); + } + } + + private add(code: DiagnosticCode, span?: Span, context?: string): void { + this.findings.push({ code, ...(span ? { span } : {}), ...(context ? { context } : {}) }); + } + + private peekKeyword(keyword: string): boolean { + return hasWordAt(this.text, this.pos, keyword); + } + + private consumeKeyword(keyword: string): boolean { + if (!this.peekKeyword(keyword)) { + return false; + } + this.pos = skipWS(this.text, this.pos + keyword.length); + return true; + } + + /** + * Consume a leading clause keyword, checking its casing first. The first + * keyword of the sentence is sentence-initial (capitalized in Mavin's + * templates); a later clause keyword is mid-sentence and lowercase. + */ + private consumeClauseKeyword(keyword: ClauseKind): void { + this.checkKeywordCase(keyword, this.pos, this.pos === this.start); + this.pos = skipWS(this.text, this.pos + keyword.length); + } + + /** + * Report `ears.keyword_case` when a keyword at `pos` does not match its + * canonical casing under a strict dialect. Clause keywords are capitalized + * sentence-initially and lowercase mid-sentence; `shall` and `then` are always + * lowercase. A case-insensitive dialect performs no check. + */ + private checkKeywordCase(keyword: string, pos: number, sentenceInitial: boolean): void { + if (this.dialect.keywordCase !== 'strict') { + return; + } + const actual = this.text.slice(pos, pos + keyword.length); + const expected = expectedKeywordCasing(keyword, sentenceInitial); + if (actual !== expected) { + this.add('ears.keyword_case', { start: pos, end: pos + keyword.length }, actual); + } + } + + /** + * When the current position begins a literal system phrase the dialect + * accepts (for example `THE SYSTEM`), return its length so the tail parser can + * take it in place of the canonical `the ` form. Returns `0` otherwise. + */ + private matchLiteralSystemName(): number { + for (const name of this.dialect.allowLiteralSystemName) { + if (name !== '' && hasPhraseAt(this.text, this.pos, name)) { + return name.length; + } + } + return 0; + } +} + +/** The clause-derived optional fields of an {@link EarsAst}. */ +type ClauseFields = Pick; + +/** + * Build the clause fields for the AST from the extracted clauses. + * + * Each clause body becomes a {@link FreeTextExpr}. Repeated same-kind clauses + * are `and`-joined. `If` maps to `unwanted`, `When` to `trigger`, `Where` to + * `feature`, and `While` to `preconditions`. + */ +function buildClauseFields(clauses: readonly ClauseSlot[]): ClauseFields { + const fields: ClauseFields = {}; + for (const clause of clauses) { + const node: FreeTextExpr = { kind: 'free-text', span: clause.span, text: clause.text }; + switch (clause.kind) { + case 'while': + fields.preconditions = combine(fields.preconditions, node); + break; + case 'when': + fields.trigger = combine(fields.trigger, node); + break; + case 'where': + fields.feature = combine(fields.feature, node); + break; + case 'if': + fields.unwanted = node; + break; + } + } + return fields; +} + +/** Combine a same-kind clause node into an existing slot, `and`-joining them. */ +function combine(existing: ClauseExpr | undefined, node: ClauseExpr): ClauseExpr { + if (!existing) { + return node; + } + const items = existing.kind === 'and' ? [...existing.items, node] : [existing, node]; + const start = existing.span?.start; + const end = node.span?.end; + const span = start !== undefined && end !== undefined ? { start, end } : undefined; + return { kind: 'and', items, ...(span ? { span } : {}) }; +} + +function classifyPattern(clauses: readonly ClauseSlot[]): Pattern { + if (clauses.length === 0) { + return 'ubiquitous'; + } + if (clauses.length === 1) { + switch (clauses[0].kind) { + case 'while': + return 'state-driven'; + case 'when': + return 'event-driven'; + case 'where': + return 'optional-feature'; + case 'if': + return 'unwanted-behaviour'; + } + } + return 'complex'; +} + +/** + * Validate the accepted clause order `While* -> Where* -> When* -> If*`. + * + * A clause whose rank is lower than a previously seen clause's rank breaks the + * order. + */ +function validClauseOrder(clauses: readonly ClauseSlot[]): boolean { + const rank: Record = { while: 1, where: 2, when: 3, if: 4 }; + let max = 0; + for (const clause of clauses) { + const v = rank[clause.kind]; + if (v < max) { + return false; + } + max = v; + } + return true; +} + +const SHALL_RE = /\bshall\b/gi; + +function countShall(text: string): number { + const matches = text.match(SHALL_RE); + return matches ? matches.length : 0; +} + +function skipWS(text: string, pos: number): number { + let cursor = pos; + while (cursor < text.length && isWhitespace(text.charCodeAt(cursor))) { + cursor++; + } + return cursor; +} + +function isWhitespace(code: number): boolean { + // space, tab, newline, carriage return. + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isWordChar(code: number): boolean { + return ( + (code >= 97 && code <= 122) || // a-z + (code >= 65 && code <= 90) || // A-Z + (code >= 48 && code <= 57) || // 0-9 + code === 95 // _ + ); +} + +/** Case-insensitive whole-word match of `word` at `pos`. */ +function hasWordAt(text: string, pos: number, word: string): boolean { + if (pos < 0 || pos + word.length > text.length) { + return false; + } + if (text.slice(pos, pos + word.length).toLowerCase() !== word.toLowerCase()) { + return false; + } + const beforeOk = pos === 0 || !isWordChar(text.charCodeAt(pos - 1)); + const afterPos = pos + word.length; + const afterOk = afterPos >= text.length || !isWordChar(text.charCodeAt(afterPos)); + return beforeOk && afterOk; +} + +function hasAnyKeywordAt(text: string, pos: number, keywords: readonly string[]): boolean { + return keywords.some((kw) => hasWordAt(text, pos, kw)); +} + +/** Case-insensitive whole-phrase match of `phrase` at `pos` (word-bounded). */ +function hasPhraseAt(text: string, pos: number, phrase: string): boolean { + if (pos + phrase.length > text.length) { + return false; + } + if (text.slice(pos, pos + phrase.length).toLowerCase() !== phrase.toLowerCase()) { + return false; + } + const beforeOk = pos === 0 || !isWordChar(text.charCodeAt(pos - 1)); + const afterPos = pos + phrase.length; + const afterOk = afterPos >= text.length || !isWordChar(text.charCodeAt(afterPos)); + return beforeOk && afterOk; +} + +/** + * The canonical casing a keyword must carry under a strict dialect. Clause + * keywords (`while`, `when`, `where`, `if`) are capitalized when they open the + * sentence and lowercase when they appear mid-sentence; `shall` and `then` are + * always lowercase. + */ +function expectedKeywordCasing(keyword: string, sentenceInitial: boolean): string { + if (keyword === 'shall' || keyword === 'then') { + return keyword; + } + return sentenceInitial ? keyword[0].toUpperCase() + keyword.slice(1) : keyword; +} + +const FRAME_ID_PREFIX_RE = /^REQ-\d+[.:)\]]?\s+/i; + +/** + * Skip a leading `REQ-###` frame-metadata id prefix starting at `from`, returning + * the offset of the first character after it. When no such prefix is present the + * input offset is returned unchanged. + */ +function skipFrameMetadataPrefix(text: string, from: number): number { + const match = FRAME_ID_PREFIX_RE.exec(text.slice(from)); + return match ? skipWS(text, from + match[0].length) : from; +} + +const TRAILING_SOURCE_TAG_RE = /\s*\[source:[^\]]*\]\s*\.?\s*$/i; + +/** + * Strip a trailing `[source: path:line]` frame-metadata tag (and any trailing + * period it precedes) from a response string. Returns the response unchanged + * when no such tag is present. + */ +function stripTrailingSourceTag(response: string): string { + return response.replace(TRAILING_SOURCE_TAG_RE, '').trimEnd(); +} + +function findWordFrom(text: string, pos: number, word: string): number { + for (let i = pos; i < text.length; i++) { + if (hasWordAt(text, i, word)) { + return i; + } + } + return -1; +} + +/** Find a top-level (paren-depth-0) `then` boundary from `start`. */ +function findThenBoundary(text: string, start: number): number { + let depth = 0; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')' && depth > 0) { + depth--; + } + if (depth === 0 && hasWordAt(text, i, 'then')) { + return i; + } + } + return -1; +} + +const LEADING_CLAUSE_KEYWORDS: readonly string[] = ['while', 'when', 'where', 'if']; + +/** + * Scan from `start` to the next top-level clause boundary: a comma at + * paren-depth 0 followed by another clause keyword or the system tail. + * + * A comma followed by a leading clause keyword (`while`, `when`, `where`, + * `if`) always ends the clause. A comma followed by `the` ends the clause + * when `commaAsAnd` is `false`, or when `commaAsAnd` is `true` only if that + * `the` begins the system tail (`the ... shall`, per {@link isSystemTail}). + * + * Returns the body `end` (exclusive, at the comma) and the `next` position to + * resume parsing from (the keyword after the comma). When no boundary is + * found, both are the end of the text. + */ +function scanUntilClauseBoundary( + text: string, + start: number, + commaAsAnd: boolean, +): { end: number; next: number; comma: boolean } { + let depth = 0; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')' && depth > 0) { + depth--; + } + if (depth === 0 && ch === ',' && isClauseBoundaryAfterComma(text, i + 1, commaAsAnd)) { + return { end: i, next: skipWS(text, i + 1), comma: true }; + } + } + return { end: text.length, next: text.length, comma: false }; +} + +/** + * Find the `the` that opens the system tail (`the shall`) at or after + * `from`, tracking parenthesis depth. This is the last top-level `the` before + * the shell `shall`, so a comma-less leading clause can still be split from the + * tail it ran into. Returns `-1` when no such `the` precedes a `shall`. + */ +function findSystemTailThe(text: string, from: number): number { + const shallIdx = findWordFrom(text, from, 'shall'); + if (shallIdx < 0) { + return -1; + } + let depth = 0; + let last = -1; + for (let i = from; i < shallIdx; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')' && depth > 0) { + depth--; + } + if (depth === 0 && hasWordAt(text, i, 'the')) { + last = i; + } + } + return last; +} + +/** Whether a top-level (paren-depth-0) `then` keyword appears from `start`. */ +function hasTopLevelThen(text: string, start: number): boolean { + return findThenBoundary(text, start) >= 0; +} + +/** Whether a comma at position `commaEnd - 1` marks a clause boundary. */ +function isClauseBoundaryAfterComma(text: string, commaEnd: number, commaAsAnd: boolean): boolean { + const j = skipWS(text, commaEnd); + if (hasAnyKeywordAt(text, j, LEADING_CLAUSE_KEYWORDS)) { + return true; + } + if (!hasWordAt(text, j, 'the')) { + return false; + } + // A comma + `the` ends the clause unless commaAsAnd keeps non-tail `the` + // segments inside the body. + return !commaAsAnd || isSystemTail(text, j); +} + +/** + * Whether the `the` at `pos` begins the system tail: a `shall` follows at + * paren-depth 0 with no intervening top-level comma. This distinguishes the + * real `the shall` tail from a `the ...` segment that is part of a + * comma-joined clause body. + */ +function isSystemTail(text: string, pos: number): boolean { + let depth = 0; + for (let i = pos; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') { + depth++; + } else if (ch === ')' && depth > 0) { + depth--; + } else if (depth === 0 && ch === ',') { + return false; + } + if (depth === 0 && hasWordAt(text, i, 'shall')) { + return true; + } + } + return false; +} + +/** The span of the trimmed content of `text[start:end)`. */ +function trimmedSpan(text: string, start: number, end: number): Span { + let s = start; + let e = end; + while (s < e && isWhitespace(text.charCodeAt(s))) { + s++; + } + while (e > s && isWhitespace(text.charCodeAt(e - 1))) { + e--; + } + return { start: s, end: e }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..3df4ee1 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,488 @@ +/** + * Frozen public type contracts for `@earsyntax/core`. + * + * This module is the single source of truth for every shared shape used across + * the EARS JS toolkit. Downstream packages (`@earsyntax/extract`, + * `@earsyntax/cli`) and every implementation agent compile against these types. + * + * The types combine two sources: + * 1. The recommended TypeScript shapes from the orchestration brief. + * 2. The Go reference implementation (`ears-lint-go`) for `TermMatch`, + * `ReferenceMatch`, `CatalogRef`, `TermRole`, and `Severity`. + * + * Determinism note: none of these shapes carry runtime behavior. The linter + * and parser that consume them must remain deterministic (no LLM, no network, + * no file system, no fuzzy matching). + */ + +/** + * Linting strictness. + * + * - `strict`: structural parse failures and unresolved systems are errors. + * - `guided`: structural parse failures may be downgraded to warnings where a + * partial AST can still be recovered. + */ +export type Mode = 'strict' | 'guided'; + +/** + * The EARS shell pattern a requirement matches. + * + * Values follow the canonical Mavin EARS templates. TypeScript uses + * `optional-feature` and `unwanted-behaviour` consistently. + */ +export type Pattern = + | 'ubiquitous' + | 'state-driven' + | 'event-driven' + | 'optional-feature' + | 'unwanted-behaviour' + | 'complex'; + +/** + * Diagnostic severity level. + * + * `valid` on a {@link LintResult} is `false` when any diagnostic has severity + * `error`, and `true` otherwise. `warning` and `info` never affect validity. + */ +export type Severity = 'error' | 'warning' | 'info'; + +/** + * The clause keyword a reference or expression belongs to. + * + * Mirrors the four EARS shell clause keywords. Used to describe where a + * {@link ReferenceMatch} was found and to classify clause expressions. + */ +export type ClauseType = 'while' | 'when' | 'where' | 'if'; + +/** + * The semantic role a matched term plays in a requirement. + * + * Ported verbatim from the Go reference `TermRole`. Catalog groups map one to + * one onto these roles (for example `systems` entries carry role `system`). + */ +export type TermRole = + 'system' | 'actor' | 'event' | 'state' | 'feature' | 'mode' | 'condition' | 'data-term'; + +/** + * A half-open source offset range `[start, end)`. + * + * Offsets are 0-based character indices into the requirement text. `end` is + * exclusive. Spans are preserved through parsing so tools can point at the + * exact substring a diagnostic or term refers to. + */ +export interface Span { + /** 0-based inclusive start offset in the input text. */ + start: number; + /** 0-based exclusive end offset in the input text. */ + end: number; +} + +/** + * A single catalog entry: one canonical named concept plus optional aliases. + */ +export interface CatalogEntry { + /** Stable identifier for the entry (for example `SYS-BILLING`). */ + id: string; + /** Canonical display name matched against requirement text. */ + name: string; + /** Optional alternative spellings that also match this entry. */ + aliases?: string[]; +} + +/** + * A user-supplied catalog of known domain terms grouped by role. + * + * Each group is optional. Matching is deterministic: exact canonical name, + * then exact alias, then ambiguous (more than one match), then unresolved + * (no match). No fuzzy or semantic matching is performed. + */ +export interface Catalog { + /** Entries whose role is `system`. */ + systems?: CatalogEntry[]; + /** Entries whose role is `actor`. */ + actors?: CatalogEntry[]; + /** Entries whose role is `event`. */ + events?: CatalogEntry[]; + /** Entries whose role is `state`. */ + states?: CatalogEntry[]; + /** Entries whose role is `feature`. */ + features?: CatalogEntry[]; + /** Entries whose role is `mode`. */ + modes?: CatalogEntry[]; + /** Entries whose role is `condition`. */ + conditions?: CatalogEntry[]; + /** Entries whose role is `data-term`. */ + dataTerms?: CatalogEntry[]; +} + +/** + * A resolved pointer to the catalog entry a term matched. + * + * Ported from the Go reference `CatalogRef`. `group` is the catalog group name + * (for example `systems`), not the {@link TermRole}. + */ +export interface CatalogRef { + /** Catalog group the entry belongs to (for example `systems`). */ + group: string; + /** Identifier of the matched {@link CatalogEntry}. */ + id: string; + /** Canonical name of the matched {@link CatalogEntry}. */ + name: string; +} + +/** + * The result of attempting to match a single raw term against the catalog. + * + * Ported from the Go reference `TermMatch`. Exactly one outcome applies: + * a single `matched` entry, a non-empty `ambiguous` list, or `unresolved`. + * When the catalog is absent the term is neither matched nor unresolved. + */ +export interface TermMatch { + /** The raw term text as it appeared in the requirement. */ + raw: string; + /** The role this term was expected to play. */ + role: TermRole; + /** The single catalog entry matched, when the match is unambiguous. */ + matched?: CatalogRef; + /** All candidate entries, when more than one entry matched. */ + ambiguous?: CatalogRef[]; + /** `true` when a catalog was supplied but no entry matched. */ + unresolved?: boolean; + /** `true` when the match was via an alias rather than the canonical name. */ + viaAlias?: boolean; +} + +/** + * A catalog reference discovered somewhere in a requirement, with its clause + * context and source span. + * + * Ported from the Go reference `ReferenceMatch`. Collected into + * {@link LintResult.references} so tools can report every catalog touch point. + */ +export interface ReferenceMatch { + /** Where the reference was found (for example `system`, `trigger`). */ + clause: string; + /** The raw reference text. */ + text: string; + /** The role this reference was matched under. */ + role: TermRole; + /** The single catalog entry matched, when the match is unambiguous. */ + matched?: CatalogRef; + /** All candidate entries, when more than one entry matched. */ + ambiguous?: CatalogRef[]; + /** `true` when a catalog was supplied but no entry matched. */ + unresolved?: boolean; + /** `true` when the match was via an alias rather than the canonical name. */ + viaAlias?: boolean; + /** Source span of the reference in the requirement text, when known. */ + span?: Span; +} + +/** + * The complete registry of diagnostic codes the toolkit can emit. + * + * This union is append-only once fixtures depend on it. It contains: + * - The 24 codes from the orchestration diagnostic table. + * - Two Go reference extras: `expr.mixed_unresolved_terms` and + * `catalog.term_unreferenced`. + * - The generated `catalog._unresolved` / `catalog._ambiguous` + * family for roles `system`, `state`, `event`, and `feature`. + */ +export type DiagnosticCode = + // EARS shell diagnostics. + | 'ears.no_match' + | 'ears.invalid_clause_order' + | 'ears.missing_system' + | 'ears.missing_shall' + | 'ears.multiple_shall' + | 'ears.invalid_if_then_form' + | 'ears.empty_clause' + | 'ears.empty_response' + // Expression diagnostics. + | 'expr.unbalanced_parentheses' + | 'expr.invalid_operator_sequence' + | 'expr.empty_subexpression' + | 'expr.operator_precedence_warning' + | 'expr.unknown_term' + | 'expr.ambiguous_term' + | 'expr.mixed_unresolved_terms' + // Catalog diagnostics: system role. + | 'catalog.system_unresolved' + | 'catalog.system_ambiguous' + // Catalog diagnostics: state role. + | 'catalog.state_unresolved' + | 'catalog.state_ambiguous' + // Catalog diagnostics: event role. + | 'catalog.event_unresolved' + | 'catalog.event_ambiguous' + // Catalog diagnostics: feature role. + | 'catalog.feature_unresolved' + | 'catalog.feature_ambiguous' + // Catalog coverage. + | 'catalog.term_unreferenced' + // Lint diagnostics. + | 'lint.multiple_responses' + | 'lint.vague_response' + | 'lint.unparsed_tail' + | 'lint.alias_used' + | 'lint.suspicious_text_shape' + // Host-native grammar diagnostics. Introduced by the host-native grammar + // work; these have no legacy code they migrate from, but the dotted form + // registers as an alias like every other code. + | 'ears.keyword_case' + | 'ears.missing_leading_comma' + | 'ears.prohibition_not_allowed'; + +/** + * A single machine-readable finding about a requirement. + * + * Ported from the Go reference `Diagnostic`, with `code` narrowed to the + * frozen {@link DiagnosticCode} registry. Diagnostics are stably sorted by + * span, then code, then message, then severity. + */ +export interface Diagnostic { + /** The registered diagnostic code. */ + code: DiagnosticCode; + /** Severity, which drives {@link LintResult.valid}. */ + severity: Severity; + /** Human-readable explanation of the finding. */ + message: string; + /** Source span the finding refers to, when known. */ + span?: Span; +} + +/** + * A clause expression node. + * + * Discriminated on `kind`. Built by parsing the boolean-like body of a + * `While`, `Where`, `When`, or `If` clause. Precedence is `not > and > or`. + */ +export type ClauseExpr = TermExpr | AndExpr | OrExpr | NotExpr | GroupExpr | FreeTextExpr; + +/** + * A leaf clause term (for example `the payment provider is unavailable`). + */ +export interface TermExpr { + kind: 'term'; + /** Source span of the term, when known. */ + span?: Span; + /** The raw term text. */ + text: string; + /** Catalog match result for the term, when catalog matching ran. */ + term?: TermMatch; +} + +/** + * A conjunction of two or more operands (`A and B`). + */ +export interface AndExpr { + kind: 'and'; + /** Source span covering the whole conjunction, when known. */ + span?: Span; + /** The conjoined operands, in source order. */ + items: ClauseExpr[]; +} + +/** + * A disjunction of two or more operands (`A or B`). + */ +export interface OrExpr { + kind: 'or'; + /** Source span covering the whole disjunction, when known. */ + span?: Span; + /** The disjoined operands, in source order. */ + items: ClauseExpr[]; +} + +/** + * A negation of a single operand (`not A`). + */ +export interface NotExpr { + kind: 'not'; + /** Source span covering the negation, when known. */ + span?: Span; + /** The negated operand. */ + item: ClauseExpr; +} + +/** + * A parenthesized group wrapping a single operand (`(A or B)`). + */ +export interface GroupExpr { + kind: 'group'; + /** Source span covering the group including parentheses, when known. */ + span?: Span; + /** The grouped operand. */ + item: ClauseExpr; +} + +/** + * A clause body that could not be parsed as a boolean expression and is kept + * verbatim as free text. + */ +export interface FreeTextExpr { + kind: 'free-text'; + /** Source span of the free text, when known. */ + span?: Span; + /** The raw clause body. */ + text: string; +} + +/** + * The parsed abstract syntax tree of one EARS requirement. + * + * Which optional clause fields are present depends on {@link EarsAst.pattern}. + * `system`, `responses`, and `raw` are always present. + */ +export interface EarsAst { + /** The classified shell pattern. */ + pattern: Pattern; + /** State/optional preconditions (`While` / `Where` clause bodies). */ + preconditions?: ClauseExpr; + /** Event trigger (`When` clause body). */ + trigger?: ClauseExpr; + /** Optional feature condition (`Where` clause body). */ + feature?: ClauseExpr; + /** Unwanted-behaviour condition (`If` clause body). */ + unwanted?: ClauseExpr; + /** The system the requirement constrains. */ + system: TermMatch; + /** Response phrases after `shall`, split on semicolons. */ + responses: string[]; + /** The original requirement text. */ + raw: string; + /** + * `true` when the requirement is a prohibition (`shall not`) accepted because + * the active dialect sets {@link DialectOptions.allowProhibition}. Absent or + * `false` for an ordinary `shall` obligation. See `docs/contracts/profile.md`. + */ + prohibition?: boolean; +} + +/** + * Grammar tolerances an EARS dialect applies during parsing and linting. + * + * These mirror the `dialect` block of a profile (see `docs/contracts/profile.md`, + * "dialect"). Every field is optional; an absent field takes the canonical + * strict default (strict keyword casing, no literal system names, a required + * leading comma, and no story wrapper, frame metadata, or prohibition). + */ +export interface DialectOptions { + /** + * `strict`: EARS keywords must match canonical casing (`When`, `While`, + * `Where`, `If`, `shall`). `case-insensitive`: any casing is accepted. + */ + keywordCase?: 'strict' | 'case-insensitive'; + /** + * Literal system phrases accepted in place of `the ` (for example + * `['THE SYSTEM']`). Absent or empty means only the canonical form is valid. + */ + allowLiteralSystemName?: string[]; + /** + * `required`: a leading `When`/`While`/`Where`/`If` clause must be followed by + * a comma before the main clause. `optional`: the comma may be absent. + */ + commaAfterLeadingClause?: 'required' | 'optional'; + /** + * When `true`, user-story frame lines (for example `As a user, I want ...`) + * are treated as non-requirement frame content and skipped, not parsed. + */ + allowStoryWrapper?: boolean; + /** + * When `true`, `REQ-###` frame ids and `[source: path:line]` tags are accepted + * as metadata prefixes on a requirement line. + */ + allowFrameMetadata?: boolean; + /** + * When `true`, `shall not` is accepted as a prohibition kind. When `false` or + * absent, `shall not` is rejected (canonical Mavin EARS has no prohibition + * template). + */ + allowProhibition?: boolean; +} + +/** + * Options that tune parsing and linting behavior. + * + * Defaults (applied by the core integration layer): `mode` is `strict`, + * `commaAsAnd` is `false`, and `vagueTerms` is `['appropriate', 'sufficient', + * 'as needed']`. + */ +export interface Options { + /** Linting strictness. Defaults to `strict`. */ + mode?: Mode; + /** Treat unambiguous commas inside clause bodies as `and`. Defaults to `false`. */ + commaAsAnd?: boolean; + /** Terms flagged as vague when they appear in a response. */ + vagueTerms?: string[]; + /** + * Grammar tolerances applied while parsing and linting. Defaults to the + * canonical strict dialect when absent. See `docs/contracts/profile.md`. + */ + dialect?: DialectOptions; +} + +/** + * The origin of a requirement in a source file. + * + * Populated by extractors so diagnostics can be traced back to their file + * and position. All fields are optional. + */ +export interface SourceLocation { + /** Path of the source file. */ + file?: string; + /** 1-based line number within the file. */ + line?: number; + /** 1-based column number within the line. */ + column?: number; +} + +/** + * One requirement to lint, with an optional id and source origin. + * + * Used by {@link LintResult} batch APIs and produced by extractors. + */ +export interface RequirementInput { + /** Caller-supplied identifier, echoed back on the result. */ + id?: string; + /** The requirement text to parse and lint. */ + text: string; + /** Where the requirement came from, when known. */ + source?: SourceLocation; +} + +/** + * The full result of linting one requirement. + * + * `valid` is derived only from diagnostic severity: `false` if any diagnostic + * is an `error`, otherwise `true`. + */ +export interface LintResult { + /** Echo of {@link RequirementInput.id}, when one was supplied. */ + id?: string; + /** `false` when any diagnostic has severity `error`. */ + valid: boolean; + /** The classified pattern, when the requirement parsed. */ + pattern?: Pattern; + /** The parsed AST, when available. */ + ast?: EarsAst; + /** Every catalog reference found, in stable order. */ + references: ReferenceMatch[]; + /** Every finding, stably sorted. */ + diagnostics: Diagnostic[]; +} + +/** + * The result of parsing (without full linting) one requirement. + * + * A lighter surface than {@link LintResult}: it carries the pattern, AST, and + * any structural diagnostics, but not catalog references or lint findings. + */ +export interface ParseResult { + /** The classified pattern, when the requirement parsed. */ + pattern?: Pattern; + /** The parsed AST, when available. */ + ast?: EarsAst; + /** Structural findings from parsing, stably sorted. */ + diagnostics: Diagnostic[]; +} diff --git a/packages/core/test/fixtures.helpers.test.ts b/packages/core/test/fixtures.helpers.test.ts new file mode 100644 index 0000000..4f9674b --- /dev/null +++ b/packages/core/test/fixtures.helpers.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { multisetDiff, pairKey, spanAssertionFailures, subsetMatch } from './fixtures.helpers.js'; + +describe('pairKey', () => { + it('joins code and severity', () => { + expect(pairKey({ code: 'ears.missing_shall', severity: 'error' })).toBe( + 'ears.missing_shall|error', + ); + }); +}); + +describe('multisetDiff', () => { + it('reports equal multisets as no diff', () => { + const diff = multisetDiff( + [ + { code: 'a', severity: 'error' }, + { code: 'b', severity: 'warning' }, + ], + [ + { code: 'b', severity: 'warning' }, + { code: 'a', severity: 'error' }, + ], + ); + expect(diff).toEqual({ missing: [], extra: [] }); + }); + + it('treats an empty-vs-empty comparison as equal', () => { + expect(multisetDiff([], [])).toEqual({ missing: [], extra: [] }); + }); + + it('detects a missing pair', () => { + const diff = multisetDiff([{ code: 'a', severity: 'error' }], []); + expect(diff.missing).toEqual(['a|error (expected 1, got 0)']); + expect(diff.extra).toEqual([]); + }); + + it('detects an extra pair', () => { + const diff = multisetDiff([], [{ code: 'a', severity: 'error' }]); + expect(diff.extra).toEqual(['a|error (expected 0, got 1)']); + expect(diff.missing).toEqual([]); + }); + + it('counts duplicates: two expected require two actual', () => { + const diff = multisetDiff( + [ + { code: 'lint.vague_response', severity: 'warning' }, + { code: 'lint.vague_response', severity: 'warning' }, + ], + [{ code: 'lint.vague_response', severity: 'warning' }], + ); + expect(diff.missing).toEqual(['lint.vague_response|warning (expected 2, got 1)']); + expect(diff.extra).toEqual([]); + }); + + it('distinguishes same code across severities', () => { + const diff = multisetDiff( + [{ code: 'x', severity: 'error' }], + [{ code: 'x', severity: 'warning' }], + ); + expect(diff.missing).toEqual(['x|error (expected 1, got 0)']); + expect(diff.extra).toEqual(['x|warning (expected 0, got 1)']); + }); +}); + +describe('spanAssertionFailures', () => { + it('ignores expected diagnostics without a span', () => { + expect(spanAssertionFailures([{ code: 'a', severity: 'error' }], [])).toEqual([]); + }); + + it('passes when an actual diagnostic carries the pinned span', () => { + const failures = spanAssertionFailures( + [{ code: 'a', severity: 'error', span: { start: 0, end: 3 } }], + [{ code: 'a', severity: 'error', span: { start: 0, end: 3 } }], + ); + expect(failures).toEqual([]); + }); + + it('fails when the span differs', () => { + const failures = spanAssertionFailures( + [{ code: 'a', severity: 'error', span: { start: 0, end: 3 } }], + [{ code: 'a', severity: 'error', span: { start: 0, end: 4 } }], + ); + expect(failures).toHaveLength(1); + }); + + it('fails when no actual diagnostic matches code and severity', () => { + const failures = spanAssertionFailures( + [{ code: 'a', severity: 'error', span: { start: 0, end: 3 } }], + [{ code: 'a', severity: 'warning', span: { start: 0, end: 3 } }], + ); + expect(failures).toHaveLength(1); + }); +}); + +describe('subsetMatch', () => { + it('matches when only a subset of keys is asserted', () => { + expect( + subsetMatch( + { pattern: 'event-driven', system: { role: 'system' } }, + { + pattern: 'event-driven', + system: { role: 'system', raw: 'billing service' }, + responses: ['verify'], + }, + ), + ).toBeNull(); + }); + + it('ignores extra keys in actual', () => { + expect(subsetMatch({ a: 1 }, { a: 1, b: 2 })).toBeNull(); + }); + + it('reports a primitive mismatch with a path', () => { + const result = subsetMatch({ pattern: 'ubiquitous' }, { pattern: 'event-driven' }); + expect(result).toBe('$.pattern: expected "ubiquitous", got "event-driven"'); + }); + + it('reports a missing key', () => { + expect(subsetMatch({ a: { b: 1 } }, { a: {} })).toBe('$.a.b: missing key in actual'); + }); + + it('compares arrays element by element in order', () => { + expect(subsetMatch(['verify'], ['verify', 'log'])).toBeNull(); + expect(subsetMatch(['verify', 'log'], ['verify'])).toBe( + '$: expected at least 2 element(s), got 1', + ); + expect(subsetMatch(['a'], ['b'])).toBe('$[0]: expected "a", got "b"'); + }); + + it('matches array elements as subsets', () => { + expect(subsetMatch([{ kind: 'term' }], [{ kind: 'term', text: 'x' }])).toBeNull(); + }); + + it('handles null leaves distinctly from objects', () => { + expect(subsetMatch(null, null)).toBeNull(); + expect(subsetMatch({ a: null }, { a: null })).toBeNull(); + expect(subsetMatch({ a: 1 }, null)).toBe('$: expected object, got null'); + }); + + it('reports a type mismatch when actual is not an object', () => { + expect(subsetMatch({ a: 1 }, 'x')).toBe('$: expected object, got string'); + }); + + it('reports a type mismatch when actual is not an array', () => { + expect(subsetMatch([1], { 0: 1 })).toBe('$: expected array, got object'); + }); +}); diff --git a/packages/core/test/fixtures.helpers.ts b/packages/core/test/fixtures.helpers.ts new file mode 100644 index 0000000..a7aef1c --- /dev/null +++ b/packages/core/test/fixtures.helpers.ts @@ -0,0 +1,156 @@ +/** + * Pure comparison helpers for the golden-fixture harness. + * + * These functions encode the matching semantics from `fixtures/schema.md`. + * They carry no I/O and no test-runner coupling so they can be unit tested + * directly (see `fixtures.helpers.test.ts`). + */ + +/** A `(code, severity)` pair, the unit of diagnostic multiset comparison. */ +export interface DiagPair { + code: string; + severity: string; +} + +/** A diagnostic entry that may pin an exact span. */ +export interface SpannedDiag extends DiagPair { + span?: { start: number; end: number }; +} + +/** Stable key for a `(code, severity)` pair. */ +export function pairKey(d: DiagPair): string { + return `${d.code}|${d.severity}`; +} + +function counts(pairs: readonly DiagPair[]): Map { + const map = new Map(); + for (const p of pairs) { + const key = pairKey(p); + map.set(key, (map.get(key) ?? 0) + 1); + } + return map; +} + +/** + * Compare two diagnostic sets as a multiset of `(code, severity)` pairs. + * + * Order is irrelevant; duplicates count. Returns the pairs that are under- or + * over-represented in `actual` relative to `expected`. An empty `missing` and + * `extra` means the multisets are equal. + */ +export function multisetDiff( + expected: readonly DiagPair[], + actual: readonly DiagPair[], +): { missing: string[]; extra: string[] } { + const ec = counts(expected); + const ac = counts(actual); + const missing: string[] = []; + const extra: string[] = []; + const keys = new Set([...ec.keys(), ...ac.keys()]); + for (const key of [...keys].sort()) { + const e = ec.get(key) ?? 0; + const a = ac.get(key) ?? 0; + if (e > a) { + missing.push(`${key} (expected ${e}, got ${a})`); + } else if (a > e) { + extra.push(`${key} (expected ${e}, got ${a})`); + } + } + return { missing, extra }; +} + +/** + * Verify per-diagnostic span assertions. + * + * Span is opt-in: only expected diagnostics that carry a `span` are checked. + * For each such entry there must be at least one actual diagnostic with the + * same `(code, severity)` and an exactly equal span. Returns a description for + * every unsatisfied span assertion; empty means all span pins are satisfied. + */ +export function spanAssertionFailures( + expected: readonly SpannedDiag[], + actual: readonly SpannedDiag[], +): string[] { + const failures: string[] = []; + for (const ed of expected) { + if (!ed.span) { + continue; + } + const found = actual.some( + (ad) => + ad.code === ed.code && + ad.severity === ed.severity && + ad.span !== undefined && + ad.span.start === ed.span!.start && + ad.span.end === ed.span!.end, + ); + if (!found) { + failures.push(`${pairKey(ed)} expected span ${JSON.stringify(ed.span)} not found in actual`); + } + } + return failures; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function typeName(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return typeof value; +} + +/** + * Recursive subset (partial deep) match, per the `expected.ast` semantics. + * + * Only keys present in `expected` are checked; extra keys in `actual` are + * ignored. Nested objects recurse by the same rule. Arrays compare element by + * element in order over the first `expected.length` elements, and `actual` + * must be at least that long. Primitive leaves compare with strict equality. + * + * Returns `null` on a match, or a `path: reason` string locating the first + * mismatch for a readable failure message. + */ +export function subsetMatch(expected: unknown, actual: unknown, path = '$'): string | null { + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return `${path}: expected array, got ${typeName(actual)}`; + } + if (actual.length < expected.length) { + return `${path}: expected at least ${expected.length} element(s), got ${actual.length}`; + } + for (let i = 0; i < expected.length; i++) { + const result = subsetMatch(expected[i], actual[i], `${path}[${i}]`); + if (result) { + return result; + } + } + return null; + } + + if (isPlainObject(expected)) { + if (!isPlainObject(actual)) { + return `${path}: expected object, got ${typeName(actual)}`; + } + for (const key of Object.keys(expected)) { + if (!(key in actual)) { + return `${path}.${key}: missing key in actual`; + } + const result = subsetMatch(expected[key], actual[key], `${path}.${key}`); + if (result) { + return result; + } + } + return null; + } + + if (expected !== actual) { + return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`; + } + return null; +} diff --git a/packages/core/test/fixtures.test.ts b/packages/core/test/fixtures.test.ts new file mode 100644 index 0000000..562888e --- /dev/null +++ b/packages/core/test/fixtures.test.ts @@ -0,0 +1,132 @@ +/** + * Golden-fixture harness for `@earsyntax/core`. + * + * Discovers every JSON fixture under `fixtures/{valid,invalid,ears-lint-go-parity}` + * at test time and runs each through `lintEars`, asserting the result against + * the fixture's `expected` block using the matching semantics defined in + * `fixtures/schema.md`. File system access lives only in this test module, + * never in `src`. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; +import { lintEars } from '../src/index.js'; +import type { + Catalog, + DialectOptions, + DiagnosticCode, + LintResult, + Options, + Pattern, + Severity, + Span, +} from '../src/types.js'; +import { multisetDiff, pairKey, spanAssertionFailures, subsetMatch } from './fixtures.helpers.js'; + +interface ExpectedDiagnostic { + code: DiagnosticCode; + severity: Severity; + span?: Span; +} + +interface Fixture { + id: string; + text: string; + options?: Options; + catalog?: Catalog; + expected: { + valid: boolean; + pattern?: Pattern; + diagnostics: ExpectedDiagnostic[]; + ast?: Record; + responses?: string[]; + }; +} + +interface LoadedFixture { + group: string; + file: string; + name: string; + fixture: Fixture; +} + +// Resolve the fixtures dir relative to the repo root from this test's location: +// packages/core/test/ -> ../../../fixtures. +const FIXTURES_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../fixtures'); +const GROUPS = ['valid', 'invalid', 'ears-lint-go-parity'] as const; + +function loadGroup(group: string): LoadedFixture[] { + const dir = join(FIXTURES_ROOT, group); + return readdirSync(dir) + .filter((file) => file.endsWith('.json')) + .sort() + .map((file) => { + const fixture = JSON.parse(readFileSync(join(dir, file), 'utf8')) as Fixture; + return { group, file, name: `${group}/${file} :: ${fixture.id}`, fixture }; + }); +} + +/** + * The dialect the Go reference (`ears-lint-go`) implements: keywords match + * case-insensitively, a leading comma is not separately required, and `shall not` + * is not treated as a prohibition. The parity corpus pins Go behavior, so it runs + * under this dialect while the strict `valid`/`invalid` corpora assert the new + * strict defaults. A parity fixture that sets its own `dialect` overrides this. + */ +const GO_PARITY_DIALECT: DialectOptions = { + keywordCase: 'case-insensitive', + commaAfterLeadingClause: 'optional', + allowProhibition: true, +}; + +/** Resolve the options a fixture runs under, injecting the parity dialect. */ +function fixtureOptions(loaded: LoadedFixture): Options | undefined { + const { group, fixture } = loaded; + if (group !== 'ears-lint-go-parity') { + return fixture.options; + } + const options: Options = { ...fixture.options }; + options.dialect = { ...GO_PARITY_DIALECT, ...fixture.options?.dialect }; + return options; +} + +function runFixture(loaded: LoadedFixture): void { + const { fixture } = loaded; + const { expected } = fixture; + const result: LintResult = lintEars(fixture.text, fixture.catalog, fixtureOptions(loaded)); + + expect(result.valid, 'valid mismatch').toBe(expected.valid); + + if (expected.pattern !== undefined) { + expect(result.pattern, 'pattern mismatch').toBe(expected.pattern); + } + + const diff = multisetDiff(expected.diagnostics, result.diagnostics); + expect( + diff.missing.length === 0 && diff.extra.length === 0, + `diagnostics multiset mismatch\n missing: ${diff.missing.join(', ') || 'none'}\n extra: ${diff.extra.join(', ') || 'none'}\n actual: ${result.diagnostics.map(pairKey).join(', ') || 'none'}`, + ).toBe(true); + + const spanFailures = spanAssertionFailures(expected.diagnostics, result.diagnostics); + expect(spanFailures, `span assertion failures: ${spanFailures.join('; ')}`).toEqual([]); + + if (expected.ast !== undefined) { + const astFailure = subsetMatch(expected.ast, result.ast, 'ast'); + expect(astFailure, `ast subset mismatch: ${astFailure ?? ''}`).toBeNull(); + } + + if (expected.responses !== undefined) { + expect(result.ast?.responses, 'responses mismatch').toEqual(expected.responses); + } +} + +for (const group of GROUPS) { + const fixtures = loadGroup(group); + describe(`fixtures: ${group} (${fixtures.length})`, () => { + test.each(fixtures.map((loaded) => [loaded.name, loaded] as const))('%s', (_name, loaded) => { + runFixture(loaded); + }); + }); +} diff --git a/packages/core/test/profiles-schema.test.ts b/packages/core/test/profiles-schema.test.ts new file mode 100644 index 0000000..79d098c --- /dev/null +++ b/packages/core/test/profiles-schema.test.ts @@ -0,0 +1,73 @@ +/** + * Golden-fixture harness for profile schema v1 validation. + * + * Discovers every JSON fixture under `fixtures/profiles/schema/{valid,invalid}` + * and runs each through `validateProfile`. Valid fixtures must validate. + * Invalid fixtures carry an `expectedErrors` array; each expected + * `{ path, code }` must appear in the reported errors (subset match). File + * system access lives only in this test module, never in `src`. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; +import { validateProfile, type ProfileValidationErrorCode } from '../src/profiles/schema.js'; + +const SCHEMA_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../fixtures/profiles/schema', +); + +interface ExpectedError { + path: string; + code: ProfileValidationErrorCode; +} + +interface InvalidFixture { + expectedErrors: ExpectedError[]; + profile: unknown; +} + +function jsonFiles(dir: string): string[] { + return readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .sort(); +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')); +} + +describe('profile schema fixtures: valid', () => { + const dir = join(SCHEMA_ROOT, 'valid'); + for (const file of jsonFiles(dir)) { + test(file, () => { + const profile = readJson(join(dir, file)); + const result = validateProfile(profile); + expect(result.ok, result.ok ? '' : JSON.stringify(result.errors)).toBe(true); + }); + } +}); + +describe('profile schema fixtures: invalid', () => { + const dir = join(SCHEMA_ROOT, 'invalid'); + for (const file of jsonFiles(dir)) { + test(file, () => { + const fixture = readJson(join(dir, file)) as InvalidFixture; + const result = validateProfile(fixture.profile); + expect(result.ok).toBe(false); + if (!result.ok) { + for (const expected of fixture.expectedErrors) { + const match = result.errors.find( + (error) => error.path === expected.path && error.code === expected.code, + ); + expect( + match, + `expected error ${expected.code} at '${expected.path}', got ${JSON.stringify(result.errors)}`, + ).toBeDefined(); + } + } + }); + } +}); diff --git a/packages/core/test/profiles-strict-earsx.test.ts b/packages/core/test/profiles-strict-earsx.test.ts new file mode 100644 index 0000000..896dc66 --- /dev/null +++ b/packages/core/test/profiles-strict-earsx.test.ts @@ -0,0 +1,174 @@ +/** + * Fixture-driven checks for the strict and ears-x profile fixtures. + * + * These drive the strict and ears-x built-in dialects through `lintEars` and + * assert the per-line expectations recorded in the sidecar JSON, plus the + * superset invariant (every strict-valid line is ears-x-valid unchanged). The + * host-native grammar rulings (Agent C4b) that these fixtures witness are now + * wired into the parser, so every line runs; the `verification` markers in the + * sidecars are retained as documentation of which ruling each line covers. + * + * File system access lives only in this test module, never in `src`. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; +import { lintEars, BUILTIN_PROFILES, type DialectOptions } from '../src/index.js'; + +const PROFILES_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../fixtures/profiles', +); + +function dialectOf(name: 'strict' | 'ears-x'): DialectOptions { + return BUILTIN_PROFILES[name].dialect; +} + +function nonEmptyLines(relPath: string): string[] { + return readFileSync(resolve(PROFILES_ROOT, relPath), 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +interface StrictInvalidExpected { + lines: { + line: number; + ruling: string; + expect?: { id: string; code: string; severity: string }[]; + }[]; +} + +interface StrictValidExpected { + findings: { ok: boolean; errors: number; warnings: number }; + lines: { line: number; pattern: string; expect: { code: string }[] }[]; +} + +interface PerProfileExpected { + lines: { + line: number; + text: string; + pattern?: string; + prohibition?: boolean; + strict: { ok: boolean; expect?: { code: string }[] }; + 'ears-x': { ok: boolean; expect?: { code: string }[]; prohibition?: boolean }; + }[]; +} + +function readText(relPath: string): string { + return readFileSync(resolve(PROFILES_ROOT, relPath), 'utf8'); +} + +describe('strict/valid.ears', () => { + const lines = nonEmptyLines('strict/valid.ears'); + const expected = JSON.parse(readText('strict/valid.expected.json')) as StrictValidExpected; + const dialect = dialectOf('strict'); + + expect(expected.findings.ok, 'sidecar findings.ok').toBe(true); + + for (const entry of expected.lines) { + const text = lines[entry.line - 1]; + const want = entry.expect.map((d) => d.code).sort(); + + test(`line ${entry.line} (${entry.pattern}) is valid under strict with no diagnostics`, () => { + const result = lintEars(text, undefined, { dialect }); + expect(result.valid, text).toBe(true); + const actual = result.diagnostics.map((d) => d.code).sort(); + expect(actual, text).toEqual(want); + }); + } +}); + +describe('strict/invalid.ears', () => { + const lines = nonEmptyLines('strict/invalid.ears'); + const expected = JSON.parse(readText('strict/invalid.expected.json')) as StrictInvalidExpected; + const dialect = dialectOf('strict'); + + for (const entry of expected.lines) { + const text = lines[entry.line - 1]; + const want = (entry.expect ?? []).map((d) => d.code).sort(); + + test(`line ${entry.line} (${entry.ruling})`, () => { + const result = lintEars(text, undefined, { dialect }); + expect(result.valid, text).toBe(false); + const actual = result.diagnostics.map((d) => d.code).sort(); + expect(actual, text).toEqual(want); + }); + } +}); + +describe('ears-x/prohibition.ears', () => { + const expected = JSON.parse(readText('ears-x/prohibition.expected.json')) as PerProfileExpected; + const strict = dialectOf('strict'); + const earsx = dialectOf('ears-x'); + + for (const entry of expected.lines) { + test(`line ${entry.line} rejected under strict, accepted as a prohibition under ears-x`, () => { + const underStrict = lintEars(entry.text, undefined, { dialect: strict }); + expect(underStrict.valid, entry.text).toBe(entry.strict.ok); + const strictCodes = underStrict.diagnostics.map((d) => d.code).sort(); + expect(strictCodes, entry.text).toEqual( + (entry.strict.expect ?? []).map((d) => d.code).sort(), + ); + + const underEarsX = lintEars(entry.text, undefined, { dialect: earsx }); + expect(underEarsX.valid, entry.text).toBe(entry['ears-x'].ok); + const earsXCodes = underEarsX.diagnostics.map((d) => d.code).sort(); + expect(earsXCodes, entry.text).toEqual( + (entry['ears-x'].expect ?? []).map((d) => d.code).sort(), + ); + expect(underEarsX.ast?.prohibition, entry.text).toBe( + entry['ears-x'].prohibition ?? entry.prohibition, + ); + if (entry.pattern) { + expect(underEarsX.pattern, entry.text).toBe(entry.pattern); + } + }); + } +}); + +describe('ears-x/frame-metadata.ears', () => { + const expected = JSON.parse( + readText('ears-x/frame-metadata.expected.json'), + ) as PerProfileExpected; + const strict = dialectOf('strict'); + const earsx = dialectOf('ears-x'); + + for (const entry of expected.lines) { + test(`line ${entry.line} frame metadata rejected under strict, parsed clean under ears-x`, () => { + const underStrict = lintEars(entry.text, undefined, { dialect: strict }); + expect(underStrict.valid, entry.text).toBe(entry.strict.ok); + const strictCodes = underStrict.diagnostics.map((d) => d.code).sort(); + expect(strictCodes, entry.text).toEqual( + (entry.strict.expect ?? []).map((d) => d.code).sort(), + ); + + const underEarsX = lintEars(entry.text, undefined, { dialect: earsx }); + expect(underEarsX.valid, entry.text).toBe(entry['ears-x'].ok); + const earsXCodes = underEarsX.diagnostics.map((d) => d.code).sort(); + expect(earsXCodes, entry.text).toEqual( + (entry['ears-x'].expect ?? []).map((d) => d.code).sort(), + ); + if (entry.pattern) { + expect(underEarsX.pattern, entry.text).toBe(entry.pattern); + } + }); + } +}); + +describe('superset invariant', () => { + test('every strict-valid line is ears-x-valid unchanged', () => { + const lines = nonEmptyLines('strict/valid.ears'); + const strict = dialectOf('strict'); + const earsx = dialectOf('ears-x'); + for (const text of lines) { + const underStrict = lintEars(text, undefined, { dialect: strict }); + const underEarsX = lintEars(text, undefined, { dialect: earsx }); + expect(underStrict.valid, text).toBe(true); + expect(underEarsX.valid, text).toBe(true); + expect(underEarsX.diagnostics, text).toHaveLength(0); + } + }); +}); diff --git a/packages/core/test/shell-parser.test.ts b/packages/core/test/shell-parser.test.ts new file mode 100644 index 0000000..191539d --- /dev/null +++ b/packages/core/test/shell-parser.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDialect, type ResolvedDialect } from '../src/options.js'; +import { parseShell, type ShellFinding } from '../src/shell-parser.js'; +import type { AndExpr, DialectOptions, DiagnosticCode, FreeTextExpr } from '../src/types.js'; + +function codes(findings: ShellFinding[]): DiagnosticCode[] { + return findings.map((f) => f.code); +} + +/** A resolved dialect built from a partial override, for readable test setup. */ +function dialect(overrides: DialectOptions): ResolvedDialect { + return resolveDialect({ dialect: overrides }); +} + +const CASE_INSENSITIVE = dialect({ keywordCase: 'case-insensitive' }); + +describe('parseShell: valid shell patterns', () => { + it('parses a ubiquitous requirement (no clauses)', () => { + const { ast, findings } = parseShell('The system shall respond.'); + expect(findings).toEqual([]); + expect(ast).toBeDefined(); + expect(ast!.pattern).toBe('ubiquitous'); + expect(ast!.system).toEqual({ raw: 'system', role: 'system' }); + expect(ast!.responses).toEqual(['respond']); + expect(ast!.preconditions).toBeUndefined(); + expect(ast!.trigger).toBeUndefined(); + expect(ast!.feature).toBeUndefined(); + expect(ast!.unwanted).toBeUndefined(); + }); + + it('parses a state-driven requirement (While)', () => { + const { ast, findings } = parseShell('While the door is open, the system shall lock.'); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('state-driven'); + expect(ast!.preconditions).toEqual({ + kind: 'free-text', + span: { start: 6, end: 22 }, + text: 'the door is open', + }); + expect(ast!.responses).toEqual(['lock']); + }); + + it('parses an event-driven requirement (When)', () => { + const { ast, findings } = parseShell('When the user logs in, the system shall greet the user.'); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('event-driven'); + expect((ast!.trigger as FreeTextExpr).text).toBe('the user logs in'); + expect(ast!.responses).toEqual(['greet the user']); + }); + + it('parses an optional-feature requirement (Where)', () => { + const { ast, findings } = parseShell( + 'Where analytics is enabled, the system shall log events.', + ); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('optional-feature'); + expect((ast!.feature as FreeTextExpr).text).toBe('analytics is enabled'); + }); + + it('parses an unwanted-behaviour requirement (If ... then)', () => { + const { ast, findings } = parseShell('If the user is unknown, then the system shall reject.'); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('unwanted-behaviour'); + expect((ast!.unwanted as FreeTextExpr).text).toBe('the user is unknown'); + expect(ast!.trigger).toBeUndefined(); + expect(ast!.responses).toEqual(['reject']); + }); + + it('keeps the response text intact and does not split on semicolons', () => { + const { ast } = parseShell('The system shall log the event; notify the operator.'); + expect(ast!.responses).toEqual(['log the event; notify the operator']); + }); +}); + +describe('parseShell: case-insensitive keywords', () => { + it('accepts upper-case and mixed-case keywords under a case-insensitive dialect', () => { + const { ast, findings } = parseShell('WHILE the door is open, THE system SHALL respond.', { + dialect: CASE_INSENSITIVE, + }); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('state-driven'); + expect(ast!.system.raw).toBe('system'); + expect(ast!.responses).toEqual(['respond']); + }); +}); + +describe('parseShell: strict keyword casing (ES-D-007)', () => { + it('flags a lower-case sentence-initial keyword', () => { + const { ast, findings } = parseShell('when the door is open, the system shall respond.'); + expect(codes(findings)).toContain('ears.keyword_case'); + expect(ast!.pattern).toBe('event-driven'); + }); + + it('flags an upper-case shall', () => { + const { findings } = parseShell('When the door is open, the system SHALL respond.'); + expect(codes(findings)).toContain('ears.keyword_case'); + }); + + it('accepts canonical casing with no finding', () => { + const { findings } = parseShell('When the door is open, the system shall respond.'); + expect(codes(findings)).not.toContain('ears.keyword_case'); + }); + + it('treats a mid-sentence clause keyword as lowercase', () => { + const { findings } = parseShell( + 'While the door is open, when the user acts, the system shall respond.', + ); + expect(codes(findings)).not.toContain('ears.keyword_case'); + }); + + it('flags a capitalized mid-sentence clause keyword', () => { + const { findings } = parseShell( + 'While the door is open, When the user acts, the system shall respond.', + ); + expect(codes(findings)).toContain('ears.keyword_case'); + }); +}); + +describe('parseShell: leading comma (ES-D-001)', () => { + it('flags a missing leading comma under a strict dialect and still recovers', () => { + const { ast, findings } = parseShell('When the timer fires the system shall reset the timer.'); + expect(codes(findings)).toContain('ears.missing_leading_comma'); + expect(ast!.pattern).toBe('event-driven'); + expect((ast!.trigger as FreeTextExpr).text).toBe('the timer fires'); + expect(ast!.system.raw).toBe('system'); + expect(ast!.responses).toEqual(['reset the timer']); + }); + + it('accepts a missing leading comma under an optional-comma dialect', () => { + const { ast, findings } = parseShell('When the timer fires the system shall reset the timer.', { + dialect: dialect({ commaAfterLeadingClause: 'optional' }), + }); + expect(codes(findings)).not.toContain('ears.missing_leading_comma'); + expect(ast!.system.raw).toBe('system'); + }); +}); + +describe('parseShell: then discriminator (ES-D-002)', () => { + it('flags a then used outside an If requirement', () => { + const { findings } = parseShell('When the timer fires, the system shall then reset the timer.'); + expect(codes(findings)).toContain('ears.invalid_if_then_form'); + }); + + it('accepts then inside a valid If ... then requirement', () => { + const { findings } = parseShell( + 'If the timer is stale, then the system shall reset the timer.', + ); + expect(codes(findings)).not.toContain('ears.invalid_if_then_form'); + }); +}); + +describe('parseShell: prohibition (ES-D-004)', () => { + it('rejects shall not under a strict dialect', () => { + const { ast, findings } = parseShell('The system shall not log the payment token.'); + expect(codes(findings)).toContain('ears.prohibition_not_allowed'); + expect(ast!.prohibition).toBeUndefined(); + }); + + it('accepts shall not and marks the AST under a prohibition dialect', () => { + const { ast, findings } = parseShell('The system shall not log the payment token.', { + dialect: dialect({ allowProhibition: true }), + }); + expect(codes(findings)).not.toContain('ears.prohibition_not_allowed'); + expect(ast!.prohibition).toBe(true); + expect(ast!.responses).toEqual(['not log the payment token']); + }); +}); + +describe('parseShell: system name (ES-D-005)', () => { + it('rejects a pronoun system reference', () => { + const { findings } = parseShell('When the timer fires, it shall reset the timer.'); + expect(codes(findings)).toContain('ears.missing_system'); + }); + + it('accepts a literal system name when the dialect allows it', () => { + const { ast, findings } = parseShell('When a webhook arrives, THE SYSTEM shall verify it.', { + dialect: dialect({ + keywordCase: 'case-insensitive', + allowLiteralSystemName: ['THE SYSTEM'], + }), + }); + expect(findings).toEqual([]); + expect(ast!.system.raw).toBe('THE SYSTEM'); + }); +}); + +describe('parseShell: frame metadata', () => { + it('accepts a REQ id prefix and a trailing source tag when the dialect allows it', () => { + const { ast, findings } = parseShell( + 'REQ-014 When the timer fires, the system shall reset the timer. [source: spec.md:12]', + { dialect: dialect({ allowFrameMetadata: true }) }, + ); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('event-driven'); + expect(ast!.system.raw).toBe('system'); + expect(ast!.responses).toEqual(['reset the timer']); + }); + + it('does not recognize a REQ id prefix under a strict dialect', () => { + const { ast, findings } = parseShell( + 'REQ-014 When the timer fires, the system shall reset the timer.', + ); + expect(ast).toBeUndefined(); + expect(codes(findings)).toContain('ears.no_match'); + }); +}); + +describe('parseShell: complex multi-clause', () => { + it('classifies a While + When requirement as complex', () => { + const { ast, findings } = parseShell( + 'While the door is open, when the user acts, the system shall respond.', + ); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('complex'); + expect((ast!.preconditions as FreeTextExpr).text).toBe('the door is open'); + expect((ast!.trigger as FreeTextExpr).text).toBe('the user acts'); + }); + + it('accepts When followed by If ... then as valid complex (no spurious order finding)', () => { + const input = + 'When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook.'; + const { ast, findings } = parseShell(input); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('complex'); + expect((ast!.trigger as FreeTextExpr).text).toBe('a payment webhook is received'); + expect((ast!.unwanted as FreeTextExpr).text).toBe('the HMAC signature is invalid'); + expect(ast!.system.raw).toBe('billing service'); + expect(ast!.responses).toEqual(['reject the webhook']); + }); + + it('flags repeated When clauses as invalid clause order', () => { + const { findings } = parseShell( + 'When the user logs in, when the user logs out, the system shall respond.', + ); + expect(codes(findings)).toContain('ears.invalid_clause_order'); + }); + + it('and-joins repeated same-kind clauses', () => { + const { ast } = parseShell( + 'While the door is open, while the light is on, the system shall respond.', + ); + expect(ast!.pattern).toBe('complex'); + const pre = ast!.preconditions as AndExpr; + expect(pre.kind).toBe('and'); + expect(pre.items).toHaveLength(2); + expect((pre.items[0] as FreeTextExpr).text).toBe('the door is open'); + expect((pre.items[1] as FreeTextExpr).text).toBe('the light is on'); + }); +}); + +describe('parseShell: commaAsAnd', () => { + const input = + 'When a payment webhook is received, the signature header is present, the billing service shall verify the HMAC signature.'; + + it('keeps a comma-joined clause body intact when commaAsAnd is true', () => { + const { ast, findings } = parseShell(input, { commaAsAnd: true }); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('event-driven'); + expect((ast!.trigger as FreeTextExpr).text).toBe( + 'a payment webhook is received, the signature header is present', + ); + expect(ast!.system.raw).toBe('billing service'); + expect(ast!.responses).toEqual(['verify the HMAC signature']); + }); + + it('splits at the first comma-then-the when commaAsAnd is false (default)', () => { + const { ast } = parseShell(input); + // Without commaAsAnd, the first "comma + the" ends the trigger clause, so + // the system tail is mis-detected. The bodies differ from the true tail. + expect((ast!.trigger as FreeTextExpr).text).toBe('a payment webhook is received'); + expect(ast!.system.raw).not.toBe('billing service'); + }); +}); + +describe('parseShell: structural findings', () => { + it('reports invalid clause order (When before While)', () => { + const { ast, findings } = parseShell( + 'When the user acts, while the door is open, the system shall respond.', + ); + expect(codes(findings)).toContain('ears.invalid_clause_order'); + expect(ast).toBeDefined(); + }); + + it('reports missing shall', () => { + const { ast, findings } = parseShell('The system responds.'); + expect(codes(findings)).toContain('ears.missing_shall'); + expect(ast).toBeUndefined(); + }); + + it('reports multiple shall but still recovers an ast', () => { + const { ast, findings } = parseShell('The system shall shall respond.'); + expect(codes(findings)).toContain('ears.multiple_shall'); + expect(ast).toBeDefined(); + }); + + it('reports missing system when the system name is empty', () => { + const { ast, findings } = parseShell('The shall respond.'); + expect(codes(findings)).toContain('ears.missing_system'); + expect(ast!.system.raw).toBe(''); + }); + + it('reports an empty response', () => { + const { ast, findings } = parseShell('The system shall .'); + expect(codes(findings)).toContain('ears.empty_response'); + expect(ast).toBeUndefined(); + }); + + it('reports an empty clause', () => { + const { ast, findings } = parseShell('When , the system shall respond.'); + expect(codes(findings)).toContain('ears.empty_clause'); + expect(ast).toBeDefined(); + }); + + it('reports an If clause with no then', () => { + const { findings } = parseShell('If the user is unknown the system shall reject.'); + expect(codes(findings)).toContain('ears.invalid_if_then_form'); + }); + + it('reports no match for empty input', () => { + const { ast, findings } = parseShell(' '); + expect(codes(findings)).toEqual(['ears.no_match']); + expect(ast).toBeUndefined(); + }); + + it('reports no match for text with no recognizable structure', () => { + const { ast, findings } = parseShell('just some prose without a shall keyword'); + expect(codes(findings)).toContain('ears.no_match'); + expect(ast).toBeUndefined(); + }); +}); + +describe('parseShell: span correctness', () => { + it('produces spans that index into the original input', () => { + const input = 'While the door is open, the system shall lock.'; + const { ast } = parseShell(input); + const span = (ast!.preconditions as FreeTextExpr).span!; + expect(input.slice(span.start, span.end)).toBe('the door is open'); + }); + + it('preserves leading-whitespace offsets in spans', () => { + const input = ' While ready, the system shall run.'; + const { ast } = parseShell(input); + const span = (ast!.preconditions as FreeTextExpr).span!; + expect(input.slice(span.start, span.end)).toBe('ready'); + expect(span.start).toBe(9); + }); + + it('spans the trigger body of a nested clause correctly', () => { + const input = 'While the door is open, when the user acts, the system shall respond.'; + const { ast } = parseShell(input); + const span = (ast!.trigger as FreeTextExpr).span!; + expect(input.slice(span.start, span.end)).toBe('the user acts'); + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..6b3a7f1 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules", "dist"], + "references": [] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..dc3bd75 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..104510b --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + globals: false, + passWithNoTests: true, + }, +}); diff --git a/packages/extract/package.json b/packages/extract/package.json new file mode 100644 index 0000000..fc062ca --- /dev/null +++ b/packages/extract/package.json @@ -0,0 +1,49 @@ +{ + "name": "@earsyntax/extract", + "version": "0.0.1-alpha.0", + "type": "module", + "license": "Apache-2.0", + "author": "Suites", + "description": "Extract EARS requirements from .ears, Markdown, YAML, and JSON files.", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "predev": "pnpm rimraf dist tsconfig.build.tsbuildinfo", + "build": "pnpm tsc -p tsconfig.build.json", + "dev": "pnpm tsc -p tsconfig.build.json --watch --incremental", + "lint": "pnpm eslint \"src/**/*.ts\"", + "lint:fix": "pnpm eslint \"src/**/*.ts\" --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@earsyntax/core": "workspace:*", + "js-yaml": "catalog:" + }, + "devDependencies": { + "@types/js-yaml": "catalog:", + "@types/node": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/extract/src/fixtures.test.ts b/packages/extract/src/fixtures.test.ts new file mode 100644 index 0000000..2799b64 --- /dev/null +++ b/packages/extract/src/fixtures.test.ts @@ -0,0 +1,40 @@ +/** + * Fixture-driven coverage for the host-native locator. + * + * Each fixture under `fixtures/pipeline/` is a representative host document; the + * sibling `*.candidates.json` file pins the candidates the profile's locator + * must produce, including their original `line` and `col`. This guards the + * position-preservation acceptance bar against regressions. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { BUILTIN_PROFILES, type ProfileName } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import { extractCandidates } from './pipeline.js'; + +const FIXTURES = join(import.meta.dirname, '..', '..', '..', 'fixtures', 'pipeline'); + +const CASES: { file: string; profile: ProfileName }[] = [ + { file: 'kiro-requirements.md', profile: 'kiro' }, + { file: 'speckit-spec.md', profile: 'speckit' }, + { file: 'openspec-spec.md', profile: 'openspec' }, + { file: 'strict-basic.ears', profile: 'strict' }, +]; + +describe('pipeline fixtures', () => { + for (const { file, profile } of CASES) { + it(`locates ${file} under the ${profile} profile`, () => { + const content = readFileSync(join(FIXTURES, file), 'utf8'); + const expected = JSON.parse( + readFileSync(join(FIXTURES, `${file.replace(/\.[^.]+$/, '')}.candidates.json`), 'utf8'), + ); + const { candidates, notices } = extractCandidates({ + files: [{ path: file, content }], + profile: BUILTIN_PROFILES[profile], + }); + expect(notices).toEqual([]); + expect(candidates).toEqual(expected); + }); + } +}); diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts new file mode 100644 index 0000000..770f26b --- /dev/null +++ b/packages/extract/src/index.ts @@ -0,0 +1,28 @@ +/** + * `@earsyntax/extract` public API surface. + * + * The host-native pipeline (locate + extract stages, composed with core's + * findings assembly) is the only extraction surface this package exposes. + * Every extractor is a pure function of its input string; nothing here touches + * the disk. + * + * This package never lints or parses EARS semantics and has no dependency on + * the core parser internals; it imports types only. + */ + +export type { ExtractError, ExtractResult } from './types.js'; + +// --- Host-native pipeline (Agent W2). Locate + extract stages plus the full +// pipeline composed with @earsyntax/core's findings assembly. --- +export { extractCandidates, runPipeline, inferKind } from './pipeline.js'; +export type { + DocumentKind, + PipelineFile, + ExtractCandidatesInput, + ExtractCandidatesResult, + RunPipelineInput, + RunPipelineResult, +} from './pipeline.js'; +// Re-exported from @earsyntax/core so callers get the candidate/notice shapes +// without a separate core import. +export type { Candidate, PipelineNotice } from '@earsyntax/core'; diff --git a/packages/extract/src/internal.ts b/packages/extract/src/internal.ts new file mode 100644 index 0000000..a27518a --- /dev/null +++ b/packages/extract/src/internal.ts @@ -0,0 +1,115 @@ +/** + * Shared helpers used across the extractors. Not part of the public API. + */ + +/** + * A declared source reference lifted from a `[source: path:line]` metadata + * segment. `line` is the start line when the segment carries a `line-line` + * range. + */ +export interface SourceRef { + /** The declared source path (for example `specs/checkout.md`). */ + file: string; + /** The declared 1-based line, or the start line of a `line-line` range. */ + line: number; +} + +/** + * A requirement text split into an optional leading id, an optional declared + * source reference, and the remaining text with the metadata prefix removed. + */ +export interface SplitId { + /** The extracted identifier, or `undefined` when no id prefix was present. */ + id: string | undefined; + /** + * The declared source reference from a `[source: path:line]` segment, or + * `undefined` when absent or malformed. + */ + ref: SourceRef | undefined; + /** The requirement text with any leading metadata prefix removed. */ + text: string; +} + +/** + * Matches a leading metadata prefix and captures the requirement text. + * + * Accepts the forms from the orchestration brief: + * + * - `REQ-001: ` + * - `REQ-001 [source: path:line]: ` + * - `REQ-001 [source: path:line-line]: ` + * + * Group 1 is the identifier, tightened to a requirement-ID shape rather than any + * word: an uppercase letter followed by uppercase letters, digits, dots, + * underscores, or hyphens, with a leading lookahead requiring at least one digit + * or hyphen somewhere in the token. This admits `REQ-001`, `US-3`, and `ABC001` + * while rejecting Title-case or all-caps prose labels: `Note:` and `Summary:` + * are not ids (lowercase letters), and `THE SYSTEM SHALL:` never reaches the + * colon as one token. Group 2 is the optional bracketed segment, captured as an + * opaque blob and validated separately by {@link SOURCE_REF}; a malformed + * bracket is still stripped from the text. Group 3 is the requirement text. + * + * Because the identifier cannot contain spaces, a normal requirement such as + * `When a payment ...` never matches: the first token `When` is followed by + * neither a bracket nor a colon. + */ +const ID_PREFIX = /^((?=[A-Z0-9._-]*[0-9-])[A-Z][A-Z0-9._-]*)(?:\s*(\[[^\]]*\]))?\s*:\s*(\S.*)$/; + +/** + * Parses the bracketed metadata blob into a {@link SourceRef}. Matches + * `[source: path:line]` and `[source: path:line-line]`. A non-matching blob is + * treated as malformed and yields no reference. + */ +const SOURCE_REF = /^\[source:\s*(.+?):(\d+)(?:-\d+)?\s*\]$/; + +/** + * Split a raw requirement string into an optional leading id, an optional + * declared source reference, and its text. + * + * When no metadata prefix is present the whole trimmed string is returned as + * `text` with no `id` and no `ref`. A malformed `[source: ...]` segment (one + * that does not parse as `path:line`) is stripped from the text; the id is + * still extracted and `ref` is `undefined`. + */ +export function splitId(raw: string): SplitId { + const trimmed = raw.trim(); + const match = ID_PREFIX.exec(trimmed); + if (!match) { + return { id: undefined, ref: undefined, text: trimmed }; + } + + const id = match[1]; + // Optional capture group: `string` per the type checker, but `undefined` at + // runtime when the bracket segment is absent. A truthy check covers both. + const bracket = match[2]; + const text = match[3].trim(); + + let ref: SourceRef | undefined; + if (bracket) { + const refMatch = SOURCE_REF.exec(bracket); + if (refMatch) { + ref = { file: refMatch[1].trim(), line: Number(refMatch[2]) }; + } + } + + return { id, ref, text }; +} + +/** + * Read the `requirements` array from a parsed structured document. + * + * Shared by the YAML and JSON extractors: both expect a top-level object/mapping + * with a `requirements` array/sequence. Returns the array when present, or + * `undefined` when the top level is not an object or `requirements` is not an + * array. This is the single definition of the accepted top-level shape. + * + * @param parsed The value returned by `JSON.parse` or `jsYaml.load`. + * @returns The requirements array, or `undefined` when the shape is wrong. + */ +export function readRequirementsArray(parsed: unknown): unknown[] | undefined { + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + const requirements = (parsed as Record).requirements; + return Array.isArray(requirements) ? requirements : undefined; +} diff --git a/packages/extract/src/json.test.ts b/packages/extract/src/json.test.ts new file mode 100644 index 0000000..578c971 --- /dev/null +++ b/packages/extract/src/json.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { extractJson } from './json.js'; + +describe('extractJson', () => { + it('extracts requirements with ids and best-effort line numbers', () => { + const content = JSON.stringify( + { + requirements: [ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + }, + ], + }, + null, + 2, + ); + + const { items, errors } = extractJson(content, 'requirements.json'); + + expect(errors).toEqual([]); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + }); + expect(items[0].source!.file).toBe('requirements.json'); + expect(items[0].source!.line).toBe(4); + expect(items[1].source!.line).toBe(8); + }); + + it('tolerates missing ids', () => { + const content = + '{ "requirements": [ { "text": "The billing service shall retain receipts." } ] }'; + + const { items, errors } = extractJson(content); + + expect(errors).toEqual([]); + expect(items).toHaveLength(1); + expect(items[0].id).toBeUndefined(); + expect(items[0].text).toBe('The billing service shall retain receipts.'); + }); + + it('reports an error for an entry missing text but keeps the others', () => { + const content = JSON.stringify({ + requirements: [ + { id: 'REQ-001' }, + { id: 'REQ-002', text: 'The billing service shall reject invalid webhooks.' }, + ], + }); + + const { items, errors } = extractJson(content, 'requirements.json'); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe('REQ-002'); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('requirements[0]'); + }); + + it('reports malformed JSON clearly', () => { + const { items, errors } = extractJson('{ "requirements": [ { "id": "REQ-001", ', 'broken.json'); + + expect(items).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Malformed JSON'); + expect(errors[0].file).toBe('broken.json'); + }); + + it('reports a clear error when the shape is wrong', () => { + const { errors } = extractJson('{ "items": [] }'); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('requirements'); + }); +}); diff --git a/packages/extract/src/json.ts b/packages/extract/src/json.ts new file mode 100644 index 0000000..8366c82 --- /dev/null +++ b/packages/extract/src/json.ts @@ -0,0 +1,84 @@ +/** + * JSON extractor. + * + * Expects an object with a `requirements` array: + * + * ```json + * { "requirements": [ { "id": "REQ-001", "text": "When ..." } ] } + * ``` + * + * Missing ids are tolerated. Malformed JSON, a missing `requirements` array, or + * a non-string `text` field are reported as {@link ExtractError}s rather than + * thrown. Source lines are a best-effort lookup of each item's id or text in + * the raw document. + */ + +import type { RequirementInput } from '@earsyntax/core'; +import type { ExtractError, ExtractResult } from './types.js'; +import { readRequirementsArray } from './internal.js'; +import { LineFinder, stripBom } from './normalize.js'; + +/** + * Extract requirements from JSON content. + * + * @param rawContent Raw file contents. + * @param file Optional source path, echoed onto each item and error. + */ +export function extractJson(rawContent: string, file?: string): ExtractResult { + const content = stripBom(rawContent); + const items: RequirementInput[] = []; + const errors: ExtractError[] = []; + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + errors.push({ + message: `Malformed JSON: ${error instanceof Error ? error.message : String(error)}`, + ...(file === undefined ? {} : { file }), + }); + return { items, errors }; + } + + const requirements = readRequirementsArray(parsed); + if (requirements === undefined) { + errors.push({ + message: 'Expected an object with a "requirements" array.', + ...(file === undefined ? {} : { file }), + }); + return { items, errors }; + } + + const finder = new LineFinder(content); + for (let index = 0; index < requirements.length; index++) { + const entry = requirements[index]; + if (typeof entry !== 'object' || entry === null) { + errors.push({ + message: `requirements[${index}] is not an object.`, + ...(file === undefined ? {} : { file }), + }); + continue; + } + const record = entry as Record; + const text = record.text; + if (typeof text !== 'string' || text.trim() === '') { + errors.push({ + message: `requirements[${index}] is missing a non-empty string "text".`, + ...(file === undefined ? {} : { file }), + }); + continue; + } + const id = typeof record.id === 'string' ? record.id : undefined; + const line = finder.locate(id) ?? finder.locate(text); + items.push({ + ...(id === undefined ? {} : { id }), + text: text.trim(), + source: { + ...(file === undefined ? {} : { file }), + ...(line === undefined ? {} : { line }), + }, + }); + } + + return { items, errors }; +} diff --git a/packages/extract/src/locator.test.ts b/packages/extract/src/locator.test.ts new file mode 100644 index 0000000..c7d9f92 --- /dev/null +++ b/packages/extract/src/locator.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for the profile-driven host-native locator, exercised through the public + * {@link extractCandidates} entry. + * + * These assert candidate positions (1-based line and col), locator-rule + * selection per profile, code-fence handling, frame-metadata gating, and + * story-wrapper skipping. Every position must point at the original document. + */ + +import { BUILTIN_PROFILES } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import { extractCandidates } from './pipeline.js'; + +const { strict, 'ears-x': earsX, kiro, speckit, openspec } = BUILTIN_PROFILES; + +function candidates(path: string, content: string, profile = strict) { + return extractCandidates({ files: [{ path, content }], profile }).candidates; +} + +describe('every-line locator (strict, ears-x)', () => { + it('emits one candidate per non-empty, non-comment line with 1-based positions', () => { + const content = ['# comment', '', 'The system shall stop.', ' The system shall wait.'].join( + '\n', + ); + const result = candidates('r.ears', content); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ line: 3, col: 1, text: 'The system shall stop.' }); + // Leading whitespace advances the column to the first text character. + expect(result[1]).toMatchObject({ line: 4, col: 3, text: 'The system shall wait.' }); + expect(result[0].locatorRuleId).toBe('strict.every-line'); + }); + + it('does not lift a REQ- prefix under strict (allowFrameMetadata false)', () => { + const result = candidates('r.ears', 'REQ-001: The system shall stop.'); + expect(result[0].text).toBe('REQ-001: The system shall stop.'); + expect(result[0].requirementId).toBeUndefined(); + }); + + it('lifts a REQ- id under ears-x but keeps the prefix in the text at col 1', () => { + // The linter strips the frame prefix at parse time under allowFrameMetadata, + // so the extractor retains the raw line and reports col 1. + const result = candidates('r.ears', 'REQ-001: The system shall stop.', earsX); + expect(result[0].requirementId).toBe('REQ-001'); + expect(result[0].text).toBe('REQ-001: The system shall stop.'); + expect(result[0].col).toBe(1); + }); +}); + +describe('markdown list-item locator (kiro)', () => { + const doc = [ + '# Feature', + '', + '**User Story:** As a user, I want checkout, so that I can pay.', + '', + '#### Acceptance Criteria', + '', + '1. WHEN a webhook arrives THE SYSTEM SHALL verify it.', + '2. THE SYSTEM SHALL retry on failure.', + '', + '## Notes', + '', + '- This bullet is outside acceptance criteria.', + ].join('\n'); + + it('captures only list items under the Acceptance Criteria heading', () => { + const result = candidates('requirements.md', doc, kiro); + expect(result.map((c) => c.text)).toEqual([ + 'WHEN a webhook arrives THE SYSTEM SHALL verify it.', + 'THE SYSTEM SHALL retry on failure.', + ]); + }); + + it('reports the ordered-item text column, past the marker', () => { + const result = candidates('requirements.md', doc, kiro); + // '1. ' is three characters, so the text starts at column 4 on line 7. + expect(result[0]).toMatchObject({ line: 7, col: 4 }); + expect(result[0].locatorRuleId).toBe('kiro.acceptance-criteria-item'); + }); + + it('captures indented continuation lines of a list item', () => { + const doc2 = [ + '#### Acceptance Criteria', + '', + '- WHEN a webhook arrives THE SYSTEM SHALL verify it', + ' and record the outcome.', + ].join('\n'); + const result = candidates('requirements.md', doc2, kiro); + expect(result).toHaveLength(1); + expect(result[0].text).toBe( + 'WHEN a webhook arrives THE SYSTEM SHALL verify it and record the outcome.', + ); + }); +}); + +describe('markdown heading-section locator (speckit)', () => { + it('captures body lines under Requirements and excludes Design prose', () => { + const doc = [ + '## Requirements', + '', + 'The service shall verify the signature.', + '', + '## Design', + '', + 'When the cache warms, the design note explains the flow.', + ].join('\n'); + const result = candidates('spec.md', doc, speckit); + expect(result.map((c) => c.text)).toEqual(['The service shall verify the signature.']); + expect(result[0].locatorRuleId).toBe('speckit.requirements-section'); + }); + + it('strips a bold FR label, lifts requirementId, and reports col at the sentence', () => { + const doc = [ + '## Requirements', + '', + '- **FR-001**: The system shall invite a teammate by email address.', + ].join('\n'); + const result = candidates('spec.md', doc, speckit); + expect(result[0]).toMatchObject({ + line: 3, + col: 15, + text: 'The system shall invite a teammate by email address.', + requirementId: 'FR-001', + }); + }); +}); + +describe('markdown block locator (openspec)', () => { + it('captures scenario body lines until the next same-or-higher heading', () => { + const doc = [ + '### Requirement: Signature check', + '', + 'The service shall verify the signature.', + '', + '#### Scenario: invalid signature', + '', + 'If the signature is invalid, then the service shall reject the webhook.', + '', + '### Requirement: Next', + '', + 'The service shall log every attempt.', + ].join('\n'); + const result = candidates('spec.md', doc, openspec); + expect(result.map((c) => c.text)).toEqual([ + 'The service shall verify the signature.', + 'If the signature is invalid, then the service shall reject the webhook.', + 'The service shall log every attempt.', + ]); + }); + + it('captures only the first EARS-shaped line per block and skips Gherkin steps', () => { + const doc = [ + '### Requirement: Retention window', + '', + 'The system shall retain build artifacts for the configured retention window.', + '', + '#### Scenario: Artifact within the window', + '', + '- **WHEN** an artifact is younger than the retention window', + '- **THEN** the system retains the artifact and its metadata', + ].join('\n'); + const result = candidates('spec.md', doc, openspec); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + line: 3, + text: 'The system shall retain build artifacts for the configured retention window.', + locatorRuleId: 'openspec.requirement', + }); + }); +}); + +describe('code fences', () => { + it('ignores fenced content and tracks marker length so a shorter run does not close', () => { + const doc = [ + '#### Acceptance Criteria', + '', + '````', + '``` not a closing fence, run is shorter', + '- THE SYSTEM SHALL not be captured here.', + '````', + '', + '- THE SYSTEM SHALL be captured here.', + ].join('\n'); + const result = candidates('requirements.md', doc, kiro); + expect(result.map((c) => c.text)).toEqual(['THE SYSTEM SHALL be captured here.']); + }); +}); + +describe('document-kind gating', () => { + it('produces no candidates when the profile does not locate over the kind', () => { + // strict locates over ears/text, not markdown. + const result = candidates('spec.md', '- The system shall stop.', strict); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/extract/src/locator.ts b/packages/extract/src/locator.ts new file mode 100644 index 0000000..d815cae --- /dev/null +++ b/packages/extract/src/locator.ts @@ -0,0 +1,531 @@ +/** + * Profile-driven host-native locator for `@earsyntax/extract`. Not part of the + * public API surface directly; consumed by `./pipeline.ts`. + * + * Given a document's content, its kind, and the active profile, this module + * produces the requirement {@link Candidate}s the profile's locator selects, + * each carrying its original 1-based `line` and `col` in the source document. + * + * Three text-family kinds are handled here: + * + * - `ears` and `text`: the trivial every-line rule. Every non-empty, non-comment + * line is a candidate. + * - `markdown`: the `heading-section`, `list-item`, and `block` locator rules, + * with fenced code honored per `locator.codeFences` and non-requirement + * sections removed by the profile's `exclude` rules. + * + * Structured kinds (`yaml`, `json`) are located by `./pipeline.ts` from parsed + * data, not here. User-story wrapper lines are skipped when the dialect sets + * `allowStoryWrapper`; frame-metadata prefixes (`REQ-001:`, `[source: ...]`) are + * lifted only when the dialect sets `allowFrameMetadata`. + * + * Determinism: pure string processing. No clock, file system, or network. + */ + +import { + isStoryWrapperLine, + type Candidate, + type LocatorRule, + type Profile, +} from '@earsyntax/core'; +import { splitId } from './internal.js'; +import { classifyFences } from './markdown-scan.js'; + +/** The text-family document kinds this locator handles. */ +export type TextKind = 'ears' | 'text' | 'markdown'; + +const BULLET = /^(\s*)([-*+])(\s+)(.+)$/; +const NUMBERED = /^(\s*)(\d+)([.)])(\s+)(.+)$/; +const HEADING = /^(#{1,6})\s+(.*\S)\s*$/; + +/** + * Locate requirement candidates in a text-family document under a profile. + * + * @param content Raw (already BOM-stripped) document content. + * @param kind The document kind (`ears`, `text`, or `markdown`). + * @param profile The active profile. + * @param file The source file path recorded on each candidate. + * @returns The located candidates, in document order. + */ +export function locateTextFamily( + content: string, + kind: TextKind, + profile: Profile, + file: string, +): Candidate[] { + const lines = content.split('\n'); + if (kind === 'markdown') { + return locateMarkdown(lines, profile, file); + } + return locateEveryLine(lines, profile, file); +} + +/** + * Every-line locator for `.ears` and plain text: one candidate per non-empty, + * non-comment line. Lines whose first non-whitespace character is `#` are + * comments and are skipped. + */ +function locateEveryLine(lines: string[], profile: Profile, file: string): Candidate[] { + const ruleId = profile.locator.include[0]?.id ?? `${profile.name}.every-line`; + const allowFrameMetadata = profile.dialect.allowFrameMetadata; + const allowStoryWrapper = profile.dialect.allowStoryWrapper; + const candidates: Candidate[] = []; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + continue; + } + const candidate = buildCandidate(raw, 0, i + 1, ruleId, profile, file, allowFrameMetadata); + if (candidate === undefined) { + continue; + } + if (allowStoryWrapper && isStoryWrapperLine(candidate.text)) { + continue; + } + candidates.push(candidate); + } + return candidates; +} + +interface MarkdownModel { + lines: string[]; + fenceStates: string[]; + /** Per-line ancestor heading texts (trimmed), outermost first. */ + headingStacks: string[][]; + /** Per-line heading level, or 0 when the line is not a heading. */ + headingLevels: number[]; +} + +/** Build the per-line heading context and fence classification once. */ +function buildMarkdownModel(lines: string[]): MarkdownModel { + const fenceStates = classifyFences(lines); + const headingStacks: string[][] = []; + const headingLevels: number[] = []; + const stack: { level: number; text: string }[] = []; + + for (let i = 0; i < lines.length; i++) { + const isHeading = fenceStates[i] === 'outside' ? HEADING.exec(lines[i]) : null; + if (isHeading) { + const level = isHeading[1].length; + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + // The heading line's own context is its ancestors, before it is pushed. + headingStacks.push(stack.map((h) => h.text)); + headingLevels.push(level); + stack.push({ level, text: isHeading[2].trim() }); + } else { + headingStacks.push(stack.map((h) => h.text)); + headingLevels.push(0); + } + } + return { lines, fenceStates, headingStacks, headingLevels }; +} + +/** Markdown locator: run each include rule, then remove excluded regions. */ +function locateMarkdown(lines: string[], profile: Profile, file: string): Candidate[] { + const model = buildMarkdownModel(lines); + const includeCode = profile.locator.codeFences === 'include'; + const excluded = excludedLineSet(model, profile); + + const byLine = new Map(); + for (const rule of profile.locator.include) { + for (const candidate of applyIncludeRule(rule, model, profile, file, includeCode)) { + const key = candidate.line; + if (excluded.has(key) || byLine.has(key)) { + continue; + } + byLine.set(key, candidate); + } + } + + return [...byLine.values()].sort((a, b) => a.line - b.line); +} + +/** The union of line indices (0-based) removed by the profile's exclude rules. */ +function excludedLineSet(model: MarkdownModel, profile: Profile): Set { + const excluded = new Set(); + for (const rule of profile.locator.exclude) { + if (rule.kind === 'heading-section' && rule.headingPattern !== undefined) { + const re = compile(rule.headingPattern); + for (let i = 0; i < model.lines.length; i++) { + if (model.headingStacks[i].some((text) => re.test(text))) { + excluded.add(i + 1); + } + } + } + } + return excluded; +} + +/** Run one include rule, yielding candidates (keyed later by line). */ +function applyIncludeRule( + rule: LocatorRule, + model: MarkdownModel, + profile: Profile, + file: string, + includeCode: boolean, +): Candidate[] { + switch (rule.kind) { + case 'heading-section': + return headingSectionCandidates(rule, model, profile, file, includeCode); + case 'list-item': + return listItemCandidates(rule, model, profile, file, includeCode); + case 'block': + return blockCandidates(rule, model, profile, file, includeCode); + case 'every-line': + // An every-line rule inside a markdown profile: treat each eligible line as + // a candidate. Not used by the built-ins but supported for completeness. + return bodyLineCandidates(model, profile, file, includeCode, rule.id, () => true); + default: + return []; + } +} + +/** Candidates from body lines under headings matching `headingPattern`. */ +function headingSectionCandidates( + rule: LocatorRule, + model: MarkdownModel, + profile: Profile, + file: string, + includeCode: boolean, +): Candidate[] { + if (rule.headingPattern === undefined) { + return []; + } + const re = compile(rule.headingPattern); + return bodyLineCandidates( + model, + profile, + file, + includeCode, + rule.id, + (i) => model.headingLevels[i] === 0 && model.headingStacks[i].some((text) => re.test(text)), + ); +} + +/** + * Candidates from `blockPrefix` blocks. + * + * A block opens at a line whose trimmed form starts with `blockPrefix` and runs + * until the next heading of equal or higher level. Per the openspec profile + * decision (`fixtures/profiles/openspec/NOTES.md`), a block contributes exactly + * one candidate: the FIRST EARS-shaped body line under the block heading. Gherkin + * scenario steps (`- **WHEN**`, `- **THEN**`, `- **AND**`) are not EARS-shaped, + * so a standard scenario block yields no candidate; a `### Requirement:` block + * yields its single statement line and never the nested scenario content. + */ +function blockCandidates( + rule: LocatorRule, + model: MarkdownModel, + profile: Profile, + file: string, + includeCode: boolean, +): Candidate[] { + if (rule.blockPrefix === undefined) { + return []; + } + const prefix = rule.blockPrefix.trim(); + const prefixLevel = leadingHashes(prefix); + const allowFrameMetadata = profile.dialect.allowFrameMetadata; + const candidates: Candidate[] = []; + + let inside = false; + let claimed = false; // whether this block already contributed its one candidate + for (let i = 0; i < model.lines.length; i++) { + if (model.fenceStates[i] === 'outside' && model.lines[i].trim().startsWith(prefix)) { + inside = true; + claimed = false; + continue; // the prefix heading line itself is not a candidate + } + if (inside && model.headingLevels[i] > 0 && model.headingLevels[i] <= prefixLevel) { + inside = false; + } + if (!inside || claimed || !lineEligible(model, i, includeCode)) { + continue; + } + const contentStart = markerContentStart(model.lines[i]); + if (!isEarsShaped(model.lines[i].slice(contentStart))) { + continue; + } + const candidate = buildCandidate( + model.lines[i], + contentStart, + i + 1, + rule.id, + profile, + file, + allowFrameMetadata, + ); + claimed = true; + if (candidate !== undefined) { + candidates.push(candidate); + } + } + return candidates; +} + +/** The 0-based index where a line's content begins, past any list marker. */ +function markerContentStart(raw: string): number { + const marker = matchListItem(raw, 'any'); + return marker === undefined ? 0 : marker.contentStart; +} + +/** + * Whether a line (with any list marker already removed) reads as an EARS + * statement: a leading `When`/`While`/`Where`/`If` clause keyword, or the + * ubiquitous `The shall ...` form. A bold Gherkin keyword such as + * `**WHEN**` is not matched, because the leading `**` is not a clause keyword. + */ +function isEarsShaped(content: string): boolean { + const text = content.trim(); + return /^(when|while|where|if)\b/i.test(text) || /^the\b[\s\S]*\bshall\b/i.test(text); +} + +/** Candidates from list items, optionally under a heading and filtered by marker. */ +function listItemCandidates( + rule: LocatorRule, + model: MarkdownModel, + profile: Profile, + file: string, + includeCode: boolean, +): Candidate[] { + const underRe = rule.underHeading === undefined ? undefined : compile(rule.underHeading); + const marker = rule.listMarker ?? 'any'; + const allowFrameMetadata = profile.dialect.allowFrameMetadata; + const allowStoryWrapper = profile.dialect.allowStoryWrapper; + const candidates: Candidate[] = []; + + let i = 0; + while (i < model.lines.length) { + if (!lineEligible(model, i, includeCode)) { + i++; + continue; + } + const item = matchListItem(model.lines[i], marker); + if (item === undefined) { + i++; + continue; + } + if (underRe !== undefined && !model.headingStacks[i].some((text) => underRe.test(text))) { + i++; + continue; + } + + const gathered = gatherContinuations(model, i, item.markerIndent, includeCode); + const candidate = buildCandidate( + model.lines[i], + item.contentStart, + i + 1, + rule.id, + profile, + file, + allowFrameMetadata, + gathered.extra, + ); + i = gathered.next; + if (candidate === undefined) { + continue; + } + if (allowStoryWrapper && isStoryWrapperLine(candidate.text)) { + continue; + } + candidates.push(candidate); + } + return candidates; +} + +/** + * Shared body-line candidate builder: emit one candidate per eligible line that + * `predicate` selects, stripping a leading list marker when present. + */ +function bodyLineCandidates( + model: MarkdownModel, + profile: Profile, + file: string, + includeCode: boolean, + ruleId: string, + predicate: (lineIndex: number) => boolean, +): Candidate[] { + const allowFrameMetadata = profile.dialect.allowFrameMetadata; + const allowStoryWrapper = profile.dialect.allowStoryWrapper; + const candidates: Candidate[] = []; + + for (let i = 0; i < model.lines.length; i++) { + if (!lineEligible(model, i, includeCode) || !predicate(i)) { + continue; + } + const marker = matchListItem(model.lines[i], 'any'); + const contentStart = marker === undefined ? 0 : marker.contentStart; + const candidate = buildCandidate( + model.lines[i], + contentStart, + i + 1, + ruleId, + profile, + file, + allowFrameMetadata, + ); + if (candidate === undefined) { + continue; + } + if (allowStoryWrapper && isStoryWrapperLine(candidate.text)) { + continue; + } + candidates.push(candidate); + } + return candidates; +} + +/** Whether a markdown line can hold a candidate (not blank, not a heading, fence-aware). */ +function lineEligible(model: MarkdownModel, i: number, includeCode: boolean): boolean { + const state = model.fenceStates[i]; + if (state === 'open' || state === 'close') { + return false; + } + if (state === 'inside' && !includeCode) { + return false; + } + if (model.headingLevels[i] > 0) { + return false; + } + return model.lines[i].trim() !== ''; +} + +interface ListItemMatch { + markerIndent: number; + contentStart: number; +} + +/** Match a bullet or numbered list item, honoring the marker filter. */ +function matchListItem( + raw: string, + marker: 'bullet' | 'ordered' | 'any', +): ListItemMatch | undefined { + if (marker !== 'ordered') { + const bullet = BULLET.exec(raw); + if (bullet) { + return { markerIndent: bullet[1].length, contentStart: raw.length - bullet[4].length }; + } + } + if (marker !== 'bullet') { + const numbered = NUMBERED.exec(raw); + if (numbered) { + return { markerIndent: numbered[1].length, contentStart: raw.length - numbered[5].length }; + } + } + return undefined; +} + +/** Gather indented continuation lines that belong to a list item. */ +function gatherContinuations( + model: MarkdownModel, + start: number, + markerIndent: number, + includeCode: boolean, +): { extra: string[]; next: number } { + const extra: string[] = []; + let j = start + 1; + while (j < model.lines.length) { + const line = model.lines[j]; + if (line.trim() === '' || !lineEligible(model, j, includeCode)) { + break; + } + const indent = line.length - line.trimStart().length; + if (indent <= markerIndent || matchListItem(line, 'any') !== undefined) { + break; + } + extra.push(line.trim()); + j++; + } + return { extra, next: j }; +} + +/** + * A bold markdown requirement label, for example `**FR-001**:` or `**REQ-12**`. + * The id shape requires an uppercase letter and at least one digit or hyphen, so + * `**Note**:` is not a label. A trailing colon and surrounding whitespace are + * consumed so the retained text starts at the requirement sentence. + */ +const BOLD_ID_LABEL = /^\*\*\s*((?=[A-Z0-9._-]*[0-9-])[A-Z][A-Z0-9._-]*)\s*\*\*\s*:?\s*/; + +/** + * Build one {@link Candidate}, computing the 1-based column of the text's first + * character in the raw line. + * + * Two id conventions are reconciled here (see the profile fixtures): + * + * - A markdown bold requirement label (`**FR-001**:`) is a host formatting + * device, not part of the EARS sentence: it is stripped, the column advances + * past it, and its id becomes `requirementId`. + * - An ears-x frame prefix (`REQ-001:` / `[source: ...]`) is retained in the + * text and the column stays at the line start; only `requirementId` is lifted. + * The linter strips the frame prefix at parse time under `allowFrameMetadata`, + * so the extractor must not move the reported position. + * + * Returns `undefined` when the resulting text is empty (nothing to lint). + */ +function buildCandidate( + raw: string, + contentStart: number, + line: number, + locatorRuleId: string, + profile: Profile, + file: string, + allowFrameMetadata: boolean, + continuation: string[] = [], +): Candidate | undefined { + const region = raw.slice(contentStart); + const leadingWs = region.length - region.trimStart().length; + let textStart = contentStart + leadingWs; + let text = region.trim(); + let requirementId: string | undefined; + + const boldLabel = BOLD_ID_LABEL.exec(text); + if (boldLabel) { + requirementId = boldLabel[1]; + textStart += boldLabel[0].length; + text = text.slice(boldLabel[0].length); + } else if (allowFrameMetadata) { + // Capture the frame id without removing it from the text; the linter strips + // the prefix during parsing, and the reported position stays at the text. + const split = splitId(text); + if (split.id !== undefined) { + requirementId = split.id; + } + } + + if (continuation.length > 0) { + text = [text, ...continuation].join(' ').trim(); + } + if (text === '') { + return undefined; + } + + const candidate: Candidate = { + file, + line, + col: textStart + 1, + text, + profile: profile.name, + locatorRuleId, + ...(requirementId === undefined ? {} : { requirementId }), + }; + return candidate; +} + +/** Count leading `#` characters on a trimmed heading-like prefix. */ +function leadingHashes(prefix: string): number { + let n = 0; + while (n < prefix.length && prefix[n] === '#') { + n++; + } + return n; +} + +/** Compile a locator regex source, case-insensitive over trimmed heading text. */ +function compile(source: string): RegExp { + return new RegExp(source, 'i'); +} diff --git a/packages/extract/src/markdown-scan.test.ts b/packages/extract/src/markdown-scan.test.ts new file mode 100644 index 0000000..1462ed6 --- /dev/null +++ b/packages/extract/src/markdown-scan.test.ts @@ -0,0 +1,25 @@ +/** + * Tests for the shared Markdown scanning primitive: fence tracking by marker + * character and length. These lock the Codex-review fixes in place. + */ + +import { describe, expect, it } from 'vitest'; +import { classifyFences, FenceTracker } from './markdown-scan.js'; + +describe('FenceTracker', () => { + it('closes a backtick fence only on a matching-or-longer backtick run', () => { + const tracker = new FenceTracker(); + expect(tracker.feed('````')).toBe('open'); + // A shorter run does not close a longer fence: still inside, not closed. + expect(tracker.feed('```')).toBe('inside'); + expect(tracker.feed('text')).toBe('inside'); + expect(tracker.feed('````')).toBe('close'); + // Once closed, an ordinary line is no longer inside the fence. + expect(tracker.feed('text')).toBe('outside'); + }); + + it('does not close a tilde fence with a backtick run', () => { + const states = classifyFences(['~~~', 'code line', '```', 'still code', '~~~', 'outside']); + expect(states).toEqual(['open', 'inside', 'inside', 'inside', 'close', 'outside']); + }); +}); diff --git a/packages/extract/src/markdown-scan.ts b/packages/extract/src/markdown-scan.ts new file mode 100644 index 0000000..d15bcfe --- /dev/null +++ b/packages/extract/src/markdown-scan.ts @@ -0,0 +1,78 @@ +/** + * Shared Markdown scanning primitives for `@earsyntax/extract`. Not part of the + * public API. + * + * {@link FenceTracker} is a CommonMark-aware fenced-code-block tracker used by + * the profile-driven locator to keep fenced-code handling consistent. It + * records the opening marker's character and length and closes only on a + * matching-or-longer run of the same character with no info string. A `~~~` + * fence is never closed by a ``` line, and a ` ``` ` fence is never closed by + * a shorter run. + */ + +/** Where a line sits relative to fenced code blocks. */ +export type FenceState = 'outside' | 'open' | 'inside' | 'close'; + +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/; + +/** + * Tracks fenced code blocks across a document, one line at a time. + * + * Feed every line in order. The tracker is stateful and must see lines in + * document order. `outside` lines are ordinary content; `open` and `close` lines + * are the fence delimiters themselves; `inside` lines are fenced code. + */ +export class FenceTracker { + private open: { char: string; len: number } | null = null; + + /** + * Classify the next line and advance the fence state. + * + * @param line The raw line (without its trailing newline). + * @returns The line's position relative to fenced code. + */ + feed(line: string): FenceState { + if (this.open === null) { + const match = FENCE_OPEN.exec(line); + if (match) { + const marker = match[1]; + const char = marker.startsWith('`') ? '`' : '~'; + // A backtick info string must not contain a backtick; a run that does is + // not a valid opening fence, so treat it as ordinary content. + if (char === '`' && match[2].includes('`')) { + return 'outside'; + } + this.open = { char, len: marker.length }; + return 'open'; + } + return 'outside'; + } + + // Inside a fence: only a bare run of the same character, at least as long as + // the opener and carrying no info string, closes it. + const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line); + if (close) { + const closeChar = close[1].startsWith('`') ? '`' : '~'; + if (closeChar === this.open.char && close[1].length >= this.open.len) { + this.open = null; + return 'close'; + } + } + return 'inside'; + } +} + +/** + * Classify every line's fence position in a single forward pass. + * + * Returns one {@link FenceState} per input line so callers can read a line's + * fence position by index without re-feeding a stateful tracker (which would + * corrupt the state on any line inspected more than once). + * + * @param lines The document split into lines, in order. + * @returns A parallel array of fence states, one per line. + */ +export function classifyFences(lines: readonly string[]): FenceState[] { + const tracker = new FenceTracker(); + return lines.map((line) => tracker.feed(line)); +} diff --git a/packages/extract/src/normalize.ts b/packages/extract/src/normalize.ts new file mode 100644 index 0000000..34ad0c0 --- /dev/null +++ b/packages/extract/src/normalize.ts @@ -0,0 +1,98 @@ +/** + * Shared document normalization for `@earsyntax/extract`. Not part of the public + * API. + * + * Two concerns live here: + * + * 1. A single BOM-stripping step so every format extractor (`.ears`, Markdown, + * YAML, JSON) sees the same normalized content. Only one leading U+FEFF is + * removed; interior BOMs are left untouched. + * 2. A monotonic {@link LineFinder} that maps a requirement's id or text back to + * a 1-based source line for structured formats (YAML, JSON) whose parsers do + * not preserve positions. It scans the document once, in document order, from + * a cursor that only moves forward, so repeated ids or texts resolve to + * successive occurrences instead of always the first, and the whole file is + * processed in O(n). + */ + +/** The Unicode byte-order mark. */ +const BOM = ''; + +/** + * Strip a single leading byte-order mark, if present. + * + * @param content Raw file contents. + * @returns The content without a leading BOM. + */ +export function stripBom(content: string): string { + return content.startsWith(BOM) ? content.slice(1) : content; +} + +/** + * A forward-only locator that resolves a needle (an item's id or text) to its + * 1-based source line. + * + * The finder precomputes each line's start offset once, then remembers the + * offset just past the last match. Each {@link LineFinder.locate} call searches + * from that cursor forward, so structured items resolved in document order never + * collide on an earlier duplicate occurrence. When a needle is not found from + * the cursor the finder falls back to a search from the document start, so an + * out-of-order lookup still resolves rather than returning nothing. + */ +export class LineFinder { + private readonly content: string; + private readonly lineStarts: number[]; + private cursor = 0; + + /** + * @param content The raw (BOM-stripped) document the needles come from. + */ + constructor(content: string) { + this.content = content; + this.lineStarts = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === '\n') { + this.lineStarts.push(i + 1); + } + } + } + + /** + * Resolve `needle` to its 1-based line, searching forward from the cursor. + * + * @param needle The id or text to locate, or `undefined`. + * @returns The 1-based line, or `undefined` when the needle is absent/empty. + */ + locate(needle: string | undefined): number | undefined { + if (!needle) { + return undefined; + } + let index = this.content.indexOf(needle, this.cursor); + if (index === -1) { + // Out-of-order lookup: fall back to a full scan without moving the cursor + // backward, so a later needle is never masked by this miss. + index = this.content.indexOf(needle); + if (index === -1) { + return undefined; + } + return this.lineForOffset(index); + } + this.cursor = index + needle.length; + return this.lineForOffset(index); + } + + /** Map a 0-based offset to its 1-based line via binary search over line starts. */ + private lineForOffset(offset: number): number { + let lo = 0; + let hi = this.lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.lineStarts[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; + } +} diff --git a/packages/extract/src/pipeline.test.ts b/packages/extract/src/pipeline.test.ts new file mode 100644 index 0000000..1fc1bea --- /dev/null +++ b/packages/extract/src/pipeline.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for the host-native pipeline entry: {@link runPipeline}, + * {@link extractCandidates}, kind inference, and the notice channel for + * malformed structured input. + */ + +import { BUILTIN_PROFILES } from '@earsyntax/core'; +import { describe, expect, it } from 'vitest'; +import { extractCandidates, inferKind, runPipeline } from './pipeline.js'; + +const strict = BUILTIN_PROFILES.strict; + +describe('runPipeline', () => { + it('validates a clean requirement from stdin content (path "-")', () => { + const { findings, notices } = runPipeline({ + files: [ + { + path: '-', + content: + 'When a payment webhook arrives, the billing service shall verify the signature.', + }, + ], + profile: strict, + }); + expect(notices).toHaveLength(0); + expect(findings.ok).toBe(true); + expect(findings.summary).toMatchObject({ files: 1, requirements: 1, valid: 1, errors: 0 }); + }); + + it('returns an error finding for a malformed requirement', () => { + const { findings } = runPipeline({ + files: [{ path: 'r.ears', content: 'This is not a requirement at all.' }], + profile: strict, + }); + expect(findings.ok).toBe(false); + expect(findings.diagnostics[0]).toMatchObject({ id: 'EARS-E010', file: 'r.ears', line: 1 }); + }); + + it('counts a file with no candidates and never throws on malformed YAML', () => { + const { findings, notices } = runPipeline({ + files: [{ path: 'bad.yaml', content: 'requirements:\n - text: "unterminated' }], + profile: strict, + }); + expect(findings.summary.files).toBe(1); + expect(findings.summary.requirements).toBe(0); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ + code: 'extract.malformed_yaml', + severity: 'error', + file: 'bad.yaml', + }); + }); +}); + +describe('extractCandidates', () => { + it('extracts structured YAML requirements with a synthetic locator rule id', () => { + const content = ['requirements:', ' - id: REQ-001', ' text: The system shall stop.'].join( + '\n', + ); + const { candidates } = extractCandidates({ + files: [{ path: 'r.yaml', content }], + profile: strict, + }); + expect(candidates).toHaveLength(1); + expect(candidates[0]).toMatchObject({ + file: 'r.yaml', + requirementId: 'REQ-001', + text: 'The system shall stop.', + locatorRuleId: 'structured.yaml', + profile: 'strict', + }); + expect(candidates[0].line).toBeGreaterThan(0); + }); + + it('strips a leading BOM before parsing JSON', () => { + const json = '{"requirements":[{"id":"REQ-001","text":"The system shall stop."}]}'; + const { candidates, notices } = extractCandidates({ + files: [{ path: 'r.json', content: json }], + profile: strict, + }); + expect(notices).toHaveLength(0); + expect(candidates).toHaveLength(1); + expect(candidates[0].requirementId).toBe('REQ-001'); + }); + + it('flattens candidates across multiple files in order', () => { + const { candidates } = extractCandidates({ + files: [ + { path: 'a.ears', content: 'The system shall stop.' }, + { path: 'b.ears', content: 'The system shall wait.' }, + ], + profile: strict, + }); + expect(candidates.map((c) => c.file)).toEqual(['a.ears', 'b.ears']); + }); +}); + +describe('inferKind', () => { + it('maps extensions to kinds and defaults to text', () => { + expect(inferKind('r.ears')).toBe('ears'); + expect(inferKind('r.md')).toBe('markdown'); + expect(inferKind('r.markdown')).toBe('markdown'); + expect(inferKind('r.yaml')).toBe('yaml'); + expect(inferKind('r.yml')).toBe('yaml'); + expect(inferKind('r.json')).toBe('json'); + expect(inferKind('-')).toBe('text'); + expect(inferKind('notes.txt')).toBe('text'); + }); +}); diff --git a/packages/extract/src/pipeline.ts b/packages/extract/src/pipeline.ts new file mode 100644 index 0000000..d5b484b --- /dev/null +++ b/packages/extract/src/pipeline.ts @@ -0,0 +1,230 @@ +/** + * Host-native pipeline entry for `@earsyntax/extract`. + * + * This is the locate + extract half of the pipeline (stages 1 and 2), plus a + * thin composition with the core findings-assembly half: + * + * - {@link extractCandidates} locates requirement {@link Candidate}s across a set + * of in-memory files under the active profile. This is the data the `extract` + * command prints. + * - {@link runPipeline} runs the whole pipeline: locate + extract here, then + * parse + lint + findings via `@earsyntax/core`'s {@link candidatesToFindings}. + * This is what the `validate` command runs. + * + * Text-family kinds (`ears`, `text`, `markdown`) are located by `./locator.ts` + * under the profile's locator rules and are gated by `locator.documentKinds`: a + * file whose kind the profile does not locate over yields no candidates. + * Structured kinds (`yaml`, `json`) are extracted from parsed data and are + * profile-agnostic (no built-in profile declares them in `documentKinds`), so + * they always produce candidates; the active dialect still applies when their + * text is linted. Structured candidates carry a synthetic `locatorRuleId` + * (`structured.yaml` / `structured.json`) because no `LocatorRule` selected them. + * + * Nothing here throws on malformed input: a bad structured document or an + * unsupported kind produces zero candidates plus a {@link PipelineNotice}. + * Notices are the environment/usage channel, never lint findings. + * + * Determinism: pure over the provided content strings. Only the caller (the CLI) + * reads files; this module never touches the disk. + */ + +import { + candidatesToFindings, + type Candidate, + type CandidateFile, + type Catalog, + type Findings, + type PipelineNotice, + type Profile, +} from '@earsyntax/core'; +import { extractJson } from './json.js'; +import { locateTextFamily } from './locator.js'; +import { stripBom } from './normalize.js'; +import { extractYaml } from './yaml.js'; +import type { ExtractError, ExtractResult } from './types.js'; + +/** The document kinds the pipeline understands. */ +export type DocumentKind = 'ears' | 'text' | 'markdown' | 'yaml' | 'json'; + +/** One in-memory file fed to the pipeline. */ +export interface PipelineFile { + /** The file path, recorded on every candidate and notice. Use `-` for stdin. */ + path: string; + /** The file's raw content. */ + content: string; + /** The document kind; inferred from the path extension when omitted. */ + kind?: DocumentKind; +} + +/** Input to {@link extractCandidates}. */ +export interface ExtractCandidatesInput { + /** The files to locate candidates in. */ + files: readonly PipelineFile[]; + /** The active profile. */ + profile: Profile; +} + +/** Result of {@link extractCandidates}. */ +export interface ExtractCandidatesResult { + /** Every located candidate, across all files, in file-then-document order. */ + candidates: Candidate[]; + /** Recoverable locate/extract problems. Empty on a clean run. */ + notices: PipelineNotice[]; +} + +/** Input to {@link runPipeline}. */ +export interface RunPipelineInput extends ExtractCandidatesInput { + /** Upgrade surviving warnings to errors at the findings layer. */ + strict?: boolean; + /** Optional catalog of known domain terms, passed to the linter. */ + catalog?: Catalog; +} + +/** + * Result of {@link runPipeline}. + * + * `findings` is the frozen Findings model. `notices` carries locate/extract + * problems (malformed structured input, unsupported kinds) that are not lint + * findings; the CLI maps them onto the facade-level `diagnostics` channel. + */ +export interface RunPipelineResult { + /** The canonical Findings result. */ + findings: Findings; + /** Recoverable locate/extract problems. Empty on a clean run. */ + notices: PipelineNotice[]; +} + +const TEXT_KINDS = new Set(['ears', 'text', 'markdown']); + +/** + * Locate requirement candidates across a set of files under a profile. + * + * @param input The files and the active profile. + * @returns The candidates and any recoverable notices. + */ +export function extractCandidates(input: ExtractCandidatesInput): ExtractCandidatesResult { + const candidates: Candidate[] = []; + const notices: PipelineNotice[] = []; + for (const file of input.files) { + const located = locateFile(file, input.profile); + candidates.push(...located.candidates); + notices.push(...located.notices); + } + return { candidates, notices }; +} + +/** + * Run the full pipeline: locate, extract, parse, lint, and assemble findings. + * + * Every input file becomes a Findings file group even when it yields no + * candidates, so `summary.files` counts every file the pipeline read. + * + * @param input The files, profile, and optional `strict`/`catalog`. + * @returns The Findings result and any recoverable notices. + */ +export function runPipeline(input: RunPipelineInput): RunPipelineResult { + const files: CandidateFile[] = []; + const notices: PipelineNotice[] = []; + for (const file of input.files) { + const located = locateFile(file, input.profile); + files.push({ file: file.path, candidates: located.candidates }); + notices.push(...located.notices); + } + const findings = candidatesToFindings(files, input.profile, { + ...(input.strict === undefined ? {} : { strict: input.strict }), + ...(input.catalog === undefined ? {} : { catalog: input.catalog }), + }); + return { findings, notices }; +} + +/** Locate a single file's candidates, dispatching on its kind. */ +function locateFile( + file: PipelineFile, + profile: Profile, +): { candidates: Candidate[]; notices: PipelineNotice[] } { + const kind = file.kind ?? inferKind(file.path); + const content = stripBom(file.content); + + if (kind === 'yaml' || kind === 'json') { + return structuredCandidates(kind, content, file.path, profile); + } + + if (TEXT_KINDS.has(kind)) { + if (!profile.locator.documentKinds.includes(kind)) { + // The active profile does not locate over this kind: no candidates, and + // this is not a problem worth a notice (a mixed file set is normal). + return { candidates: [], notices: [] }; + } + return { candidates: locateTextFamily(content, kind, profile, file.path), notices: [] }; + } + + return { + candidates: [], + notices: [ + { + code: 'extract.unsupported_kind', + severity: 'warning', + message: `Unsupported document kind for '${file.path}'; no requirements were extracted.`, + file: file.path, + }, + ], + }; +} + +/** Turn a YAML/JSON structured extraction into candidates plus notices. */ +function structuredCandidates( + kind: 'yaml' | 'json', + content: string, + path: string, + profile: Profile, +): { candidates: Candidate[]; notices: PipelineNotice[] } { + const result: ExtractResult = + kind === 'yaml' ? extractYaml(content, path) : extractJson(content, path); + const locatorRuleId = `structured.${kind}`; + const candidates: Candidate[] = result.items.map((item) => { + const candidate: Candidate = { + file: path, + line: item.source?.line ?? 1, + ...(item.source?.column === undefined ? {} : { col: item.source.column }), + text: item.text, + profile: profile.name, + locatorRuleId, + ...(item.id === undefined ? {} : { requirementId: item.id }), + }; + return candidate; + }); + const notices = result.errors.map((error) => structuredNotice(kind, error)); + return { candidates, notices }; +} + +/** Map an {@link ExtractError} onto a {@link PipelineNotice}. */ +function structuredNotice(kind: 'yaml' | 'json', error: ExtractError): PipelineNotice { + return { + code: `extract.malformed_${kind}`, + severity: 'error', + message: error.message, + ...(error.file === undefined ? {} : { file: error.file }), + ...(error.line === undefined ? {} : { line: error.line }), + }; +} + +/** Infer a document kind from a file path's extension. Defaults to `text`. */ +export function inferKind(path: string): DocumentKind { + const lower = path.toLowerCase(); + const dot = lower.lastIndexOf('.'); + const ext = dot === -1 ? '' : lower.slice(dot); + switch (ext) { + case '.ears': + return 'ears'; + case '.md': + case '.markdown': + return 'markdown'; + case '.yaml': + case '.yml': + return 'yaml'; + case '.json': + return 'json'; + default: + return 'text'; + } +} diff --git a/packages/extract/src/types.ts b/packages/extract/src/types.ts new file mode 100644 index 0000000..470feb8 --- /dev/null +++ b/packages/extract/src/types.ts @@ -0,0 +1,38 @@ +/** + * Public types for `@earsyntax/extract`. + * + * The extractor turns files people actually write (`.ears`, Markdown, YAML, + * JSON) into the frozen {@link RequirementInput} shape from `@earsyntax/core`. + * It never lints or parses EARS semantics; that is the job of the core package. + */ + +import type { RequirementInput } from '@earsyntax/core'; + +/** + * A problem encountered while extracting requirements from a source. + * + * Extractors are tolerant: a single malformed item or file does not throw. Each + * recoverable problem is collected here so callers can report all of them at + * once. Structural failures (malformed YAML/JSON, wrong top-level shape) also + * surface as errors rather than exceptions. + */ +export interface ExtractError { + /** Human-readable explanation of the problem. */ + message: string; + /** Path of the source file, when known. */ + file?: string; + /** 1-based line number the problem relates to, when known. */ + line?: number; +} + +/** + * The outcome of extracting requirements from a single source. + * + * `items` are in document order. `errors` is empty on a clean extraction. + */ +export interface ExtractResult { + /** Extracted requirements, in the order they appear in the source. */ + items: RequirementInput[]; + /** Recoverable problems found while extracting. Empty when clean. */ + errors: ExtractError[]; +} diff --git a/packages/extract/src/yaml.test.ts b/packages/extract/src/yaml.test.ts new file mode 100644 index 0000000..ec0ac56 --- /dev/null +++ b/packages/extract/src/yaml.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { extractYaml } from './yaml.js'; + +describe('extractYaml', () => { + it('extracts requirements with ids and best-effort line numbers', () => { + const content = [ + 'requirements:', + ' - id: REQ-001', + ' text: When a payment webhook is received, the billing service shall verify the HMAC signature.', + ' - id: REQ-002', + ' text: If the HMAC signature is invalid, then the billing service shall reject the webhook.', + ].join('\n'); + + const { items, errors } = extractYaml(content, 'requirements.yaml'); + + expect(errors).toEqual([]); + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + source: { file: 'requirements.yaml', line: 2 }, + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + source: { file: 'requirements.yaml', line: 4 }, + }, + ]); + }); + + it('tolerates missing ids', () => { + const content = [ + 'requirements:', + ' - text: The billing service shall retain receipts for seven years.', + ].join('\n'); + + const { items, errors } = extractYaml(content); + + expect(errors).toEqual([]); + expect(items).toHaveLength(1); + expect(items[0].id).toBeUndefined(); + expect(items[0].text).toBe('The billing service shall retain receipts for seven years.'); + expect(items[0].source).toEqual({ line: 2 }); + }); + + it('reports an error for an entry missing text but keeps the others', () => { + const content = [ + 'requirements:', + ' - id: REQ-001', + ' - id: REQ-002', + ' text: The billing service shall reject invalid webhooks.', + ].join('\n'); + + const { items, errors } = extractYaml(content, 'requirements.yaml'); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe('REQ-002'); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('requirements[0]'); + expect(errors[0].file).toBe('requirements.yaml'); + }); + + it('reports malformed YAML clearly with a line number', () => { + const content = ['requirements:', ' - id: REQ-001', ' text: "unterminated'].join('\n'); + + const { items, errors } = extractYaml(content, 'broken.yaml'); + + expect(items).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Malformed YAML'); + expect(errors[0].file).toBe('broken.yaml'); + expect(typeof errors[0].line).toBe('number'); + }); + + it('reports a clear error when the requirements key is absent', () => { + const { errors } = extractYaml('title: some other document\n'); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('requirements'); + }); + + it('returns no items for an empty document', () => { + expect(extractYaml('')).toEqual({ + items: [], + errors: [{ message: 'Expected a top-level "requirements" sequence.' }], + }); + }); +}); diff --git a/packages/extract/src/yaml.ts b/packages/extract/src/yaml.ts new file mode 100644 index 0000000..14c1e7e --- /dev/null +++ b/packages/extract/src/yaml.ts @@ -0,0 +1,109 @@ +/** + * YAML extractor. + * + * Expects a top-level mapping with a `requirements` sequence: + * + * ```yaml + * requirements: + * - id: REQ-001 + * text: When ... + * ``` + * + * Missing ids are tolerated. Malformed YAML, a missing `requirements` key, or a + * non-string `text` field are reported as {@link ExtractError}s rather than + * thrown. Source lines are a best-effort lookup of each item's id or text in + * the raw document. + */ + +import jsYaml from 'js-yaml'; +import type { RequirementInput } from '@earsyntax/core'; +import type { ExtractError, ExtractResult } from './types.js'; +import { readRequirementsArray } from './internal.js'; +import { LineFinder, stripBom } from './normalize.js'; + +/** + * Extract requirements from YAML content. + * + * @param rawContent Raw file contents. + * @param file Optional source path, echoed onto each item and error. + */ +export function extractYaml(rawContent: string, file?: string): ExtractResult { + const content = stripBom(rawContent); + const items: RequirementInput[] = []; + const errors: ExtractError[] = []; + + let parsed: unknown; + try { + parsed = jsYaml.load(content); + } catch (error) { + if (error instanceof jsYaml.YAMLException) { + // `@types/js-yaml` types `mark` as always present, but a YAMLException can + // be constructed without one; read it through an optional-property view so + // the guard is honest and control-flow does not narrow it away. + const mark = (error as { mark?: jsYaml.Mark }).mark; + errors.push({ + message: `Malformed YAML: ${error.reason}`, + ...(file === undefined ? {} : { file }), + // `mark.line` is 0-based; present it as a 1-based line number. + ...(mark ? { line: mark.line + 1 } : {}), + }); + } else { + errors.push({ + message: `Malformed YAML: ${error instanceof Error ? error.message : String(error)}`, + ...(file === undefined ? {} : { file }), + }); + } + return { items, errors }; + } + + const requirements = readRequirementsArray(parsed); + if (requirements === undefined) { + errors.push({ + message: 'Expected a top-level "requirements" sequence.', + ...(file === undefined ? {} : { file }), + }); + return { items, errors }; + } + + const finder = new LineFinder(content); + for (let index = 0; index < requirements.length; index++) { + const entry = requirements[index]; + if (typeof entry !== 'object' || entry === null) { + errors.push({ + message: `requirements[${index}] is not a mapping.`, + ...(file === undefined ? {} : { file }), + }); + continue; + } + const record = entry as Record; + const text = record.text; + if (typeof text !== 'string' || text.trim() === '') { + errors.push({ + message: `requirements[${index}] is missing a non-empty string "text".`, + ...(file === undefined ? {} : { file }), + }); + continue; + } + const id = typeof record.id === 'string' ? record.id : undefined; + const line = finder.locate(id) ?? finder.locate(text); + items.push(buildInput(id, text.trim(), file, line)); + } + + return { items, errors }; +} + +function buildInput( + id: string | undefined, + text: string, + file: string | undefined, + line: number | undefined, +): RequirementInput { + return { + ...(id === undefined ? {} : { id }), + text, + source: { + ...(file === undefined ? {} : { file }), + ...(line === undefined ? {} : { line }), + }, + }; +} diff --git a/packages/extract/tsconfig.build.json b/packages/extract/tsconfig.build.json new file mode 100644 index 0000000..fa63b3c --- /dev/null +++ b/packages/extract/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules", "dist"], + "references": [{ "path": "../core/tsconfig.build.json" }] +} diff --git a/packages/extract/tsconfig.json b/packages/extract/tsconfig.json new file mode 100644 index 0000000..dc3bd75 --- /dev/null +++ b/packages/extract/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/extract/vitest.config.ts b/packages/extract/vitest.config.ts new file mode 100644 index 0000000..104510b --- /dev/null +++ b/packages/extract/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + environment: 'node', + globals: false, + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..23fc38a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,7935 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@commitlint/cli': + specifier: ^19.8.1 + version: 19.8.1 + '@commitlint/config-conventional': + specifier: ^19.8.1 + version: 19.8.1 + '@eslint/js': + specifier: ^9.19.0 + version: 9.39.5 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.13.0 + version: 22.20.1 + '@typescript-eslint/eslint-plugin': + specifier: ^8.22.0 + version: 8.64.0 + '@typescript-eslint/parser': + specifier: ^8.22.0 + version: 8.64.0 + dependency-cruiser: + specifier: ^16.9.0 + version: 16.10.4 + eslint: + specifier: ^9.19.0 + version: 9.39.5 + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8 + eslint-plugin-import-x: + specifier: ^4.6.1 + version: 4.17.1 + eslint-plugin-unicorn: + specifier: ^57.0.0 + version: 57.0.0 + globals: + specifier: ^15.14.0 + version: 15.15.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.3.0 + lerna: + specifier: ^8.2.3 + version: 8.2.4 + lint-staged: + specifier: ^15.5.2 + version: 15.5.2 + prettier: + specifier: ^3.4.2 + version: 3.9.5 + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + typescript: + specifier: ~5.7.3 + version: 5.7.3 + typescript-eslint: + specifier: ^8.22.0 + version: 8.65.0 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4 + vitest: + specifier: ^3.0.5 + version: 3.2.7 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: 'catalog:' + version: 19.8.1(@types/node@22.20.1)(typescript@5.7.3) + '@commitlint/config-conventional': + specifier: 'catalog:' + version: 19.8.1 + '@eslint/js': + specifier: 'catalog:' + version: 9.39.5 + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + '@typescript-eslint/eslint-plugin': + specifier: 'catalog:' + version: 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: 'catalog:' + version: 8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + dependency-cruiser: + specifier: 'catalog:' + version: 16.10.4 + eslint: + specifier: 'catalog:' + version: 9.39.5(jiti@2.6.1) + eslint-config-prettier: + specifier: 'catalog:' + version: 10.1.8(eslint@9.39.5(jiti@2.6.1)) + eslint-plugin-import-x: + specifier: 'catalog:' + version: 4.17.1(@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1)) + eslint-plugin-unicorn: + specifier: 'catalog:' + version: 57.0.0(eslint@9.39.5(jiti@2.6.1)) + globals: + specifier: 'catalog:' + version: 15.15.0 + husky: + specifier: 'catalog:' + version: 9.1.7 + lerna: + specifier: 'catalog:' + version: 8.2.4(@types/node@22.20.1)(encoding@0.1.13) + lint-staged: + specifier: 'catalog:' + version: 15.5.2 + prettier: + specifier: 'catalog:' + version: 3.9.5 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.7.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + + packages/cli: + dependencies: + '@earsyntax/cli-contract': + specifier: workspace:* + version: link:../cli-contract + '@earsyntax/core': + specifier: workspace:* + version: link:../core + '@earsyntax/extract': + specifier: workspace:* + version: link:../extract + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.7.3 + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + + packages/cli-contract: + dependencies: + '@earsyntax/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + dependency-cruiser: + specifier: 'catalog:' + version: 16.10.4 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.7.3 + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + + packages/core: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.7.3 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 5.1.4(typescript@5.7.3)(vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + + packages/extract: + dependencies: + '@earsyntax/core': + specifier: workspace:* + version: link:../core + js-yaml: + specifier: 'catalog:' + version: 4.3.0 + devDependencies: + '@types/js-yaml': + specifier: 'catalog:' + version: 4.0.9 + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.7.3 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 5.1.4(typescript@5.7.3)(vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@commitlint/cli@19.8.1': + resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.8.1': + resolution: {integrity: sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.8.1': + resolution: {integrity: sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==} + engines: {node: '>=v18'} + + '@commitlint/ensure@19.8.1': + resolution: {integrity: sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.8.1': + resolution: {integrity: sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==} + engines: {node: '>=v18'} + + '@commitlint/format@19.8.1': + resolution: {integrity: sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.8.1': + resolution: {integrity: sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==} + engines: {node: '>=v18'} + + '@commitlint/lint@19.8.1': + resolution: {integrity: sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==} + engines: {node: '>=v18'} + + '@commitlint/load@19.8.1': + resolution: {integrity: sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==} + engines: {node: '>=v18'} + + '@commitlint/message@19.8.1': + resolution: {integrity: sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==} + engines: {node: '>=v18'} + + '@commitlint/parse@19.8.1': + resolution: {integrity: sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==} + engines: {node: '>=v18'} + + '@commitlint/read@19.8.1': + resolution: {integrity: sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.8.1': + resolution: {integrity: sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==} + engines: {node: '>=v18'} + + '@commitlint/rules@19.8.1': + resolution: {integrity: sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.8.1': + resolution: {integrity: sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.8.1': + resolution: {integrity: sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==} + engines: {node: '>=v18'} + + '@commitlint/types@19.8.1': + resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} + engines: {node: '>=v18'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@hutson/parse-repository-url@3.0.2': + resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} + engines: {node: '>=6.9.0'} + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/string-locale-compare@1.1.0': + resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@lerna/create@8.2.4': + resolution: {integrity: sha512-A8AlzetnS2WIuhijdAzKUyFpR5YbLLfV3luQ4lzBgIBgRfuoBDZeF+RSZPhra+7A6/zTUlrbhKZIOi/MNhqgvQ==} + engines: {node: '>=18.0.0'} + deprecated: This package is an implementation detail of Lerna and is no longer published separately. + + '@napi-rs/wasm-runtime@0.2.4': + resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@npmcli/agent@2.2.2': + resolution: {integrity: sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/arborist@7.5.4': + resolution: {integrity: sha512-nWtIc6QwwoUORCRNzKx4ypHqCk3drI+5aeYdMTQQiRCcn4lOOgfQh7WyZobGYTxXPSq1VwV53lkpN/BRlRk08g==} + engines: {node: ^16.14.0 || >=18.0.0} + hasBin: true + + '@npmcli/fs@3.1.1': + resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/git@5.0.8': + resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/installed-package-contents@2.1.0': + resolution: {integrity: sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + '@npmcli/map-workspaces@3.0.6': + resolution: {integrity: sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/metavuln-calculator@7.1.1': + resolution: {integrity: sha512-Nkxf96V0lAx3HCpVda7Vw4P23RILgdi/5K1fmj2tZkWIYLpXAN8k2UVVOsW16TsS5F8Ws2I7Cm+PU1/rsVF47g==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/name-from-folder@2.0.0': + resolution: {integrity: sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/node-gyp@3.0.0': + resolution: {integrity: sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/package-json@5.2.0': + resolution: {integrity: sha512-qe/kiqqkW0AGtvBjL8TJKZk/eBBSpnJkUWvHdQ9jM2lKHXRYYJuyNpJPlJw3c8QjC2ow6NZYiLExhUaeJelbxQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/promise-spawn@7.0.2': + resolution: {integrity: sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/query@3.1.0': + resolution: {integrity: sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/redact@2.0.1': + resolution: {integrity: sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/run-script@8.1.0': + resolution: {integrity: sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@nx/devkit@20.8.4': + resolution: {integrity: sha512-3r+6QmIXXAWL6K7m8vAbW31aniAZmZAZXeMhOhWcJoOAU7ggpCQaM8JP8/kO5ov/Bmhyf0i/SSVXI6kwiR5WNQ==} + peerDependencies: + nx: '>= 19 <= 21' + + '@nx/nx-darwin-arm64@20.8.4': + resolution: {integrity: sha512-8Y7+4wj1qoZsuDRpnuiHzSIsMt3VqtJ0su8dgd/MyGccvvi4pndan2R5yTiVw/wmbMxtBmZ6PO6Z8dgSIrMVog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@nx/nx-darwin-x64@20.8.4': + resolution: {integrity: sha512-2lfuxRc56QWnAysMhcD03tpCPiRzV1+foUq0MhV2sSBIybXmgV4wHLkPZNhlBCl4FNXrWiZiN1OJ2X9AGiOdug==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@nx/nx-freebsd-x64@20.8.4': + resolution: {integrity: sha512-99vnUXZy+OUBHU+8Yhabre2qafepKg9GKkQkhmXvJGqOmuIsepK7wirUFo2PiVM8YhS6UV2rv6hKAZcQ7skYyg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@nx/nx-linux-arm-gnueabihf@20.8.4': + resolution: {integrity: sha512-dht73zpnpzEUEzMHFQs4mfiwZH3WcJgQNWkD5p7WkeJewHq2Yyd0eG5Jg3kB7wnFtwPUV1eNJRM5rephgylkLA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@nx/nx-linux-arm64-gnu@20.8.4': + resolution: {integrity: sha512-syXxbJZ0yPaqzVmB28QJgUtaarSiW/PQmv/5Z2Ps8rCi7kYylISPVNjP1NNiIOcGDRWbHqoBfM0bEGPfSp0rBQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@nx/nx-linux-arm64-musl@20.8.4': + resolution: {integrity: sha512-AlZZFolS/S0FahRKG7rJ0Z9CgmIkyzHgGaoy3qNEMDEjFhR3jt2ZZSLp90W7zjgrxojOo90ajNMrg2UmtcQRDA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@nx/nx-linux-x64-gnu@20.8.4': + resolution: {integrity: sha512-MSu+xVNdR95tuuO+eL/a/ZeMlhfrZ627On5xaCZXnJ+lFxNg/S4nlKZQk0Eq5hYALCd/GKgFGasRdlRdOtvGPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@nx/nx-linux-x64-musl@20.8.4': + resolution: {integrity: sha512-KxpQpyLCgIIHWZ4iRSUN9ohCwn1ZSDASbuFCdG3mohryzCy8WrPkuPcb+68J3wuQhmA5w//Xpp/dL0hHoit9zQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@nx/nx-win32-arm64-msvc@20.8.4': + resolution: {integrity: sha512-ffLBrxM9ibk+eWSY995kiFFRTSRb9HkD5T1s/uZyxV6jfxYPaZDBAWAETDneyBXps7WtaOMu+kVZlXQ3X+TfIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@nx/nx-win32-x64-msvc@20.8.4': + resolution: {integrity: sha512-JxuuZc4h8EBqoYAiRHwskimpTJx70yn4lhIRFBoW5ICkxXW1Rw0yip/1UVsWRHXg/x9BxmH7VVazdfaQWmGu6A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-enterprise-rest@6.0.1': + resolution: {integrity: sha512-93uGjlhUD+iNg1iWhUENAtJata6w5nE+V4urXOAlIXdco6xNZtUSfYY8dzp3Udy74aqO/B5UZL80x/YMa5PKRw==} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/rest@20.1.2': + resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} + engines: {node: '>= 18'} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@sigstore/bundle@2.3.2': + resolution: {integrity: sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@sigstore/core@1.1.0': + resolution: {integrity: sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@sigstore/protobuf-specs@0.3.3': + resolution: {integrity: sha512-RpacQhBlwpBWd7KEJsRKcBQalbV28fvkxwTOJIqhIuDysMMaJW47V4OqW30iJB9uRpqOSxxEAQFdr8tTattReQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@2.3.2': + resolution: {integrity: sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@sigstore/tuf@2.3.4': + resolution: {integrity: sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@sigstore/verify@1.2.1': + resolution: {integrity: sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@2.0.1': + resolution: {integrity: sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@tybys/wasm-util@0.9.0': + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/conventional-commits-parser@5.0.2': + resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/minimatch@3.0.5': + resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} + + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@typescript-eslint/eslint-plugin@8.64.0': + resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.64.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.64.0': + resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.64.0': + resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.64.0': + resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.64.0': + resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + + '@yarnpkg/parsers@3.0.2': + resolution: {integrity: sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==} + engines: {node: '>=18.12.0'} + + '@zkochan/js-yaml@0.0.7': + resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} + hasBin: true + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + acorn-jsx-walk@2.0.0: + resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + add-stream@1.0.0: + resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-differ@3.0.0: + resolution: {integrity: sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==} + engines: {node: '>=8'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.6: + resolution: {integrity: sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==} + engines: {node: '>=6.0.0'} + hasBin: true + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + + bin-links@4.0.4: + resolution: {integrity: sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@4.0.0: + resolution: {integrity: sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==} + engines: {node: '>=18.20'} + + byte-size@8.1.1: + resolution: {integrity: sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==} + engines: {node: '>=12.17'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacache@18.0.4: + resolution: {integrity: sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-keys@6.2.2: + resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} + engines: {node: '>=8'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.0: + resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==} + engines: {node: '>=10'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.6.1: + resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} + engines: {node: '>=6'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cmd-shim@6.0.3: + resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + columnify@1.6.0: + resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} + engines: {node: '>=8.0.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + comment-parser@1.4.7: + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + engines: {node: '>= 12.0.0'} + + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-changelog-core@5.0.1: + resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==} + engines: {node: '>=14'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. + + conventional-changelog-preset-loader@3.0.0: + resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==} + engines: {node: '>=14'} + + conventional-changelog-writer@6.0.1: + resolution: {integrity: sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ==} + engines: {node: '>=14'} + hasBin: true + + conventional-commits-filter@3.0.0: + resolution: {integrity: sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q==} + engines: {node: '>=14'} + + conventional-commits-parser@4.0.0: + resolution: {integrity: sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg==} + engines: {node: '>=14'} + hasBin: true + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + + conventional-recommended-bump@7.0.1: + resolution: {integrity: sha512-Ft79FF4SlOFvX4PkwFDRnaNiIVX7YbmqGU0RwccUaiGvgp3S0a8ipR2/Qxk31vclDNM+GSdJOVs2KrsUCjblVA==} + engines: {node: '>=14'} + hasBin: true + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + dargs@7.0.0: + resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} + engines: {node: '>=8'} + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + + dateformat@3.0.3: + resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize-keys@1.1.1: + resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} + engines: {node: '>=0.10.0'} + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dependency-cruiser@16.10.4: + resolution: {integrity: sha512-hrxVOjIm8idZ9ZVDGSyyG3SHiNcEUPhL6RTEmO/3wfQWLepH5pA3nuDMMrcJ1DkZztFA7xg3tk8OVO+MmwwH9w==} + engines: {node: ^18.17||>=20} + hasBin: true + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + + detect-indent@5.0.0: + resolution: {integrity: sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g==} + engines: {node: '>=4'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.397: + resolution: {integrity: sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.4: + resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + engines: {node: '>=10.13.0'} + + enquirer@2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + envinfo@7.13.0: + resolution: {integrity: sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==} + engines: {node: '>=4'} + hasBin: true + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-plugin-import-x@4.17.1: + resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + + eslint-plugin-unicorn@57.0.0: + resolution: {integrity: sha512-zUYYa6zfNdTeG9BISWDlcLmz16c+2Ck2o5ZDHh0UzXJz3DEP7xjmlVDTzbyV0W+XksgZ0q37WEWzN2D2Ze+g9Q==} + engines: {node: '>=18.18'} + peerDependencies: + eslint: '>=9.20.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@5.0.0: + resolution: {integrity: sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@2.1.0: + resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} + engines: {node: '>=4'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + front-matter@4.0.2: + resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-pkg-repo@4.2.1: + resolution: {integrity: sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA==} + engines: {node: '>=6.9.0'} + hasBin: true + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.0: + resolution: {integrity: sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + git-raw-commits@3.0.0: + resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} + engines: {node: '>=14'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + git-remote-origin-url@2.0.0: + resolution: {integrity: sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==} + engines: {node: '>=4'} + + git-semver-tags@5.0.1: + resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==} + engines: {node: '>=14'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + git-up@7.0.0: + resolution: {integrity: sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==} + + git-url-parse@14.0.0: + resolution: {integrity: sha512-NnLweV+2A4nCvn4U/m2AoYu0pPKlsmhK9cknG7IMwsjFY1S2jxM+mAhsDxyxfCIGfGaD+dozsyX4b6vkYc83yQ==} + + gitconfiglocal@1.0.0: + resolution: {integrity: sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + hard-rejection@2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-walk@6.0.5: + resolution: {integrity: sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.1.0: + resolution: {integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==} + engines: {node: '>=8'} + hasBin: true + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + init-package-json@6.0.3: + resolution: {integrity: sha512-Zfeb5ol+H+eqJWHTaGca9BovufyGeIfr4zaaBorPmJBMrJ+KBnN+kQx2ZtXdsotUTgldHmHQV44xvUWOUA7E2w==} + engines: {node: ^16.14.0 || >=18.0.0} + + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-builtin-module@4.0.0: + resolution: {integrity: sha512-rWP3AMAalQSesXO8gleROyL2iKU73SX5Er66losQn9rWOWL4Gef0a/xOEOVqjWGMuR2vHG3FJ8UUmT700O8oFg==} + engines: {node: '>=18.20'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-ssh@1.4.1: + resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==} + + is-stream@2.0.0: + resolution: {integrity: sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-text-path@1.0.1: + resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} + engines: {node: '>=0.10.0'} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-nice@1.1.4: + resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + just-diff-apply@5.5.0: + resolution: {integrity: sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==} + + just-diff@6.0.2: + resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lerna@8.2.4: + resolution: {integrity: sha512-0gaVWDIVT7fLfprfwpYcQajb7dBJv3EGavjG7zvJ+TmGx3/wovl5GklnSwM2/WeE0Z2wrIz7ndWhBcDUHVjOcQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libnpmaccess@8.0.6: + resolution: {integrity: sha512-uM8DHDEfYG6G5gVivVl+yQd4pH3uRclHC59lzIbSvy7b5FEwR+mU49Zq1jEyRtRFv7+M99mUW9S0wL/4laT4lw==} + engines: {node: ^16.14.0 || >=18.0.0} + + libnpmpublish@9.0.9: + resolution: {integrity: sha512-26zzwoBNAvX9AWOPiqqF6FG4HrSCPsHFkQm7nT+xU1ggAujL/eae81RnCv4CJ2In9q9fh10B88sYSzKCUh/Ghg==} + engines: {node: ^16.14.0 || >=18.0.0} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lines-and-columns@2.0.3: + resolution: {integrity: sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lint-staged@15.5.2: + resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} + engines: {node: '>=18.0.0'} + + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + + load-json-file@6.2.0: + resolution: {integrity: sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==} + engines: {node: '>=8'} + + locate-path@2.0.0: + resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} + engines: {node: '>=4'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.ismatch@4.4.0: + resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-fetch-happen@13.0.1: + resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} + engines: {node: ^16.14.0 || >=18.0.0} + + map-obj@1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + + map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize@10.2.0: + resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} + engines: {node: '>=18'} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + + meow@8.1.2: + resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.0.5: + resolution: {integrity: sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist-options@4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@3.0.5: + resolution: {integrity: sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + modify-values@1.0.1: + resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} + engines: {node: '>=0.10.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multimatch@5.0.0: + resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==} + engines: {node: '>=10'} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-fetch@2.6.7: + resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp@10.3.1: + resolution: {integrity: sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==} + engines: {node: ^16.14.0 || >=18.0.0} + hasBin: true + + node-machine-id@1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + + normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-bundled@3.0.1: + resolution: {integrity: sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-install-checks@6.3.0: + resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-normalize-package-bin@3.0.1: + resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-package-arg@11.0.2: + resolution: {integrity: sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-packlist@8.0.2: + resolution: {integrity: sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-pick-manifest@9.1.0: + resolution: {integrity: sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-registry-fetch@17.1.0: + resolution: {integrity: sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nx@20.8.4: + resolution: {integrity: sha512-/++x0OM3/UTmDR+wmPeV13tSxeTr+QGzj3flgtH9DiOPmQnn2CjHWAMZiOhcSh/hHoE/V3ySL4757InQUsVtjQ==} + hasBin: true + peerDependencies: + '@swc-node/register': ^1.8.0 + '@swc/core': ^1.3.85 + peerDependenciesMeta: + '@swc-node/register': + optional: true + '@swc/core': + optional: true + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.3.0: + resolution: {integrity: sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==} + engines: {node: '>=10'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@1.3.0: + resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@2.0.0: + resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} + engines: {node: '>=4'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map-series@2.1.0: + resolution: {integrity: sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q==} + engines: {node: '>=8'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-pipe@3.1.0: + resolution: {integrity: sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw==} + engines: {node: '>=8'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-reduce@2.1.0: + resolution: {integrity: sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-try@1.0.0: + resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} + engines: {node: '>=4'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + p-waterfall@2.1.1: + resolution: {integrity: sha512-RRTnDb2TBG/epPRI2yYXsimO0v3BXC8Yd3ogr1545IaqKK17VGhbWVeGGN+XfCm/08OK8635nH31c8bATkHuSw==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pacote@18.0.6: + resolution: {integrity: sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==} + engines: {node: ^16.14.0 || >=18.0.0} + hasBin: true + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-conflict-json@3.0.1: + resolution: {integrity: sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-path@7.1.0: + resolution: {integrity: sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==} + + parse-url@8.1.0: + resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-type@3.0.0: + resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} + engines: {node: '>=4'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pidtree@0.6.1: + resolution: {integrity: sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proggy@2.0.0: + resolution: {integrity: sha512-69agxLtnI8xBs9gUGqEnK26UfiexpHy+KUpBQWabiytQjnn5wFY8rklAi7GRfABIuPNnQ/ik48+LGLkYYJcy4A==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + promise-all-reject-late@1.0.1: + resolution: {integrity: sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==} + + promise-call-limit@3.0.2: + resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + promzard@1.0.2: + resolution: {integrity: sha512-2FPputGL+mP3jJ3UZg/Dl9YOkovB7DX0oOr+ck5QbZ5MtORtds8k/BZdn+02peDLI8/YWbmzx34k5fA+fHvCVQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + protocols@2.0.2: + resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-lru@4.0.1: + resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} + engines: {node: '>=8'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + read-cmd-shim@4.0.0: + resolution: {integrity: sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + read-package-json-fast@3.0.2: + resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + read-package-up@11.0.0: + resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} + engines: {node: '>=18'} + + read-pkg-up@3.0.0: + resolution: {integrity: sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==} + engines: {node: '>=4'} + + read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + + read-pkg@3.0.0: + resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} + engines: {node: '>=4'} + + read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + read@3.0.1: + resolution: {integrity: sha512-SLBrDU/Srs/9EoWhU5GdbAoxG1GzpQHo/6qiGItaoLJ1thmYpcNIM1qISEUvyHBzfGlWIyd6p2DNi1oV1VmAuw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regjsparser@0.12.0: + resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@4.4.1: + resolution: {integrity: sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==} + engines: {node: '>=14'} + hasBin: true + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sigstore@2.3.1: + resolution: {integrity: sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sort-keys@2.0.0: + resolution: {integrity: sha512-/dPCrG1s3ePpWm6yBbxZq5Be1dXGLyLn9Z791chDC3NFrpkVbWGzkBwPN1knaciexFXgRJ7hzdnwZ4stHSDmjg==} + engines: {node: '>=4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@3.2.2: + resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + ssri@10.0.6: + resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + teamcity-service-messages@0.1.14: + resolution: {integrity: sha512-29aQwaHqm8RMX74u2o/h1KbMLP89FjNiMxD9wbF2BbWOnbM+q+d1sCEC+MqCc4QW3NJykn77OMpTFw/xTHIc0w==} + + temp-dir@1.0.0: + resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} + engines: {node: '>=4'} + + text-extensions@1.9.0: + resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} + engines: {node: '>=0.10'} + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.12: + resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + treeverse@3.0.0: + resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + trim-newlines@3.0.1: + resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} + engines: {node: '>=8'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + deprecated: unmaintained + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tuf-js@2.2.1: + resolution: {integrity: sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==} + engines: {node: ^16.14.0 || >=18.0.0} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.18.1: + resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.4.1: + resolution: {integrity: sha512-IwzA/LSfD2vC1/YDYMv/zHP4rDF1usCwllsDpbolT3D4fUepIO7f9K70jjmUewU/LmGUKJcwcVtDCpnKk4BPMw==} + engines: {node: '>=6'} + + type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unique-filename@3.0.0: + resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + unique-slug@4.0.0: + resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + upath@2.0.1: + resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + walk-up-path@3.0.1: + resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + + watskeburt@4.2.3: + resolution: {integrity: sha512-uG9qtQYoHqAsnT711nG5iZc/8M5inSmkGCOp7pFaytKG2aTfIca7p//CjiVzAE4P7hzaYuCozMjNNaLgmhbK5g==} + engines: {node: ^18||>=20} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@2.4.3: + resolution: {integrity: sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + write-json-file@3.2.0: + resolution: {integrity: sha512-3xZqT7Byc2uORAatYiP3DHUUAVEkNOswEWNs9H5KXiicRTvzYzYqKjYc4G7p+8pltvAw641lVByKVtMpf+4sYQ==} + engines: {node: '>=6'} + + write-pkg@4.0.0: + resolution: {integrity: sha512-v2UQ+50TNf2rNHJ8NyWttfm/EJUBWMJcx6ZTYZr6Qp52uuegWw/lBkCtCbnYZEmPRNL61m+u67dAmGxo+HTULA==} + engines: {node: '>=8'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@commitlint/cli@19.8.1(@types/node@22.20.1)(typescript@5.7.3)': + dependencies: + '@commitlint/format': 19.8.1 + '@commitlint/lint': 19.8.1 + '@commitlint/load': 19.8.1(@types/node@22.20.1)(typescript@5.7.3) + '@commitlint/read': 19.8.1 + '@commitlint/types': 19.8.1 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + ajv: 8.20.0 + + '@commitlint/ensure@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.8.1': {} + + '@commitlint/format@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + + '@commitlint/is-ignored@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + semver: 7.8.5 + + '@commitlint/lint@19.8.1': + dependencies: + '@commitlint/is-ignored': 19.8.1 + '@commitlint/parse': 19.8.1 + '@commitlint/rules': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/load@19.8.1(@types/node@22.20.1)(typescript@5.7.3)': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/execute-rule': 19.8.1 + '@commitlint/resolve-extends': 19.8.1 + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + cosmiconfig: 9.0.2(typescript@5.7.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@22.20.1)(cosmiconfig@9.0.2(typescript@5.7.3))(typescript@5.7.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@19.8.1': {} + + '@commitlint/parse@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.8.1': + dependencies: + '@commitlint/top-level': 19.8.1 + '@commitlint/types': 19.8.1 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 1.2.4 + + '@commitlint/resolve-extends@19.8.1': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/types': 19.8.1 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@19.8.1': + dependencies: + '@commitlint/ensure': 19.8.1 + '@commitlint/message': 19.8.1 + '@commitlint/to-lines': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/to-lines@19.8.1': {} + + '@commitlint/top-level@19.8.1': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.8.1': + dependencies: + '@types/conventional-commits-parser': 5.0.2 + chalk: 5.6.2 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.6.1))': + dependencies: + eslint: 9.39.5(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@hutson/parse-repository-url@3.0.2': {} + + '@inquirer/external-editor@1.0.3(@types/node@22.20.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/string-locale-compare@1.1.0': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@lerna/create@8.2.4(@types/node@22.20.1)(encoding@0.1.13)(typescript@5.7.3)': + dependencies: + '@npmcli/arborist': 7.5.4 + '@npmcli/package-json': 5.2.0 + '@npmcli/run-script': 8.1.0 + '@nx/devkit': 20.8.4(nx@20.8.4) + '@octokit/plugin-enterprise-rest': 6.0.1 + '@octokit/rest': 20.1.2 + aproba: 2.0.0 + byte-size: 8.1.1 + chalk: 4.1.0 + clone-deep: 4.0.1 + cmd-shim: 6.0.3 + color-support: 1.1.3 + columnify: 1.6.0 + console-control-strings: 1.1.0 + conventional-changelog-core: 5.0.1 + conventional-recommended-bump: 7.0.1 + cosmiconfig: 9.0.0(typescript@5.7.3) + dedent: 1.5.3 + execa: 5.0.0 + fs-extra: 11.4.0 + get-stream: 6.0.0 + git-url-parse: 14.0.0 + glob-parent: 6.0.2 + graceful-fs: 4.2.11 + has-unicode: 2.0.1 + ini: 1.3.8 + init-package-json: 6.0.3 + inquirer: 8.2.7(@types/node@22.20.1) + is-ci: 3.0.1 + is-stream: 2.0.0 + js-yaml: 4.1.0 + libnpmpublish: 9.0.9 + load-json-file: 6.2.0 + make-dir: 4.0.0 + minimatch: 3.0.5 + multimatch: 5.0.0 + node-fetch: 2.6.7(encoding@0.1.13) + npm-package-arg: 11.0.2 + npm-packlist: 8.0.2 + npm-registry-fetch: 17.1.0 + nx: 20.8.4 + p-map: 4.0.0 + p-map-series: 2.1.0 + p-queue: 6.6.2 + p-reduce: 2.1.0 + pacote: 18.0.6 + pify: 5.0.0 + read-cmd-shim: 4.0.0 + resolve-from: 5.0.0 + rimraf: 4.4.1 + semver: 7.8.5 + set-blocking: 2.0.0 + signal-exit: 3.0.7 + slash: 3.0.0 + ssri: 10.0.6 + string-width: 4.2.3 + tar: 6.2.1 + temp-dir: 1.0.0 + through: 2.3.8 + tinyglobby: 0.2.12 + upath: 2.0.1 + uuid: 10.0.0 + validate-npm-package-license: 3.0.4 + validate-npm-package-name: 5.0.1 + wide-align: 1.1.5 + write-file-atomic: 5.0.1 + write-pkg: 4.0.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@swc-node/register' + - '@swc/core' + - '@types/node' + - babel-plugin-macros + - bluebird + - debug + - encoding + - supports-color + - typescript + + '@napi-rs/wasm-runtime@0.2.4': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.9.0 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@npmcli/agent@2.2.2': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 10.4.3 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/arborist@7.5.4': + dependencies: + '@isaacs/string-locale-compare': 1.1.0 + '@npmcli/fs': 3.1.1 + '@npmcli/installed-package-contents': 2.1.0 + '@npmcli/map-workspaces': 3.0.6 + '@npmcli/metavuln-calculator': 7.1.1 + '@npmcli/name-from-folder': 2.0.0 + '@npmcli/node-gyp': 3.0.0 + '@npmcli/package-json': 5.2.0 + '@npmcli/query': 3.1.0 + '@npmcli/redact': 2.0.1 + '@npmcli/run-script': 8.1.0 + bin-links: 4.0.4 + cacache: 18.0.4 + common-ancestor-path: 1.0.1 + hosted-git-info: 7.0.2 + json-parse-even-better-errors: 3.0.2 + json-stringify-nice: 1.1.4 + lru-cache: 10.4.3 + minimatch: 9.0.9 + nopt: 7.2.1 + npm-install-checks: 6.3.0 + npm-package-arg: 11.0.2 + npm-pick-manifest: 9.1.0 + npm-registry-fetch: 17.1.0 + pacote: 18.0.6 + parse-conflict-json: 3.0.1 + proc-log: 4.2.0 + proggy: 2.0.0 + promise-all-reject-late: 1.0.1 + promise-call-limit: 3.0.2 + read-package-json-fast: 3.0.2 + semver: 7.8.5 + ssri: 10.0.6 + treeverse: 3.0.0 + walk-up-path: 3.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + + '@npmcli/fs@3.1.1': + dependencies: + semver: 7.8.5 + + '@npmcli/git@5.0.8': + dependencies: + '@npmcli/promise-spawn': 7.0.2 + ini: 4.1.3 + lru-cache: 10.4.3 + npm-pick-manifest: 9.1.0 + proc-log: 4.2.0 + promise-inflight: 1.0.1 + promise-retry: 2.0.1 + semver: 7.8.5 + which: 4.0.0 + transitivePeerDependencies: + - bluebird + + '@npmcli/installed-package-contents@2.1.0': + dependencies: + npm-bundled: 3.0.1 + npm-normalize-package-bin: 3.0.1 + + '@npmcli/map-workspaces@3.0.6': + dependencies: + '@npmcli/name-from-folder': 2.0.0 + glob: 10.5.0 + minimatch: 9.0.9 + read-package-json-fast: 3.0.2 + + '@npmcli/metavuln-calculator@7.1.1': + dependencies: + cacache: 18.0.4 + json-parse-even-better-errors: 3.0.2 + pacote: 18.0.6 + proc-log: 4.2.0 + semver: 7.8.5 + transitivePeerDependencies: + - bluebird + - supports-color + + '@npmcli/name-from-folder@2.0.0': {} + + '@npmcli/node-gyp@3.0.0': {} + + '@npmcli/package-json@5.2.0': + dependencies: + '@npmcli/git': 5.0.8 + glob: 10.5.0 + hosted-git-info: 7.0.2 + json-parse-even-better-errors: 3.0.2 + normalize-package-data: 6.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + transitivePeerDependencies: + - bluebird + + '@npmcli/promise-spawn@7.0.2': + dependencies: + which: 4.0.0 + + '@npmcli/query@3.1.0': + dependencies: + postcss-selector-parser: 6.1.4 + + '@npmcli/redact@2.0.1': {} + + '@npmcli/run-script@8.1.0': + dependencies: + '@npmcli/node-gyp': 3.0.0 + '@npmcli/package-json': 5.2.0 + '@npmcli/promise-spawn': 7.0.2 + node-gyp: 10.3.1 + proc-log: 4.2.0 + which: 4.0.0 + transitivePeerDependencies: + - bluebird + - supports-color + + '@nx/devkit@20.8.4(nx@20.8.4)': + dependencies: + ejs: 3.1.10 + enquirer: 2.3.6 + ignore: 5.3.2 + minimatch: 9.0.3 + nx: 20.8.4 + semver: 7.8.5 + tmp: 0.2.7 + tslib: 2.8.1 + yargs-parser: 21.1.1 + + '@nx/nx-darwin-arm64@20.8.4': + optional: true + + '@nx/nx-darwin-x64@20.8.4': + optional: true + + '@nx/nx-freebsd-x64@20.8.4': + optional: true + + '@nx/nx-linux-arm-gnueabihf@20.8.4': + optional: true + + '@nx/nx-linux-arm64-gnu@20.8.4': + optional: true + + '@nx/nx-linux-arm64-musl@20.8.4': + optional: true + + '@nx/nx-linux-x64-gnu@20.8.4': + optional: true + + '@nx/nx-linux-x64-musl@20.8.4': + optional: true + + '@nx/nx-win32-arm64-msvc@20.8.4': + optional: true + + '@nx/nx-win32-x64-msvc@20.8.4': + optional: true + + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-enterprise-rest@6.0.1': {} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/rest@20.1.2': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@sigstore/bundle@2.3.2': + dependencies: + '@sigstore/protobuf-specs': 0.3.3 + + '@sigstore/core@1.1.0': {} + + '@sigstore/protobuf-specs@0.3.3': {} + + '@sigstore/sign@2.3.2': + dependencies: + '@sigstore/bundle': 2.3.2 + '@sigstore/core': 1.1.0 + '@sigstore/protobuf-specs': 0.3.3 + make-fetch-happen: 13.0.1 + proc-log: 4.2.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@2.3.4': + dependencies: + '@sigstore/protobuf-specs': 0.3.3 + tuf-js: 2.2.1 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@1.2.1': + dependencies: + '@sigstore/bundle': 2.3.2 + '@sigstore/core': 1.1.0 + '@sigstore/protobuf-specs': 0.3.3 + + '@sinclair/typebox@0.27.12': {} + + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@2.0.1': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 9.0.9 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@tybys/wasm-util@0.9.0': + dependencies: + tslib: 2.8.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/conventional-commits-parser@5.0.2': + dependencies: + '@types/node': 22.20.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/minimatch@3.0.5': {} + + '@types/minimist@1.2.5': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/normalize-package-data@2.4.4': {} + + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/type-utils': 8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.64.0 + eslint: 9.39.5(jiti@2.6.1) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 9.39.5(jiti@2.6.1) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.64.0(typescript@5.7.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.7.3) + '@typescript-eslint/types': 8.64.0 + debug: 4.4.3 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.7.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.7.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.7.3)': + dependencies: + typescript: 5.7.3 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.7.3)': + dependencies: + typescript: 5.7.3 + + '@typescript-eslint/type-utils@8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.7.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.7.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.64.0(typescript@5.7.3)': + dependencies: + '@typescript-eslint/project-service': 8.64.0(typescript@5.7.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.7.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.7.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.7.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.7.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.7.3) + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.7.3) + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@yarnpkg/lockfile@1.1.0': {} + + '@yarnpkg/parsers@3.0.2': + dependencies: + js-yaml: 3.15.0 + tslib: 2.8.1 + + '@zkochan/js-yaml@0.0.7': + dependencies: + argparse: 2.0.1 + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + abbrev@2.0.0: {} + + acorn-jsx-walk@2.0.0: {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn-loose@8.5.2: + dependencies: + acorn: 8.18.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + add-stream@1.0.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + aproba@2.0.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-differ@3.0.0: {} + + array-ify@1.0.0: {} + + array-union@2.1.0: {} + + arrify@1.0.1: {} + + arrify@2.0.1: {} + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.6: {} + + before-after-hook@2.2.3: {} + + bin-links@4.0.4: + dependencies: + cmd-shim: 6.0.3 + npm-normalize-package-bin: 3.0.1 + read-cmd-shim: 4.0.0 + write-file-atomic: 5.0.1 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.6 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.397 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@4.0.0: {} + + byte-size@8.1.1: {} + + cac@6.7.14: {} + + cacache@18.0.4: + dependencies: + '@npmcli/fs': 3.1.1 + fs-minipass: 3.0.3 + glob: 10.5.0 + lru-cache: 10.4.3 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 4.0.0 + ssri: 10.0.6 + tar: 6.2.1 + unique-filename: 3.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + camelcase-keys@6.2.2: + dependencies: + camelcase: 5.3.1 + map-obj: 4.3.0 + quick-lru: 4.0.1 + + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.2.0: {} + + check-error@2.1.3: {} + + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + ci-info@4.4.0: {} + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.6.1: {} + + cli-spinners@2.9.2: {} + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cli-width@3.0.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + clone@1.0.4: {} + + cmd-shim@6.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-support@1.1.3: {} + + colorette@2.0.20: {} + + columnify@1.6.0: + dependencies: + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + comment-parser@1.4.7: {} + + common-ancestor-path@1.0.1: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + console-control-strings@1.1.0: {} + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-core@5.0.1: + dependencies: + add-stream: 1.0.0 + conventional-changelog-writer: 6.0.1 + conventional-commits-parser: 4.0.0 + dateformat: 3.0.3 + get-pkg-repo: 4.2.1 + git-raw-commits: 3.0.0 + git-remote-origin-url: 2.0.0 + git-semver-tags: 5.0.1 + normalize-package-data: 3.0.3 + read-pkg: 3.0.0 + read-pkg-up: 3.0.0 + + conventional-changelog-preset-loader@3.0.0: {} + + conventional-changelog-writer@6.0.1: + dependencies: + conventional-commits-filter: 3.0.0 + dateformat: 3.0.3 + handlebars: 4.7.9 + json-stringify-safe: 5.0.1 + meow: 8.1.2 + semver: 7.8.5 + split: 1.0.1 + + conventional-commits-filter@3.0.0: + dependencies: + lodash.ismatch: 4.4.0 + modify-values: 1.0.1 + + conventional-commits-parser@4.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 1.0.1 + meow: 8.1.2 + split2: 3.2.2 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + conventional-recommended-bump@7.0.1: + dependencies: + concat-stream: 2.0.0 + conventional-changelog-preset-loader: 3.0.0 + conventional-commits-filter: 3.0.0 + conventional-commits-parser: 4.0.0 + git-raw-commits: 3.0.0 + git-semver-tags: 5.0.1 + meow: 8.1.2 + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.7 + + core-util-is@1.0.3: {} + + cosmiconfig-typescript-loader@6.3.0(@types/node@22.20.1)(cosmiconfig@9.0.2(typescript@5.7.3))(typescript@5.7.3): + dependencies: + '@types/node': 22.20.1 + cosmiconfig: 9.0.2(typescript@5.7.3) + jiti: 2.6.1 + typescript: 5.7.3 + + cosmiconfig@9.0.0(typescript@5.7.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.7.3 + + cosmiconfig@9.0.2(typescript@5.7.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.7.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + dargs@7.0.0: {} + + dargs@8.1.0: {} + + dateformat@3.0.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize-keys@1.1.1: + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + + decamelize@1.2.0: {} + + dedent@1.5.3: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-lazy-prop@2.0.0: {} + + delayed-stream@1.0.0: {} + + dependency-cruiser@16.10.4: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + acorn-jsx-walk: 2.0.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + ajv: 8.20.0 + commander: 13.1.0 + enhanced-resolve: 5.24.4 + ignore: 7.0.6 + interpret: 3.1.1 + is-installed-globally: 1.0.0 + json5: 2.2.3 + memoize: 10.2.0 + picocolors: 1.1.1 + picomatch: 4.0.5 + prompts: 2.4.2 + rechoir: 0.8.0 + safe-regex: 2.1.1 + semver: 7.8.5 + teamcity-service-messages: 0.1.14 + tsconfig-paths-webpack-plugin: 4.2.0 + watskeburt: 4.2.3 + + deprecation@2.3.1: {} + + detect-indent@5.0.0: {} + + diff-sequences@29.6.3: {} + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.397: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + enquirer@2.3.6: + dependencies: + ansi-colors: 4.1.3 + + env-paths@2.2.1: {} + + envinfo@7.13.0: {} + + environment@1.1.0: {} + + err-code@2.0.3: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.6.1)): + dependencies: + eslint: 9.39.5(jiti@2.6.1) + + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.14.0 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1)): + dependencies: + '@typescript-eslint/types': 8.65.0 + comment-parser: 1.4.7 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.6.1) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + is-glob: 4.0.3 + minimatch: 10.2.5 + semver: 7.8.5 + stable-hash-x: 0.2.0 + unrs-resolver: 1.12.2 + optionalDependencies: + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@57.0.0(eslint@9.39.5(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.6.1)) + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 9.39.5(jiti@2.6.1) + esquery: 1.7.0 + globals: 15.15.0 + indent-string: 5.0.0 + is-builtin-module: 4.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + read-package-up: 11.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.12.0 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + execa@5.0.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.0 + human-signals: 2.1.0 + is-stream: 2.0.0 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + expect-type@1.4.0: {} + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.4: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up-simple@1.0.1: {} + + find-up@2.1.0: + dependencies: + locate-path: 2.0.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.3 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.4.3: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + front-matter@4.0.2: + dependencies: + js-yaml: 3.15.0 + + fs-constants@1.0.0: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-pkg-repo@4.2.1: + dependencies: + '@hutson/parse-repository-url': 3.0.2 + hosted-git-info: 4.1.0 + through2: 2.0.5 + yargs: 16.2.2 + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.0: {} + + get-stream@8.0.1: {} + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + git-raw-commits@3.0.0: + dependencies: + dargs: 7.0.0 + meow: 8.1.2 + split2: 3.2.2 + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + git-remote-origin-url@2.0.0: + dependencies: + gitconfiglocal: 1.0.0 + pify: 2.3.0 + + git-semver-tags@5.0.1: + dependencies: + meow: 8.1.2 + semver: 7.8.5 + + git-up@7.0.0: + dependencies: + is-ssh: 1.4.1 + parse-url: 8.1.0 + + git-url-parse@14.0.0: + dependencies: + git-up: 7.0.0 + + gitconfiglocal@1.0.0: + dependencies: + ini: 1.3.8 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + hard-rejection@2.1.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hosted-git-info@2.8.9: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-walk@6.0.5: + dependencies: + minimatch: 9.0.9 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.1.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + indent-string@5.0.0: {} + + index-to-position@1.2.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.1: {} + + ini@4.1.3: {} + + init-package-json@6.0.3: + dependencies: + '@npmcli/package-json': 5.2.0 + npm-package-arg: 11.0.2 + promzard: 1.0.2 + read: 3.0.1 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + validate-npm-package-name: 5.0.1 + transitivePeerDependencies: + - bluebird + + inquirer@8.2.7(@types/node@22.20.1): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@22.20.1) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + + interpret@3.1.1: {} + + ip-address@10.3.1: {} + + is-arrayish@0.2.1: {} + + is-builtin-module@4.0.0: + dependencies: + builtin-modules: 4.0.0 + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + + is-interactive@1.0.0: {} + + is-lambda@1.0.1: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@1.1.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-ssh@1.4.1: + dependencies: + protocols: 2.0.2 + + is-stream@2.0.0: {} + + is-stream@3.0.0: {} + + is-text-path@1.0.1: + dependencies: + text-extensions: 1.9.0 + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + is-unicode-supported@0.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + isobject@3.0.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.0 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-get-type@29.6.3: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.0.2: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-parse-even-better-errors@3.0.2: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-nice@1.1.4: {} + + json-stringify-safe@5.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.0: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonparse@1.3.1: {} + + just-diff-apply@5.5.0: {} + + just-diff@6.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + kleur@3.0.3: {} + + lerna@8.2.4(@types/node@22.20.1)(encoding@0.1.13): + dependencies: + '@lerna/create': 8.2.4(@types/node@22.20.1)(encoding@0.1.13)(typescript@5.7.3) + '@npmcli/arborist': 7.5.4 + '@npmcli/package-json': 5.2.0 + '@npmcli/run-script': 8.1.0 + '@nx/devkit': 20.8.4(nx@20.8.4) + '@octokit/plugin-enterprise-rest': 6.0.1 + '@octokit/rest': 20.1.2 + aproba: 2.0.0 + byte-size: 8.1.1 + chalk: 4.1.0 + clone-deep: 4.0.1 + cmd-shim: 6.0.3 + color-support: 1.1.3 + columnify: 1.6.0 + console-control-strings: 1.1.0 + conventional-changelog-angular: 7.0.0 + conventional-changelog-core: 5.0.1 + conventional-recommended-bump: 7.0.1 + cosmiconfig: 9.0.0(typescript@5.7.3) + dedent: 1.5.3 + envinfo: 7.13.0 + execa: 5.0.0 + fs-extra: 11.4.0 + get-port: 5.1.1 + get-stream: 6.0.0 + git-url-parse: 14.0.0 + glob-parent: 6.0.2 + graceful-fs: 4.2.11 + has-unicode: 2.0.1 + import-local: 3.1.0 + ini: 1.3.8 + init-package-json: 6.0.3 + inquirer: 8.2.7(@types/node@22.20.1) + is-ci: 3.0.1 + is-stream: 2.0.0 + jest-diff: 29.7.0 + js-yaml: 4.1.0 + libnpmaccess: 8.0.6 + libnpmpublish: 9.0.9 + load-json-file: 6.2.0 + make-dir: 4.0.0 + minimatch: 3.0.5 + multimatch: 5.0.0 + node-fetch: 2.6.7(encoding@0.1.13) + npm-package-arg: 11.0.2 + npm-packlist: 8.0.2 + npm-registry-fetch: 17.1.0 + nx: 20.8.4 + p-map: 4.0.0 + p-map-series: 2.1.0 + p-pipe: 3.1.0 + p-queue: 6.6.2 + p-reduce: 2.1.0 + p-waterfall: 2.1.1 + pacote: 18.0.6 + pify: 5.0.0 + read-cmd-shim: 4.0.0 + resolve-from: 5.0.0 + rimraf: 4.4.1 + semver: 7.8.5 + set-blocking: 2.0.0 + signal-exit: 3.0.7 + slash: 3.0.0 + ssri: 10.0.6 + string-width: 4.2.3 + tar: 6.2.1 + temp-dir: 1.0.0 + through: 2.3.8 + tinyglobby: 0.2.12 + typescript: 5.7.3 + upath: 2.0.1 + uuid: 10.0.0 + validate-npm-package-license: 3.0.4 + validate-npm-package-name: 5.0.1 + wide-align: 1.1.5 + write-file-atomic: 5.0.1 + write-pkg: 4.0.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@swc-node/register' + - '@swc/core' + - '@types/node' + - babel-plugin-macros + - bluebird + - debug + - encoding + - supports-color + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libnpmaccess@8.0.6: + dependencies: + npm-package-arg: 11.0.2 + npm-registry-fetch: 17.1.0 + transitivePeerDependencies: + - supports-color + + libnpmpublish@9.0.9: + dependencies: + ci-info: 4.4.0 + normalize-package-data: 6.0.2 + npm-package-arg: 11.0.2 + npm-registry-fetch: 17.1.0 + proc-log: 4.2.0 + semver: 7.8.5 + sigstore: 2.3.1 + ssri: 10.0.6 + transitivePeerDependencies: + - supports-color + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lines-and-columns@2.0.3: {} + + lint-staged@15.5.2: + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.3 + micromatch: 4.0.8 + pidtree: 0.6.1 + string-argv: 0.3.2 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + listr2@8.3.3: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + + load-json-file@6.2.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 5.2.0 + strip-bom: 4.0.0 + type-fest: 0.6.0 + + locate-path@2.0.0: + dependencies: + p-locate: 2.0.0 + path-exists: 3.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.ismatch@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.kebabcase@4.1.1: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.0 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-fetch-happen@13.0.1: + dependencies: + '@npmcli/agent': 2.2.2 + cacache: 18.0.4 + http-cache-semantics: 4.2.0 + is-lambda: 1.0.1 + minipass: 7.1.3 + minipass-fetch: 3.0.5 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + proc-log: 4.2.0 + promise-retry: 2.0.1 + ssri: 10.0.6 + transitivePeerDependencies: + - supports-color + + map-obj@1.0.1: {} + + map-obj@4.3.0: {} + + math-intrinsics@1.1.0: {} + + memoize@10.2.0: + dependencies: + mimic-function: 5.0.1 + + meow@12.1.1: {} + + meow@8.1.2: + dependencies: + '@types/minimist': 1.2.5 + camelcase-keys: 6.2.2 + decamelize-keys: 1.1.1 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.0.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimatch@8.0.7: + dependencies: + brace-expansion: 2.1.2 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.1.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minimist-options@4.1.0: + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + + minimist@1.2.8: {} + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@3.0.5: + dependencies: + minipass: 7.1.3 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@4.2.8: {} + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + modify-values@1.0.1: {} + + ms@2.1.3: {} + + multimatch@5.0.0: + dependencies: + '@types/minimatch': 3.0.5 + array-differ: 3.0.0 + array-union: 2.1.0 + arrify: 2.0.1 + minimatch: 3.0.5 + + mute-stream@0.0.8: {} + + mute-stream@1.0.0: {} + + nanoid@3.3.16: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.4: {} + + neo-async@2.6.2: {} + + node-fetch@2.6.7(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + + node-gyp@10.3.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + make-fetch-happen: 13.0.1 + nopt: 7.2.1 + proc-log: 4.2.0 + semver: 7.8.5 + tar: 6.2.1 + which: 4.0.0 + transitivePeerDependencies: + - supports-color + + node-machine-id@1.1.12: {} + + node-releases@2.0.51: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.12 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + + normalize-package-data@3.0.3: + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.16.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + npm-bundled@3.0.1: + dependencies: + npm-normalize-package-bin: 3.0.1 + + npm-install-checks@6.3.0: + dependencies: + semver: 7.8.5 + + npm-normalize-package-bin@3.0.1: {} + + npm-package-arg@11.0.2: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + validate-npm-package-name: 5.0.1 + + npm-packlist@8.0.2: + dependencies: + ignore-walk: 6.0.5 + + npm-pick-manifest@9.1.0: + dependencies: + npm-install-checks: 6.3.0 + npm-normalize-package-bin: 3.0.1 + npm-package-arg: 11.0.2 + semver: 7.8.5 + + npm-registry-fetch@17.1.0: + dependencies: + '@npmcli/redact': 2.0.1 + jsonparse: 1.3.1 + make-fetch-happen: 13.0.1 + minipass: 7.1.3 + minipass-fetch: 3.0.5 + minizlib: 2.1.2 + npm-package-arg: 11.0.2 + proc-log: 4.2.0 + transitivePeerDependencies: + - supports-color + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nx@20.8.4: + dependencies: + '@napi-rs/wasm-runtime': 0.2.4 + '@yarnpkg/lockfile': 1.1.0 + '@yarnpkg/parsers': 3.0.2 + '@zkochan/js-yaml': 0.0.7 + axios: 1.18.1 + chalk: 4.1.0 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + cliui: 8.0.1 + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + enquirer: 2.3.6 + figures: 3.2.0 + flat: 5.0.2 + front-matter: 4.0.2 + ignore: 5.3.2 + jest-diff: 29.7.0 + jsonc-parser: 3.2.0 + lines-and-columns: 2.0.3 + minimatch: 9.0.3 + node-machine-id: 1.1.12 + npm-run-path: 4.0.1 + open: 8.4.2 + ora: 5.3.0 + resolve.exports: 2.0.3 + semver: 7.8.5 + string-width: 4.2.3 + tar-stream: 2.2.0 + tmp: 0.2.7 + tsconfig-paths: 4.2.0 + tslib: 2.8.1 + yaml: 2.9.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + optionalDependencies: + '@nx/nx-darwin-arm64': 20.8.4 + '@nx/nx-darwin-x64': 20.8.4 + '@nx/nx-freebsd-x64': 20.8.4 + '@nx/nx-linux-arm-gnueabihf': 20.8.4 + '@nx/nx-linux-arm64-gnu': 20.8.4 + '@nx/nx-linux-arm64-musl': 20.8.4 + '@nx/nx-linux-x64-gnu': 20.8.4 + '@nx/nx-linux-x64-musl': 20.8.4 + '@nx/nx-win32-arm64-msvc': 20.8.4 + '@nx/nx-win32-x64-msvc': 20.8.4 + transitivePeerDependencies: + - debug + - supports-color + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.3.0: + dependencies: + bl: 4.1.0 + chalk: 4.1.0 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + is-interactive: 1.0.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-finally@1.0.0: {} + + p-limit@1.3.0: + dependencies: + p-try: 1.0.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@2.0.0: + dependencies: + p-limit: 1.3.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map-series@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-pipe@3.1.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-reduce@2.1.0: {} + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-try@1.0.0: {} + + p-try@2.2.0: {} + + p-waterfall@2.1.1: + dependencies: + p-reduce: 2.1.0 + + package-json-from-dist@1.0.1: {} + + pacote@18.0.6: + dependencies: + '@npmcli/git': 5.0.8 + '@npmcli/installed-package-contents': 2.1.0 + '@npmcli/package-json': 5.2.0 + '@npmcli/promise-spawn': 7.0.2 + '@npmcli/run-script': 8.1.0 + cacache: 18.0.4 + fs-minipass: 3.0.3 + minipass: 7.1.3 + npm-package-arg: 11.0.2 + npm-packlist: 8.0.2 + npm-pick-manifest: 9.1.0 + npm-registry-fetch: 17.1.0 + proc-log: 4.2.0 + promise-retry: 2.0.1 + sigstore: 2.3.1 + ssri: 10.0.6 + tar: 6.2.1 + transitivePeerDependencies: + - bluebird + - supports-color + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-conflict-json@3.0.1: + dependencies: + json-parse-even-better-errors: 3.0.2 + just-diff: 6.0.2 + just-diff-apply: 5.5.0 + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-path@7.1.0: + dependencies: + protocols: 2.0.2 + + parse-url@8.1.0: + dependencies: + parse-path: 7.1.0 + + path-exists@3.0.0: {} + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-type@3.0.0: + dependencies: + pify: 3.0.0 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pidtree@0.6.1: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pify@4.0.1: {} + + pify@5.0.0: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pluralize@8.0.0: {} + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.5: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + process-nextick-args@2.0.1: {} + + proggy@2.0.0: {} + + promise-all-reject-late@1.0.1: {} + + promise-call-limit@3.0.2: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + promzard@1.0.2: + dependencies: + read: 3.0.1 + + protocols@2.0.2: {} + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + quick-lru@4.0.1: {} + + react-is@18.3.1: {} + + read-cmd-shim@4.0.0: {} + + read-package-json-fast@3.0.2: + dependencies: + json-parse-even-better-errors: 3.0.2 + npm-normalize-package-bin: 3.0.1 + + read-package-up@11.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 9.0.1 + type-fest: 4.41.0 + + read-pkg-up@3.0.0: + dependencies: + find-up: 2.1.0 + read-pkg: 3.0.0 + + read-pkg-up@7.0.1: + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + + read-pkg@3.0.0: + dependencies: + load-json-file: 4.0.0 + normalize-package-data: 2.5.0 + path-type: 3.0.0 + + read-pkg@5.2.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + + read@3.0.1: + dependencies: + mute-stream: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp-tree@0.1.27: {} + + regjsparser@0.12.0: + dependencies: + jsesc: 3.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + rfdc@1.4.1: {} + + rimraf@4.4.1: + dependencies: + glob: 9.3.5 + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + run-async@2.4.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + + safer-buffer@2.1.2: {} + + semver@5.7.2: {} + + semver@7.8.5: {} + + set-blocking@2.0.0: {} + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sigstore@2.3.1: + dependencies: + '@sigstore/bundle': 2.3.2 + '@sigstore/core': 1.1.0 + '@sigstore/protobuf-specs': 0.3.3 + '@sigstore/sign': 2.3.2 + '@sigstore/tuf': 2.3.4 + '@sigstore/verify': 1.2.1 + transitivePeerDependencies: + - supports-color + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.3.1 + smart-buffer: 4.2.0 + + sort-keys@2.0.0: + dependencies: + is-plain-obj: 1.1.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@3.2.2: + dependencies: + readable-stream: 3.6.2 + + split2@4.2.0: {} + + split@1.0.1: + dependencies: + through: 2.3.8 + + sprintf-js@1.0.3: {} + + ssri@10.0.6: + dependencies: + minipass: 7.1.3 + + stable-hash-x@0.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tapable@2.3.3: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + teamcity-service-messages@0.1.14: {} + + temp-dir@1.0.0: {} + + text-extensions@1.9.0: {} + + text-extensions@2.4.0: {} + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.12: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tmp@0.2.7: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + treeverse@3.0.0: {} + + trim-newlines@3.0.1: {} + + ts-api-utils@2.5.0(typescript@5.7.3): + dependencies: + typescript: 5.7.3 + + tsconfck@3.1.6(typescript@5.7.3): + optionalDependencies: + typescript: 5.7.3 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.4 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tuf-js@2.2.1: + dependencies: + '@tufjs/models': 2.0.1 + debug: 4.4.3 + make-fetch-happen: 13.0.1 + transitivePeerDependencies: + - supports-color + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.18.1: {} + + type-fest@0.21.3: {} + + type-fest@0.4.1: {} + + type-fest@0.6.0: {} + + type-fest@0.8.1: {} + + type-fest@4.41.0: {} + + typedarray@0.0.6: {} + + typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.7.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.7.3) + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + typescript@5.7.3: {} + + uglify-js@3.19.3: + optional: true + + undici-types@6.21.0: {} + + unicorn-magic@0.1.0: {} + + unique-filename@3.0.0: + dependencies: + unique-slug: 4.0.0 + + unique-slug@4.0.0: + dependencies: + imurmurhash: 0.1.4 + + universal-user-agent@6.0.1: {} + + universalify@2.0.1: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + upath@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@5.0.1: {} + + vite-node@3.2.4(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.7.3) + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + + vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.24 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.6.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + walk-up-path@3.0.1: {} + + watskeburt@4.2.3: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@2.4.3: + dependencies: + graceful-fs: 4.2.11 + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + write-json-file@3.2.0: + dependencies: + detect-indent: 5.0.0 + graceful-fs: 4.2.11 + make-dir: 2.1.0 + pify: 4.0.1 + sort-keys: 2.0.0 + write-file-atomic: 2.4.3 + + write-pkg@4.0.0: + dependencies: + sort-keys: 2.0.0 + type-fest: 0.4.1 + write-json-file: 3.2.0 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yaml@2.9.0: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..87adc02 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,44 @@ +packages: + - 'packages/**' + +catalog: + # TypeScript + typescript: ~5.7.3 + + # Node types + '@types/node': ^22.13.0 + + # ESLint (Flat Config) + eslint: ^9.19.0 + '@eslint/js': ^9.19.0 + typescript-eslint: ^8.22.0 + '@typescript-eslint/eslint-plugin': ^8.22.0 + '@typescript-eslint/parser': ^8.22.0 + # Pinned exact: 10.1.6 and 10.1.7 were compromised in the 2025 npm + # supply-chain attack. Do not widen to a caret range that includes them. + eslint-config-prettier: 10.1.8 + eslint-plugin-import-x: ^4.6.1 + eslint-plugin-unicorn: ^57.0.0 + globals: ^15.14.0 + + # Code quality + dependency-cruiser: ^16.9.0 + prettier: ^3.4.2 + + # Release tooling + lerna: ^8.2.3 + husky: ^9.1.7 + lint-staged: ^15.5.2 + '@commitlint/cli': ^19.8.1 + '@commitlint/config-conventional': ^19.8.1 + + # Utilities + rimraf: ^6.0.1 + + # Unit testing + vitest: ^3.0.5 + vite-tsconfig-paths: ^5.1.4 + + # Library runtime deps (used by @earsyntax/extract for YAML input parsing) + js-yaml: ^4.1.0 + '@types/js-yaml': ^4.0.9 diff --git a/scripts/agentic-loop-demo.sh b/scripts/agentic-loop-demo.sh new file mode 100755 index 0000000..e954899 --- /dev/null +++ b/scripts/agentic-loop-demo.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Host-native agentic loop demo. +# +# Walks the earsyntax loop against a Kiro spec repo: detect the host, render the +# agent and host integration files, extract candidates, validate (and fail), +# repair the broken criterion, revalidate until clean, then emit SARIF for CI. +# +# Knobs: +# PAUSE=0 skip the between-step pauses (default 1, pauses on a TTY) +# RUN_CLAUDE=1 call Claude Code for the repair step (default 0, deterministic) +# CLAUDE_BIN the Claude binary to invoke (default: claude) +# DEMO_DIR reuse a specific temp dir instead of a fresh mktemp one + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)" +CLI="$REPO_ROOT/packages/cli/bin/run.js" + +TMP_PARENT="${TMPDIR:-/tmp}" +TMP_PARENT="${TMP_PARENT%/}" +# A caller-supplied DEMO_DIR is preserved on exit; a fresh mktemp one is removed. +DEMO_DIR_KEEP="${DEMO_DIR+1}" +DEMO_DIR="${DEMO_DIR:-$(mktemp -d "$TMP_PARENT/earsyntax-agentic-loop.XXXXXX")}" + +SPEC_REL=".kiro/specs/checkout/requirements.md" +SPEC_GLOB=".kiro/specs/**/requirements.md" +SARIF_FILE="earsyntax.sarif" +PROFILE="kiro" + +CLAUDE_BIN="${CLAUDE_BIN:-claude}" +PAUSE="${PAUSE:-1}" +RUN_CLAUDE="${RUN_CLAUDE:-0}" + +# The single broken criterion (no `shall`, so EARS-E007) and its exact repair. +BROKEN_LINE='3. WHEN a shopper submits payment details THE SYSTEM creates a paid order and sends a confirmation email.' +FIXED_LINE='3. WHEN a shopper submits payment details THE SYSTEM SHALL create a paid order.' + +if { [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; } || [[ "${FORCE_COLOR:-0}" == "1" ]]; then + BOLD=$'\033[1m' + DIM=$'\033[2m' + RED=$'\033[31m' + GREEN=$'\033[32m' + YELLOW=$'\033[33m' + BLUE=$'\033[34m' + MAGENTA=$'\033[35m' + CYAN=$'\033[36m' + RESET=$'\033[0m' +else + BOLD='' + DIM='' + RED='' + GREEN='' + YELLOW='' + BLUE='' + MAGENTA='' + CYAN='' + RESET='' +fi + +cleanup() { + # Only remove a temp dir this run created; a caller-supplied DEMO_DIR is left. + if [[ -z "${DEMO_DIR_KEEP:-}" && -n "${DEMO_DIR:-}" && -d "$DEMO_DIR" ]]; then + rm -rf "$DEMO_DIR" + fi +} +trap cleanup EXIT + +pause_here() { + if [[ "$PAUSE" == "0" || ! -t 0 ]]; then + return 0 + fi + + printf "\n%sPress any key or Enter to continue...%s" "$DIM" "$RESET" + IFS= read -rsn1 _ || true + printf "\n" +} + +section() { + local title="$1" + local detail="${2:-}" + + printf "\n%s%s%s\n" "$BOLD$CYAN" "$title" "$RESET" + if [[ -n "$detail" ]]; then + printf "%s%s%s\n" "$DIM" "$detail" "$RESET" + fi + pause_here +} + +note() { + printf "%s%s%s\n" "$DIM" "$1" "$RESET" +} + +success() { + printf "%s%s%s\n" "$GREEN" "$1" "$RESET" +} + +warn() { + printf "%s%s%s\n" "$YELLOW" "$1" "$RESET" +} + +print_cmd() { + printf "\n%s$ earsyntax" "$BLUE" + for arg in "$@"; do + printf " %q" "$arg" + done + printf "%s\n" "$RESET" +} + +# Invoke the built CLI without a global install. Runs from the demo repo root so +# the relative Kiro paths and the printed commands are the ones a user would run. +earsyntax() { + node "$CLI" "$@" +} + +run_earsyntax() { + print_cmd "$@" + earsyntax "$@" +} + +write_kiro_spec() { + mkdir -p "$DEMO_DIR/.kiro/specs/checkout" + cat >"$DEMO_DIR/$SPEC_REL" <"$target.tmp" + mv "$target.tmp" "$target" +} + +# Try Claude Code for the repair; fall back to the deterministic edit. +run_repair() { + local prompt="/earsyntax-repair $SPEC_REL --profile $PROFILE" + + printf "\n%s$ %q -p %q%s\n" "$MAGENTA" "$CLAUDE_BIN" "$prompt" "$RESET" + if [[ "$RUN_CLAUDE" != "1" ]]; then + warn "# skipped: set RUN_CLAUDE=1 to call Claude Code; applying the deterministic repair instead" + deterministic_repair + return 0 + fi + + if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then + warn "# Claude binary not found ($CLAUDE_BIN); applying the deterministic repair instead" + deterministic_repair + return 0 + fi + + note "# Claude Code is now in control inside the spec repo." + ( cd "$DEMO_DIR" && "$CLAUDE_BIN" -p "$prompt" ) || \ + warn "# Claude Code exited nonzero; the revalidation loop will repair deterministically if needed" +} + +# Validate the spec. Returns the CLI exit code without tripping set -e. +validate_spec() { + print_cmd validate "$SPEC_GLOB" --profile "$PROFILE" + local code=0 + earsyntax validate "$SPEC_GLOB" --profile "$PROFILE" || code=$? + return "$code" +} + +printf "%sHost-native earsyntax loop demo%s\n" "$BOLD$CYAN" "$RESET" +printf "Spec repo: %s%s%s\n" "$GREEN" "$DEMO_DIR" "$RESET" +printf "Repair mode: %s%s%s\n" "$MAGENTA" "$([[ "$RUN_CLAUDE" == "1" ]] && echo "Claude Code" || echo "deterministic")" "$RESET" +printf "Pauses: %s%s%s\n" "$YELLOW" "$([[ "$PAUSE" == "0" ]] && echo "off" || echo "on")" "$RESET" + +write_kiro_spec +cd "$DEMO_DIR" + +section "1. A Kiro spec repo" "The feature spec lives at $SPEC_REL. One acceptance criterion is missing its shall boundary." +note "$SPEC_REL" +cat "$SPEC_REL" + +section "2. Detect the host" "doctor scans the repo, recognizes Kiro from .kiro/specs, and recommends commands." +run_earsyntax doctor + +section "3. Render the integration files" "init writes the Claude slash commands and the Kiro steering plus validate hook. Idempotent." +run_earsyntax init --agent claude --host "$PROFILE" + +section "4. Extract the candidates" "extract shows exactly which lines the Kiro profile treats as requirements." +run_earsyntax extract "$SPEC_GLOB" --profile "$PROFILE" --json + +section "5. Validate (expected to fail)" "The third criterion has no shall, so validation reports EARS-E007 and exits 1." +if validate_spec; then + warn "# unexpected: the spec validated clean before repair" +else + note "# validation failed as expected (exit 1); moving to repair" +fi + +section "6. Repair the broken criterion" "With RUN_CLAUDE=1 Claude Code runs /earsyntax-repair. Otherwise the deterministic edit fixes the line." +run_repair + +section "7. Revalidate until clean" "Loop validate then repair until the spec passes, so a partial agent edit still converges." +attempt=1 +max_attempts=3 +while true; do + note "# revalidation attempt $attempt" + if validate_spec; then + success "# spec is clean (exit 0)" + break + fi + if [[ "$attempt" -ge "$max_attempts" ]]; then + warn "# still failing after $attempt attempts; forcing the deterministic repair" + deterministic_repair + validate_spec + success "# spec is clean (exit 0)" + break + fi + warn "# still failing; repairing again" + deterministic_repair + attempt=$((attempt + 1)) +done + +section "8. Emit SARIF for CI" "The same validation writes a SARIF 2.1.0 log a code-scanning step can upload." +print_cmd validate "$SPEC_GLOB" --profile "$PROFILE" --sarif +earsyntax validate "$SPEC_GLOB" --profile "$PROFILE" --sarif >"$SARIF_FILE" +note "# wrote $SARIF_FILE ($(wc -c <"$SARIF_FILE" | tr -d ' ') bytes); first lines:" +head -n 12 "$SARIF_FILE" +note "..." +note "# CI step: upload $SARIF_FILE with github/codeql-action/upload-sarif so findings surface in the Security tab." + +section "9. Wire it into CI" "One command gates every push; a nonzero exit fails the job." +note "earsyntax validate \"$SPEC_GLOB\" --profile $PROFILE --sarif > $SARIF_FILE" + +echo +success "Demo complete. Spec repo: $DEMO_DIR" diff --git a/specs/checkout.md b/specs/checkout.md new file mode 100644 index 0000000..dc7a8d2 --- /dev/null +++ b/specs/checkout.md @@ -0,0 +1,19 @@ +# Checkout webhooks + +Source specification for checkout webhook handling in the billing service. + +## Behavior + +When a payment webhook is received, the billing service must verify the HMAC +signature before doing anything else. + +If the HMAC signature is invalid, the webhook must be rejected. + +When a webhook arrives, validate the signature, persist the event, and enqueue +a processing job. + +While the payment provider is unavailable, queued events should be retried. + +Where dunning management is enabled, declined charges are retried. + +When a payment is declined, the customer should be notified quickly. diff --git a/test/conformance/demo.test.ts b/test/conformance/demo.test.ts new file mode 100644 index 0000000..1172418 --- /dev/null +++ b/test/conformance/demo.test.ts @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { REPO_ROOT } from './helpers.js'; + +/** + * Demo smoke. + * + * The agentic-loop demo is the end-to-end story: detect the host, render files, + * extract, validate, repair, revalidate, emit SARIF. In deterministic mode + * (RUN_CLAUDE=0) with pauses off it must run to completion and exit 0. + */ + +const DEMO_SCRIPT = join(REPO_ROOT, 'scripts/agentic-loop-demo.sh'); + +describe('agentic-loop demo', () => { + it('runs to completion deterministically with no pauses', () => { + expect(existsSync(DEMO_SCRIPT)).toBe(true); + const result = spawnSync('bash', [DEMO_SCRIPT], { + cwd: REPO_ROOT, + env: { ...process.env, RUN_CLAUDE: '0', PAUSE: '0' }, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + if (result.status !== 0) { + // Surface the tail so a failing demo is diagnosable from the test log. + const output = `${result.stdout}\n${result.stderr}`; + throw new Error(`agentic-loop-demo.sh exited ${result.status}:\n${output.slice(-2000)}`); + } + expect(result.status).toBe(0); + }); +}); diff --git a/test/conformance/explain-coverage.test.ts b/test/conformance/explain-coverage.test.ts new file mode 100644 index 0000000..fe872db --- /dev/null +++ b/test/conformance/explain-coverage.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { runCliJson } from './helpers.js'; +// Data-only import of the built registry (not a command internal): the same +// frozen table `explain` resolves against. Requires `pnpm build` to have run, +// which the conformance chain guarantees before this suite executes. +import { DIAGNOSTIC_REGISTRY } from '../../packages/core/dist/index.js'; + +/** + * Explain coverage. + * + * Every registry id must resolve through `explain`, and every deprecated alias + * (the entry's old dotted code) must resolve to the same current id and carry a + * deprecation note. This is a thin runner over the registry, not a duplicate of + * the unit logic: it proves the shipped binary answers for the whole table. + */ + +interface RegistryEntry { + id: string; + oldCode: string; +} + +const entries = DIAGNOSTIC_REGISTRY as RegistryEntry[]; + +describe('explain resolves every registry id', () => { + it.each(entries.map((entry) => entry.id))('explains %s', (id) => { + const { result, json } = runCliJson(['explain', id, '--json']); + expect(result.code).toBe(0); + expect(json.id).toBe(id); + }); +}); + +describe('explain resolves every deprecated alias', () => { + it.each(entries.map((entry) => [entry.oldCode, entry.id] as const))( + 'resolves alias %s to %s', + (oldCode, id) => { + const { result, json } = runCliJson(['explain', oldCode, '--json']); + expect(result.code).toBe(0); + expect(json.id).toBe(id); + expect(json.alias).toBe(true); + expect(String(json.deprecationNote)).toContain('deprecated alias'); + }, + ); +}); diff --git a/test/conformance/helpers.ts b/test/conformance/helpers.ts new file mode 100644 index 0000000..0404505 --- /dev/null +++ b/test/conformance/helpers.ts @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +/** + * Shared helpers for the conformance smoke suite. + * + * Every helper spawns the real built binary through `node packages/cli/bin/run.js` + * so the tests exercise exactly what a published install would run. No command + * module is imported here: the only contract under test is the process boundary + * (argv in, exit code and stdout out). + */ + +/** Absolute path to the repo root, resolved from this file's location. */ +export const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); + +/** Absolute path to the CLI entrypoint the published `earsyntax` bin points at. */ +export const CLI_BIN = fileURLToPath(new URL('../../packages/cli/bin/run.js', import.meta.url)); + +/** Result of one CLI invocation. */ +export interface CliResult { + /** Process exit code. `null` only if the process was killed by a signal. */ + code: number | null; + /** Captured stdout. */ + stdout: string; + /** Captured stderr. */ + stderr: string; +} + +/** Options for a single CLI invocation. */ +export interface RunOptions { + /** Data piped to the process stdin (for `validate -` and other stdin routes). */ + input?: string; + /** Working directory. Defaults to the repo root. */ + cwd?: string; +} + +/** Spawn the CLI with the given argv and return its exit code and streams. */ +export function runCli(args: string[], options: RunOptions = {}): CliResult { + const result = spawnSync(process.execPath, [CLI_BIN, ...args], { + cwd: options.cwd ?? REPO_ROOT, + input: options.input, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +/** Run the CLI and parse its stdout as the JSON envelope, failing loudly on bad JSON. */ +export function runCliJson( + args: string[], + options: RunOptions = {}, +): { + result: CliResult; + json: Record; +} { + const result = runCli(args, options); + let json: Record; + try { + json = JSON.parse(result.stdout) as Record; + } catch (cause) { + throw new Error( + `CLI stdout was not valid JSON for args [${args.join(' ')}]:\n${result.stdout}\n${result.stderr}`, + { cause }, + ); + } + return { result, json }; +} diff --git a/test/conformance/profile-matrix.test.ts b/test/conformance/profile-matrix.test.ts new file mode 100644 index 0000000..1c5ab69 --- /dev/null +++ b/test/conformance/profile-matrix.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { REPO_ROOT, runCli } from './helpers.js'; + +/** + * Profile fixture matrix. + * + * Each profile has a host document that validates clean under its own profile. + * The kiro document additionally witnesses the strict/kiro split: strict is + * markdown-blind, so the same acceptance-criteria markdown fed through the + * documented stdin/text route lints as plain prose and fails. This encodes the + * documented semantics; it does not try to make strict read markdown structure. + */ + +interface MatrixRow { + profile: string; + fixture: string; +} + +const CLEAN_UNDER_OWN_PROFILE: MatrixRow[] = [ + { profile: 'strict', fixture: 'fixtures/profiles/strict/valid.ears' }, + { profile: 'ears-x', fixture: 'fixtures/profiles/ears-x/prohibition.ears' }, + { profile: 'ears-x', fixture: 'fixtures/profiles/ears-x/frame-metadata.ears' }, + { profile: 'kiro', fixture: 'fixtures/profiles/kiro/requirements.md' }, + { profile: 'speckit', fixture: 'fixtures/profiles/speckit/spec.md' }, + { profile: 'openspec', fixture: 'fixtures/profiles/openspec/spec.md' }, +]; + +describe('profile fixture matrix: clean under own profile', () => { + it.each(CLEAN_UNDER_OWN_PROFILE)( + 'validates $fixture clean under $profile', + ({ profile, fixture }) => { + const result = runCli(['validate', join(REPO_ROOT, fixture), '--profile', profile]); + expect(result.code).toBe(0); + }, + ); +}); + +describe('strict-dialect text lint', () => { + it('fails the Kiro acceptance-criteria markdown fed as text through stdin', () => { + // strict does not locate markdown structure, so it lints every non-empty line + // as a candidate requirement. The Kiro doc's prose lines are not valid EARS, + // so the run produces error findings and exits 1. + const kiroDoc = readFileSync( + join(REPO_ROOT, 'fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md'), + 'utf8', + ); + const result = runCli(['validate', '-', '--profile', 'strict'], { input: kiroDoc }); + expect(result.code).toBe(1); + }); +}); diff --git a/test/conformance/smoke.test.ts b/test/conformance/smoke.test.ts new file mode 100644 index 0000000..f8f4354 --- /dev/null +++ b/test/conformance/smoke.test.ts @@ -0,0 +1,228 @@ +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { REPO_ROOT, runCli, runCliJson } from './helpers.js'; + +/** + * CLI smoke conformance. + * + * Proves the frozen alpha facade at the process boundary: the eight commands are + * present and nothing outside them is, the exit-code contract (0/1/2) holds, the + * JSON envelopes parse, the tool is stateless (validates from stdin in an empty + * directory and never writes a workspace), and the SARIF projection is schema-tagged. + */ + +const KIRO_EXTRACT_FIXTURE = join(REPO_ROOT, 'fixtures/profiles/kiro/requirements.md'); +const KIRO_HOST_REPO = join(REPO_ROOT, 'fixtures/host-repos/kiro'); +const KIRO_HOST_DOC = join( + REPO_ROOT, + 'fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md', +); + +const FACADE_COMMANDS = [ + 'validate', + 'extract', + 'instructions', + 'explain', + 'profiles', + 'doctor', + 'init', + 'version', +]; + +// Verbs and flags that belonged to the removed `.earsyntax/` workspace and +// lifecycle surface. None may reappear in help. +const FORBIDDEN_COMMANDS = ['new', 'list', 'status', 'show', 'accept', 'check']; +const FORBIDDEN_FLAGS = ['--tools', '--work']; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('help surface', () => { + it('lists exactly the eight facade commands', () => { + const help = runCli(['--help']); + expect(help.code).toBe(0); + for (const command of FACADE_COMMANDS) { + expect(help.stdout).toContain(command); + } + // "exactly eight" is proven against the feature manifest, the machine-readable + // command list, so a stray tenth command cannot slip past a substring check. + const { json } = runCliJson(['version', '--features', '--json']); + const features = json.features as Record; + expect(features.commands).toEqual(FACADE_COMMANDS); + }); + + it('advertises none of the removed workspace verbs', () => { + const help = runCli(['--help']); + for (const verb of FORBIDDEN_COMMANDS) { + // Word-boundary check so "extract" does not count as containing "act". + expect(new RegExp(`\\b${verb}\\b`).test(help.stdout)).toBe(false); + } + }); + + it('advertises none of the removed workspace flags', () => { + const help = runCli(['--help']); + for (const flag of FORBIDDEN_FLAGS) { + expect(help.stdout).not.toContain(flag); + } + }); +}); + +describe('version --features --json', () => { + it('parses and reports the profile list and sarif capability', () => { + const { result, json } = runCliJson(['version', '--features', '--json']); + expect(result.code).toBe(0); + const features = json.features as Record; + expect(Array.isArray(features.profiles)).toBe(true); + expect(features.profiles).toEqual(['strict', 'ears-x', 'kiro', 'speckit', 'openspec']); + expect(features.sarif).toBe(true); + }); +}); + +describe('validate exit-code contract', () => { + it('exits 0 for clean EARS on stdin', () => { + const result = runCli(['validate', '-'], { input: 'The system shall respond.\n' }); + expect(result.code).toBe(0); + }); + + it('exits 1 for broken EARS on stdin', () => { + const result = runCli(['validate', '-'], { input: 'When foo the bar.\n' }); + expect(result.code).toBe(1); + }); + + it('exits 2 for a missing file', () => { + const result = runCli(['validate', join(REPO_ROOT, 'does', 'not', 'exist.md')]); + expect(result.code).toBe(2); + }); + + it('validates from stdin in an empty directory and writes nothing (stateless proof)', () => { + const dir = makeTempDir('earsyntax-conf-empty-'); + const result = runCli(['validate', '-', '--cwd', dir], { + input: 'The system shall respond.\n', + cwd: dir, + }); + expect(result.code).toBe(0); + // The stateless facade never materializes a workspace to validate. + expect(readdirSync(dir)).toEqual([]); + }); +}); + +describe('extract', () => { + it('locates the nine Kiro candidates as JSON', () => { + const { result, json } = runCliJson([ + 'extract', + KIRO_EXTRACT_FIXTURE, + '--profile', + 'kiro', + '--json', + ]); + expect(result.code).toBe(0); + expect(Array.isArray(json.candidates)).toBe(true); + expect((json.candidates as unknown[]).length).toBe(9); + }); +}); + +describe('doctor', () => { + it('detects the Kiro host on the kiro fixture repo', () => { + const { result, json } = runCliJson(['doctor', '--cwd', KIRO_HOST_REPO, '--json'], { + cwd: KIRO_HOST_REPO, + }); + expect(result.code).toBe(0); + const detected = json.detected as { hosts: { host: string }[] }; + expect(detected.hosts.map((entry) => entry.host)).toContain('kiro'); + }); +}); + +describe('explain', () => { + it('resolves a current registry id', () => { + const { result, json } = runCliJson(['explain', 'EARS-E001', '--json']); + expect(result.code).toBe(0); + expect(json.id).toBe('EARS-E001'); + }); + + it('resolves a deprecated alias and flags the deprecation', () => { + const { result, json } = runCliJson(['explain', 'catalog.system_ambiguous', '--json']); + expect(result.code).toBe(0); + expect(json.id).toBe('EARS-E001'); + expect(json.alias).toBe(true); + expect(String(json.deprecationNote)).toContain('deprecated alias'); + }); +}); + +describe('profiles', () => { + it('lists the five built-in profiles as JSON', () => { + const { result, json } = runCliJson(['profiles', '--json']); + expect(result.code).toBe(0); + const profiles = json.profiles as { name: string }[]; + expect(profiles.map((profile) => profile.name)).toEqual([ + 'strict', + 'ears-x', + 'kiro', + 'speckit', + 'openspec', + ]); + }); +}); + +describe('init idempotency', () => { + it('skips every managed file on a second run and never creates .earsyntax/', () => { + const dir = makeTempDir('earsyntax-conf-init-'); + + const first = runCliJson( + ['init', '--agent', 'claude', '--host', 'kiro', '--cwd', dir, '--json'], + { + cwd: dir, + }, + ); + expect(first.result.code).toBe(0); + const firstWritten = first.json.written as unknown[]; + expect(firstWritten.length).toBeGreaterThan(0); + + const second = runCliJson( + ['init', '--agent', 'claude', '--host', 'kiro', '--cwd', dir, '--json'], + { + cwd: dir, + }, + ); + expect(second.result.code).toBe(0); + expect(second.json.ok).toBe(true); + expect(second.json.written).toEqual([]); + // Second run touches nothing: every managed file is reported as skipped. + expect((second.json.skipped as unknown[]).length).toBe(firstWritten.length); + + // The host-native facade owns no managed workspace directory. + expect(readdirSync(dir)).not.toContain('.earsyntax'); + }); +}); + +describe('validate --sarif', () => { + it('emits schema-tagged SARIF for the Kiro fixture', () => { + const { result, json } = runCliJson([ + 'validate', + KIRO_HOST_DOC, + '--profile', + 'kiro', + '--sarif', + ]); + // A clean run still emits a valid SARIF log; exit 0 with no error findings. + expect(result.code).toBe(0); + expect(json.version).toBe('2.1.0'); + expect(String(json.$schema)).toBe( + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', + ); + expect(Array.isArray(json.runs)).toBe(true); + expect((json.runs as unknown[]).length).toBe(1); + }); +}); diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json new file mode 100644 index 0000000..c69bc47 --- /dev/null +++ b/test/conformance/tsconfig.json @@ -0,0 +1,12 @@ +{ + "//": "Scopes the conformance suite to its own TypeScript project so the ESLint typed-rule project service can resolve these root-level test files. Not part of the build graph (tsconfig.build.json does not reference it); it exists only to give the conformance tests an owning tsconfig for typed linting and editor tooling.", + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f0b6fa7 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "composite": true, + "incremental": true, + "skipLibCheck": true + }, + "files": [], + "include": [], + "references": [ + { "path": "./packages/core/tsconfig.build.json" }, + { "path": "./packages/cli-contract/tsconfig.build.json" }, + { "path": "./packages/extract/tsconfig.build.json" }, + { "path": "./packages/cli/tsconfig.build.json" } + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..16870fd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "composite": true, + "declarationMap": true, + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "declaration": true, + "removeComments": false, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "strict": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": false, + "noPropertyAccessFromIndexSignature": false, + "noEmitOnError": true, + "forceConsistentCasingInFileNames": true, + "exactOptionalPropertyTypes": false, + "useUnknownInCatchVariables": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["packages/**/src/**/*", "*.config.ts"], + "exclude": ["**/node_modules", "**/dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..c0bc0be --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true + }, + "include": ["packages/**/src/**/*.ts", "packages/**/test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.conformance.config.ts b/vitest.conformance.config.ts new file mode 100644 index 0000000..c74f355 --- /dev/null +++ b/vitest.conformance.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Conformance suite config. + * + * The conformance tests are a separate vitest project from the per-package unit + * suites. They spawn the real built binary (packages/cli/bin/run.js) and never + * import command internals, so they must run after `pnpm build`. `pnpm test` + * (which filters to packages/**) does not pick these up; only `pnpm conformance` + * runs them, via `vitest run --config vitest.conformance.config.ts`. + */ +export default defineConfig({ + test: { + include: ['test/conformance/**/*.test.ts'], + environment: 'node', + globals: false, + // Each smoke test spawns the CLI (and the demo test runs a shell script), + // so give the slow spawners room without failing on the default 5s timeout. + testTimeout: 120_000, + hookTimeout: 120_000, + }, +});