From 60b9507b0f2f59534b336152fcc8348946c85293 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 29 Jul 2026 09:15:04 +0300 Subject: [PATCH 01/20] feat: add earsyntax toolkit monorepo Deterministic EARS requirements toolkit: @earsyntax/core (parser, linter, diagnostics), @earsyntax/extract (.ears, Markdown, YAML, JSON extraction), @earsyntax/cli-contract (JSON and SARIF report contracts), and @earsyntax/cli (the earsyntax facade with the agentic spec-to-EARS loop). Includes the golden fixture corpus, ears-lint-go parity fixtures, facade contracts, and docs. --- .dependency-cruiser.cjs | 42 + .github/CODEOWNERS | 1 + .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 30 + .github/ISSUE_TEMPLATE/feature_request.md | 23 + .github/dependabot.yml | 31 + .github/pull_request_template.md | 29 + .github/verdaccio/config.yaml | 26 + .github/workflows/ci.yml | 193 + .github/workflows/e2e-release.yml | 118 + .github/workflows/e2e.yml | 77 + .github/workflows/pr-title.yml | 50 + .github/workflows/publish-packages.yml | 86 + .github/workflows/release-packages.yml | 132 + .github/workflows/release-preview.yml | 127 + .gitignore | 41 + .husky/commit-msg | 1 + .husky/pre-commit | 1 + .prettierrc | 11 + EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md | 2289 +++++ README.md | 126 + commitlint.config.cjs | 3 + docs/agent-rules.md | 237 + docs/agentic-loop.md | 152 + docs/api.md | 296 + docs/authoring-ears.md | 185 + docs/cli.md | 295 + docs/compatibility.md | 209 + docs/diagnostics.md | 107 + docs/facade-api.md | 465 + docs/grammar.md | 185 + docs/input-formats.md | 238 + docs/public-api.md | 180 + docs/quickstart.md | 186 + eslint.config.mjs | 330 + fixtures/demo/catalog.json | 26 + fixtures/demo/requirements.ears | 19 + fixtures/demo/valid-only.ears | 15 + fixtures/ears-lint-go-parity/.gitkeep | 0 fixtures/ears-lint-go-parity/A301.json | 388 + fixtures/ears-lint-go-parity/A303.json | 392 + fixtures/ears-lint-go-parity/I402.json | 380 + fixtures/ears-lint-go-parity/I404.json | 380 + fixtures/ears-lint-go-parity/I405.json | 369 + fixtures/ears-lint-go-parity/I406.json | 369 + fixtures/ears-lint-go-parity/I410.json | 389 + fixtures/ears-lint-go-parity/I501.json | 384 + fixtures/ears-lint-go-parity/I502.json | 373 + fixtures/ears-lint-go-parity/I506.json | 380 + fixtures/ears-lint-go-parity/K901.json | 398 + .../ears-lint-go-parity/LIB-alias-used.json | 64 + .../LIB-ambiguous-event.json | 74 + .../ears-lint-go-parity/LIB-comma-as-and.json | 60 + .../ears-lint-go-parity/LIB-complex-expr.json | 59 + .../ears-lint-go-parity/LIB-determinism.json | 72 + .../ears-lint-go-parity/LIB-event-driven.json | 66 + .../LIB-guided-suspicious.json | 57 + .../LIB-ifthen-missing-then.json | 64 + .../ears-lint-go-parity/LIB-ifthen-valid.json | 59 + .../LIB-multiple-trigger.json | 64 + .../LIB-optional-feature.json | 59 + .../ears-lint-go-parity/LIB-state-driven.json | 59 + .../LIB-system-unresolved.json | 64 + .../ears-lint-go-parity/LIB-ubiquitous.json | 59 + fixtures/ears-lint-go-parity/N701.json | 369 + fixtures/ears-lint-go-parity/S801.json | 375 + fixtures/ears-lint-go-parity/S803.json | 375 + fixtures/ears-lint-go-parity/V001.json | 375 + fixtures/ears-lint-go-parity/V002.json | 382 + fixtures/ears-lint-go-parity/V003.json | 375 + fixtures/ears-lint-go-parity/V004.json | 382 + fixtures/ears-lint-go-parity/V005.json | 382 + fixtures/ears-lint-go-parity/V006.json | 375 + fixtures/ears-lint-go-parity/V102.json | 389 + fixtures/ears-lint-go-parity/V105.json | 375 + fixtures/ears-lint-go-parity/V108.json | 375 + fixtures/ears-lint-go-parity/V109.json | 375 + fixtures/ears-lint-go-parity/V110.json | 380 + fixtures/ears-lint-go-parity/V201.json | 384 + fixtures/ears-lint-go-parity/V202.json | 388 + fixtures/ears-lint-go-parity/V204.json | 384 + fixtures/facade/README.md | 42 + fixtures/facade/accepted.manifest.json | 29 + fixtures/facade/instructions-author.json | 48 + fixtures/facade/instructions-convert.json | 75 + fixtures/facade/instructions-repair.json | 64 + fixtures/facade/instructions-review.json | 51 + fixtures/facade/scaffolded.manifest.json | 22 + fixtures/facade/stale.manifest.json | 29 + fixtures/facade/status.response.json | 29 + fixtures/facade/traceability.json | 99 + fixtures/facade/valid.manifest.json | 23 + fixtures/facade/validate.response.json | 85 + .../invalid/catalog-system-ambiguous.json | 35 + .../invalid/catalog-system-unresolved.json | 28 + fixtures/invalid/empty-clause.json | 13 + fixtures/invalid/empty-response.json | 13 + fixtures/invalid/empty-subexpression.json | 13 + fixtures/invalid/if-without-then.json | 14 + fixtures/invalid/invalid-clause-order.json | 14 + ...nvalid-operator-sequence-trailing-and.json | 16 + .../invalid/invalid-operator-sequence.json | 13 + fixtures/invalid/missing-shall.json | 13 + fixtures/invalid/missing-system.json | 13 + fixtures/invalid/multiple-shall.json | 14 + fixtures/invalid/no-match.json | 13 + fixtures/invalid/unbalanced-parentheses.json | 13 + fixtures/schema.md | 134 + .../valid/case-insensitive-if-then-upper.json | 19 + .../valid/case-insensitive-when-lower.json | 19 + .../valid/case-insensitive-when-upper.json | 19 + .../valid/case-insensitive-while-mixed.json | 19 + .../valid/catalog-clause-term-unresolved.json | 25 + fixtures/valid/complex-boolean-nested.json | 34 + fixtures/valid/complex-when-if.json | 27 + fixtures/valid/complex-while-when.json | 27 + fixtures/valid/complex-while-where-when.json | 31 + fixtures/valid/event-driven-basic.json | 23 + fixtures/valid/event-driven-refund.json | 19 + fixtures/valid/event-driven-subscription.json | 19 + .../valid/expr-and-keyword-comma-off.json | 33 + fixtures/valid/expr-and.json | 30 + fixtures/valid/expr-comma-as-and.json | 25 + fixtures/valid/expr-group.json | 30 + fixtures/valid/expr-not.json | 25 + fixtures/valid/expr-or.json | 30 + fixtures/valid/expr-precedence-warning.json | 35 + .../guided-catalog-system-unresolved.json | 19 + .../valid/guided-empty-subexpression.json | 13 + fixtures/valid/guided-if-without-then.json | 14 + .../valid/guided-invalid-clause-order.json | 14 + fixtures/valid/guided-missing-shall.json | 13 + .../valid/guided-unbalanced-parentheses.json | 13 + fixtures/valid/lint-alias-used.json | 36 + fixtures/valid/lint-multiple-responses.json | 25 + fixtures/valid/lint-unparsed-tail.json | 24 + fixtures/valid/lint-vague-response.json | 24 + fixtures/valid/optional-feature-basic.json | 23 + .../valid/optional-feature-multicurrency.json | 19 + fixtures/valid/optional-feature-tax.json | 19 + fixtures/valid/state-driven-basic.json | 23 + fixtures/valid/state-driven-maintenance.json | 19 + .../valid/state-driven-provider-degraded.json | 19 + fixtures/valid/ubiquitous-audit-log.json | 19 + fixtures/valid/ubiquitous-basic.json | 19 + fixtures/valid/ubiquitous-retry-limit.json | 19 + fixtures/valid/unwanted-behaviour-basic.json | 23 + .../valid/unwanted-behaviour-declined.json | 19 + fixtures/valid/unwanted-behaviour-stale.json | 19 + lerna.json | 26 + package.json | 58 + packages/cli-contract/package.json | 46 + .../src/__fixtures__/sample-run.ts | 92 + .../src/diagnostic-registry.test.ts | 21 + .../cli-contract/src/diagnostic-registry.ts | 67 + packages/cli-contract/src/exit-codes.test.ts | 48 + packages/cli-contract/src/exit-codes.ts | 33 + packages/cli-contract/src/index.ts | 42 + packages/cli-contract/src/input.ts | 40 + packages/cli-contract/src/json-report.test.ts | 86 + packages/cli-contract/src/json-report.ts | 164 + packages/cli-contract/src/pretty.test.ts | 65 + packages/cli-contract/src/pretty.ts | 110 + packages/cli-contract/src/sarif.test.ts | 68 + packages/cli-contract/src/sarif.ts | 167 + packages/cli-contract/tsconfig.build.json | 13 + packages/cli-contract/tsconfig.json | 9 + packages/cli-contract/vitest.config.ts | 10 + packages/cli/bin/run.js | 5 + packages/cli/package.json | 48 + packages/cli/src/args.ts | 114 + packages/cli/src/cli.test.ts | 367 + packages/cli/src/cli.ts | 131 + packages/cli/src/color.ts | 38 + packages/cli/src/commands/.gitkeep | 0 packages/cli/src/commands/accept.ts | 73 + packages/cli/src/commands/doctor.ts | 117 + packages/cli/src/commands/init.ts | 139 + packages/cli/src/commands/instructions.ts | 156 + packages/cli/src/commands/list.ts | 34 + packages/cli/src/commands/new.ts | 194 + packages/cli/src/commands/show.ts | 79 + packages/cli/src/commands/status.ts | 63 + packages/cli/src/commands/validate.ts | 289 + packages/cli/src/commands/version.ts | 28 + packages/cli/src/context.ts | 21 + packages/cli/src/errors.ts | 38 + packages/cli/src/facade-types.ts | 125 + packages/cli/src/hash.ts | 15 + packages/cli/src/index.ts | 11 + packages/cli/src/mode.ts | 21 + packages/cli/src/next-actions.ts | 61 + packages/cli/src/project.ts | 97 + packages/cli/src/response.ts | 71 + packages/cli/src/rules.ts | 104 + packages/cli/src/tool-wrappers.ts | 98 + packages/cli/src/version.ts | 59 + packages/cli/src/workspace.ts | 189 + packages/cli/tsconfig.build.json | 17 + packages/cli/tsconfig.json | 9 + packages/cli/vitest.config.ts | 10 + packages/core/package.json | 41 + packages/core/src/catalog.test.ts | 372 + packages/core/src/catalog.ts | 652 ++ packages/core/src/diagnostics.test.ts | 421 + packages/core/src/diagnostics.ts | 374 + packages/core/src/expression-parser.test.ts | 225 + packages/core/src/expression-parser.ts | 417 + packages/core/src/index.ts | 16 + packages/core/src/lint.test.ts | 341 + packages/core/src/lint.ts | 401 + packages/core/src/options.ts | 64 + packages/core/src/shell-parser.ts | 519 ++ packages/core/src/types.ts | 436 + packages/core/test/fixtures.helpers.test.ts | 147 + packages/core/test/fixtures.helpers.ts | 156 + packages/core/test/fixtures.test.ts | 107 + packages/core/test/shell-parser.test.ts | 213 + packages/core/tsconfig.build.json | 13 + packages/core/tsconfig.json | 9 + packages/core/vitest.config.ts | 10 + packages/extract/package.json | 46 + packages/extract/src/dispatch.test.ts | 70 + packages/extract/src/dispatch.ts | 71 + packages/extract/src/ears.test.ts | 109 + packages/extract/src/ears.ts | 46 + packages/extract/src/index.ts | 18 + packages/extract/src/internal.ts | 115 + packages/extract/src/json.test.ts | 67 + packages/extract/src/json.ts | 89 + packages/extract/src/markdown.test.ts | 182 + packages/extract/src/markdown.ts | 165 + packages/extract/src/types.ts | 38 + packages/extract/src/yaml.test.ts | 79 + packages/extract/src/yaml.ts | 110 + packages/extract/tsconfig.build.json | 13 + packages/extract/tsconfig.json | 9 + packages/extract/vitest.config.ts | 10 + pnpm-lock.yaml | 7935 +++++++++++++++++ pnpm-workspace.yaml | 44 + specs/checkout.md | 19 + tsconfig.build.json | 17 + tsconfig.json | 37 + tsconfig.tests.json | 9 + 244 files changed, 38182 insertions(+) create mode 100644 .dependency-cruiser.cjs create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/verdaccio/config.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/e2e-release.yml create mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/publish-packages.yml create mode 100644 .github/workflows/release-packages.yml create mode 100644 .github/workflows/release-preview.yml create mode 100644 .gitignore create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .prettierrc create mode 100644 EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md create mode 100644 README.md create mode 100644 commitlint.config.cjs create mode 100644 docs/agent-rules.md create mode 100644 docs/agentic-loop.md create mode 100644 docs/api.md create mode 100644 docs/authoring-ears.md create mode 100644 docs/cli.md create mode 100644 docs/compatibility.md create mode 100644 docs/diagnostics.md create mode 100644 docs/facade-api.md create mode 100644 docs/grammar.md create mode 100644 docs/input-formats.md create mode 100644 docs/public-api.md create mode 100644 docs/quickstart.md create mode 100644 eslint.config.mjs create mode 100644 fixtures/demo/catalog.json create mode 100644 fixtures/demo/requirements.ears create mode 100644 fixtures/demo/valid-only.ears create mode 100644 fixtures/ears-lint-go-parity/.gitkeep create mode 100644 fixtures/ears-lint-go-parity/A301.json create mode 100644 fixtures/ears-lint-go-parity/A303.json create mode 100644 fixtures/ears-lint-go-parity/I402.json create mode 100644 fixtures/ears-lint-go-parity/I404.json create mode 100644 fixtures/ears-lint-go-parity/I405.json create mode 100644 fixtures/ears-lint-go-parity/I406.json create mode 100644 fixtures/ears-lint-go-parity/I410.json create mode 100644 fixtures/ears-lint-go-parity/I501.json create mode 100644 fixtures/ears-lint-go-parity/I502.json create mode 100644 fixtures/ears-lint-go-parity/I506.json create mode 100644 fixtures/ears-lint-go-parity/K901.json create mode 100644 fixtures/ears-lint-go-parity/LIB-alias-used.json create mode 100644 fixtures/ears-lint-go-parity/LIB-ambiguous-event.json create mode 100644 fixtures/ears-lint-go-parity/LIB-comma-as-and.json create mode 100644 fixtures/ears-lint-go-parity/LIB-complex-expr.json create mode 100644 fixtures/ears-lint-go-parity/LIB-determinism.json create mode 100644 fixtures/ears-lint-go-parity/LIB-event-driven.json create mode 100644 fixtures/ears-lint-go-parity/LIB-guided-suspicious.json create mode 100644 fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json create mode 100644 fixtures/ears-lint-go-parity/LIB-ifthen-valid.json create mode 100644 fixtures/ears-lint-go-parity/LIB-multiple-trigger.json create mode 100644 fixtures/ears-lint-go-parity/LIB-optional-feature.json create mode 100644 fixtures/ears-lint-go-parity/LIB-state-driven.json create mode 100644 fixtures/ears-lint-go-parity/LIB-system-unresolved.json create mode 100644 fixtures/ears-lint-go-parity/LIB-ubiquitous.json create mode 100644 fixtures/ears-lint-go-parity/N701.json create mode 100644 fixtures/ears-lint-go-parity/S801.json create mode 100644 fixtures/ears-lint-go-parity/S803.json create mode 100644 fixtures/ears-lint-go-parity/V001.json create mode 100644 fixtures/ears-lint-go-parity/V002.json create mode 100644 fixtures/ears-lint-go-parity/V003.json create mode 100644 fixtures/ears-lint-go-parity/V004.json create mode 100644 fixtures/ears-lint-go-parity/V005.json create mode 100644 fixtures/ears-lint-go-parity/V006.json create mode 100644 fixtures/ears-lint-go-parity/V102.json create mode 100644 fixtures/ears-lint-go-parity/V105.json create mode 100644 fixtures/ears-lint-go-parity/V108.json create mode 100644 fixtures/ears-lint-go-parity/V109.json create mode 100644 fixtures/ears-lint-go-parity/V110.json create mode 100644 fixtures/ears-lint-go-parity/V201.json create mode 100644 fixtures/ears-lint-go-parity/V202.json create mode 100644 fixtures/ears-lint-go-parity/V204.json create mode 100644 fixtures/facade/README.md create mode 100644 fixtures/facade/accepted.manifest.json create mode 100644 fixtures/facade/instructions-author.json create mode 100644 fixtures/facade/instructions-convert.json create mode 100644 fixtures/facade/instructions-repair.json create mode 100644 fixtures/facade/instructions-review.json create mode 100644 fixtures/facade/scaffolded.manifest.json create mode 100644 fixtures/facade/stale.manifest.json create mode 100644 fixtures/facade/status.response.json create mode 100644 fixtures/facade/traceability.json create mode 100644 fixtures/facade/valid.manifest.json create mode 100644 fixtures/facade/validate.response.json create mode 100644 fixtures/invalid/catalog-system-ambiguous.json create mode 100644 fixtures/invalid/catalog-system-unresolved.json create mode 100644 fixtures/invalid/empty-clause.json create mode 100644 fixtures/invalid/empty-response.json create mode 100644 fixtures/invalid/empty-subexpression.json create mode 100644 fixtures/invalid/if-without-then.json create mode 100644 fixtures/invalid/invalid-clause-order.json create mode 100644 fixtures/invalid/invalid-operator-sequence-trailing-and.json create mode 100644 fixtures/invalid/invalid-operator-sequence.json create mode 100644 fixtures/invalid/missing-shall.json create mode 100644 fixtures/invalid/missing-system.json create mode 100644 fixtures/invalid/multiple-shall.json create mode 100644 fixtures/invalid/no-match.json create mode 100644 fixtures/invalid/unbalanced-parentheses.json create mode 100644 fixtures/schema.md create mode 100644 fixtures/valid/case-insensitive-if-then-upper.json create mode 100644 fixtures/valid/case-insensitive-when-lower.json create mode 100644 fixtures/valid/case-insensitive-when-upper.json create mode 100644 fixtures/valid/case-insensitive-while-mixed.json create mode 100644 fixtures/valid/catalog-clause-term-unresolved.json create mode 100644 fixtures/valid/complex-boolean-nested.json create mode 100644 fixtures/valid/complex-when-if.json create mode 100644 fixtures/valid/complex-while-when.json create mode 100644 fixtures/valid/complex-while-where-when.json create mode 100644 fixtures/valid/event-driven-basic.json create mode 100644 fixtures/valid/event-driven-refund.json create mode 100644 fixtures/valid/event-driven-subscription.json create mode 100644 fixtures/valid/expr-and-keyword-comma-off.json create mode 100644 fixtures/valid/expr-and.json create mode 100644 fixtures/valid/expr-comma-as-and.json create mode 100644 fixtures/valid/expr-group.json create mode 100644 fixtures/valid/expr-not.json create mode 100644 fixtures/valid/expr-or.json create mode 100644 fixtures/valid/expr-precedence-warning.json create mode 100644 fixtures/valid/guided-catalog-system-unresolved.json create mode 100644 fixtures/valid/guided-empty-subexpression.json create mode 100644 fixtures/valid/guided-if-without-then.json create mode 100644 fixtures/valid/guided-invalid-clause-order.json create mode 100644 fixtures/valid/guided-missing-shall.json create mode 100644 fixtures/valid/guided-unbalanced-parentheses.json create mode 100644 fixtures/valid/lint-alias-used.json create mode 100644 fixtures/valid/lint-multiple-responses.json create mode 100644 fixtures/valid/lint-unparsed-tail.json create mode 100644 fixtures/valid/lint-vague-response.json create mode 100644 fixtures/valid/optional-feature-basic.json create mode 100644 fixtures/valid/optional-feature-multicurrency.json create mode 100644 fixtures/valid/optional-feature-tax.json create mode 100644 fixtures/valid/state-driven-basic.json create mode 100644 fixtures/valid/state-driven-maintenance.json create mode 100644 fixtures/valid/state-driven-provider-degraded.json create mode 100644 fixtures/valid/ubiquitous-audit-log.json create mode 100644 fixtures/valid/ubiquitous-basic.json create mode 100644 fixtures/valid/ubiquitous-retry-limit.json create mode 100644 fixtures/valid/unwanted-behaviour-basic.json create mode 100644 fixtures/valid/unwanted-behaviour-declined.json create mode 100644 fixtures/valid/unwanted-behaviour-stale.json create mode 100644 lerna.json create mode 100644 package.json create mode 100644 packages/cli-contract/package.json create mode 100644 packages/cli-contract/src/__fixtures__/sample-run.ts create mode 100644 packages/cli-contract/src/diagnostic-registry.test.ts create mode 100644 packages/cli-contract/src/diagnostic-registry.ts create mode 100644 packages/cli-contract/src/exit-codes.test.ts create mode 100644 packages/cli-contract/src/exit-codes.ts create mode 100644 packages/cli-contract/src/index.ts create mode 100644 packages/cli-contract/src/input.ts create mode 100644 packages/cli-contract/src/json-report.test.ts create mode 100644 packages/cli-contract/src/json-report.ts create mode 100644 packages/cli-contract/src/pretty.test.ts create mode 100644 packages/cli-contract/src/pretty.ts create mode 100644 packages/cli-contract/src/sarif.test.ts create mode 100644 packages/cli-contract/src/sarif.ts create mode 100644 packages/cli-contract/tsconfig.build.json create mode 100644 packages/cli-contract/tsconfig.json create mode 100644 packages/cli-contract/vitest.config.ts create mode 100755 packages/cli/bin/run.js create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/args.ts create mode 100644 packages/cli/src/cli.test.ts create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/color.ts create mode 100644 packages/cli/src/commands/.gitkeep create mode 100644 packages/cli/src/commands/accept.ts create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/instructions.ts create mode 100644 packages/cli/src/commands/list.ts create mode 100644 packages/cli/src/commands/new.ts create mode 100644 packages/cli/src/commands/show.ts create mode 100644 packages/cli/src/commands/status.ts create mode 100644 packages/cli/src/commands/validate.ts create mode 100644 packages/cli/src/commands/version.ts create mode 100644 packages/cli/src/context.ts create mode 100644 packages/cli/src/errors.ts create mode 100644 packages/cli/src/facade-types.ts create mode 100644 packages/cli/src/hash.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/mode.ts create mode 100644 packages/cli/src/next-actions.ts create mode 100644 packages/cli/src/project.ts create mode 100644 packages/cli/src/response.ts create mode 100644 packages/cli/src/rules.ts create mode 100644 packages/cli/src/tool-wrappers.ts create mode 100644 packages/cli/src/version.ts create mode 100644 packages/cli/src/workspace.ts create mode 100644 packages/cli/tsconfig.build.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/vitest.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/catalog.test.ts create mode 100644 packages/core/src/catalog.ts create mode 100644 packages/core/src/diagnostics.test.ts create mode 100644 packages/core/src/diagnostics.ts create mode 100644 packages/core/src/expression-parser.test.ts create mode 100644 packages/core/src/expression-parser.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/lint.test.ts create mode 100644 packages/core/src/lint.ts create mode 100644 packages/core/src/options.ts create mode 100644 packages/core/src/shell-parser.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/test/fixtures.helpers.test.ts create mode 100644 packages/core/test/fixtures.helpers.ts create mode 100644 packages/core/test/fixtures.test.ts create mode 100644 packages/core/test/shell-parser.test.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/extract/package.json create mode 100644 packages/extract/src/dispatch.test.ts create mode 100644 packages/extract/src/dispatch.ts create mode 100644 packages/extract/src/ears.test.ts create mode 100644 packages/extract/src/ears.ts create mode 100644 packages/extract/src/index.ts create mode 100644 packages/extract/src/internal.ts create mode 100644 packages/extract/src/json.test.ts create mode 100644 packages/extract/src/json.ts create mode 100644 packages/extract/src/markdown.test.ts create mode 100644 packages/extract/src/markdown.ts create mode 100644 packages/extract/src/types.ts create mode 100644 packages/extract/src/yaml.test.ts create mode 100644 packages/extract/src/yaml.ts create mode 100644 packages/extract/tsconfig.build.json create mode 100644 packages/extract/tsconfig.json create mode 100644 packages/extract/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 specs/checkout.md create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 tsconfig.tests.json 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..7dd4688 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Report a problem with @earsyntax/blackbox +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/blackbox` version: +- Node version: +- Package manager (npm/pnpm/yarn) + version: +- OS: + +## Additional context + +Logs, coverage 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..bc189c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for @earsyntax/blackbox +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..d687370 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: '/' + schedule: + interval: weekly + open-pull-requests-limit: 10 + commit-message: + prefix: chore + include: scope + groups: + opentelemetry: + patterns: + - '@opentelemetry/*' + testcontainers: + patterns: + - 'testcontainers' + - '@testcontainers/*' + 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..404b8b8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +## 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 +- [ ] For packaging changes: `pnpm check:packed` passes + +## Notes for reviewers + + diff --git a/.github/verdaccio/config.yaml b/.github/verdaccio/config.yaml new file mode 100644 index 0000000..92ea8d6 --- /dev/null +++ b/.github/verdaccio/config.yaml @@ -0,0 +1,26 @@ +storage: /verdaccio/storage + +uplinks: + npmjs: + url: https://registry.npmjs.org/ + +packages: + '@earsyntax/*': + access: $all + publish: $all + proxy: npmjs + + '**': + access: $all + publish: $all + proxy: npmjs + +# Anonymous publish is allowed (CI-only simulation registry); any auth token +# is accepted. Do not use this config for a real registry. +auth: + htpasswd: + file: ./htpasswd + max_users: -1 + +logs: + - { type: stdout, format: pretty, level: http } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1222d52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,193 @@ +name: Continuous Integration + +on: + pull_request: + branches: + - main + - next + +permissions: + contents: read + checks: write + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + 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 + + lint: + name: Lint & Format + 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: Lint (packages) + run: pnpm -r --filter './packages/**' lint + + - name: Format check (packages) + run: pnpm prettier --check "packages/**/*.{ts,cjs,json,md}" + + 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 + + unit: + name: Unit Tests (${{ matrix.package }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: + - blackbox + - blackbox-features + - blackbox-coverage + - blackbox-otel-static + 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: Test + run: > + pnpm --filter "./packages/${{ matrix.package }}" test -- + --reporter=verbose + --reporter=junit + --outputFile.junit=./junit.xml + + - name: Publish test results + if: always() + uses: dorny/test-reporter@v2 + with: + name: Unit Tests (${{ matrix.package }}) + path: packages/${{ matrix.package }}/junit.xml + reporter: java-junit + fail-on-error: true + + 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 + + smoke: + name: Smoke Tests + 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 OTEL bootstrap image + run: pnpm build:otel-image + + - name: Build + run: pnpm build + + - name: Smoke tests + run: pnpm test diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml new file mode 100644 index 0000000..a4280a0 --- /dev/null +++ b/.github/workflows/e2e-release.yml @@ -0,0 +1,118 @@ +name: E2E Release Simulation + +env: + CI: true + +on: + workflow_call: + inputs: + target_branch: + description: 'Branch to simulate release from' + required: true + type: string + workflow_dispatch: + inputs: + target_branch: + description: 'Branch to simulate release from' + required: true + type: string + default: 'next' + +permissions: + contents: read + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + REGISTRY_URL: http://localhost:4873 + +jobs: + release-e2e: + name: Packed-artifact E2E via Verdaccio + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.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: Build + run: pnpm build + + # Validates the core packaging guarantee before we even publish: no + # catalog:/workspace: specifier survives into the packed manifests. + - name: Check packed specifiers + run: pnpm check:packed + + - name: Start Verdaccio + run: | + docker run -d --name verdaccio \ + -p 4873:4873 \ + -v ${{ github.workspace }}/.github/verdaccio/config.yaml:/verdaccio/conf/config.yaml \ + verdaccio/verdaccio + for i in $(seq 1 30); do + if curl -sf http://localhost:4873/-/ping >/dev/null; then + echo "Verdaccio is up"; break + fi + sleep 2 + done + + - name: Configure registry auth + run: | + cat >> .npmrc <<'EOF' + @earsyntax:registry=http://localhost:4873/ + //localhost:4873/:_authToken=blackbox-e2e + EOF + + # Verdaccio has no OIDC; provenance would fail. Strip it for the + # simulation only (the real publish workflow keeps it). + - name: Strip provenance for simulation + run: | + sudo apt-get install -y jq + for f in packages/*/package.json; do + jq 'del(.publishConfig.provenance)' "$f" > "$f.tmp" && mv "$f.tmp" "$f" + done + + - name: Publish packages to Verdaccio + run: pnpm -r --filter './packages/**' publish --no-git-checks --registry http://localhost:4873/ + + - name: Capture released version + id: ver + run: echo "version=$(node -p "require('./packages/blackbox/package.json').version")" >> "$GITHUB_OUTPUT" + + # Materialize a catalog-free consumer (npm has no catalog: support) and + # install the just-published tarballs from Verdaccio with plain npm. + - name: Prepare release consumer + run: node scripts/prepare-release-consumer.mjs /tmp/release-consumer ${{ steps.ver.outputs.version }} + + - name: Install consumer from Verdaccio + working-directory: /tmp/release-consumer + run: npm install --no-audit --no-fund + + - name: Run e2e against published packages + working-directory: /tmp/release-consumer + run: npx playwright test --config ./playwright.config.ts + + - name: Upload consumer report + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-e2e-report + path: /tmp/release-consumer/.playwright-report/ + if-no-files-found: ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..3d15336 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,77 @@ +name: E2E Tests + +env: + CI: true + +on: + pull_request: + branches: + - main + - next + workflow_dispatch: + inputs: + target_branch: + description: 'Branch to run e2e from' + required: true + type: string + default: 'next' + +permissions: + contents: read + +env: + PNPM_VERSION: 9.15.4 + NODE_VERSION: 22.x + +jobs: + e2e: + name: Subscription Service E2E (testcontainers) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.ref || inputs.target_branch }} + + - 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 + + # CRITICAL: the OTEL bootstrap travels as a Docker image so its runtime + # dependencies are real files inside the container filesystem. + - name: Build OTEL bootstrap image + run: pnpm build:otel-image + + # e2e consumes @earsyntax/blackbox through its dist exports map. + - name: Build + run: pnpm build + + - name: Run system e2e + run: pnpm test:system + + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: blackbox-coverage + path: e2e/.blackbox-coverage/ + if-no-files-found: ignore + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: e2e/.playwright-report/ + if-no-files-found: ignore diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..953a6ce --- /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(runners/bdd): resolve catalog source for scenario specs +# 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, drivers, + # runners/playwright, runners/bdd, otel.bootstrap). + 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..f57ea04 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,86 @@ +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 + + # Hard gate: fail before publish if any catalog:/workspace: specifier + # would ship verbatim (lerna's blind spot; pnpm publish resolves them). + - name: Check packed specifiers + run: pnpm check:packed + + - 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..3dbfe25 --- /dev/null +++ b/.github/workflows/release-packages.yml @@ -0,0 +1,132 @@ +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: + release-e2e: + name: Release E2E Gate + uses: ./.github/workflows/e2e-release.yml + with: + target_branch: ${{ github.event.inputs.target_branch }} + + tag-version: + name: Version & Tag + needs: [release-e2e] + 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/.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/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md b/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md new file mode 100644 index 0000000..eeeac0f --- /dev/null +++ b/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md @@ -0,0 +1,2289 @@ +# EARS JS Claude Code Agent Orchestration + +This file is the operating brief for a Claude Code manager agent and the implementation agents it activates to build a Node.js and TypeScript toolkit for EARS, the Easy Approach to Requirements Syntax. + +The intended outcome is a deterministic parser, linter, extractor, and CLI facade for EARS requirements. The facade lets any coding agent convert natural-language requirements into `.ears` files through a controlled loop: get instructions, write the artifact, validate it, repair it, and hand it to a human for review. + +## Initiative Summary + +Build an EARS toolkit for Node.js: + +```text +@earsyntax/core parser, AST, linter, diagnostics +@earsyntax/extract Markdown, .ears, YAML, and JSON requirement extraction +@earsyntax/cli CLI facade for humans, scripts, and coding agents +``` + +The first release must be deterministic. It must not use an LLM, fuzzy matching, network calls, or runtime-specific plugins in the parser core. Agentic conversion from prose to `.ears` happens in the coding agent, guided by CLI-provided instructions and checked by CLI validation. + +## Source Anchors + +Use these references when defining behavior: + +| Source | Use | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| https://alistairmavin.com/ears/ | Canonical EARS pattern definitions and clause ordering principles. | +| https://pkg.go.dev/github.com/labeth/ears-lint-go | Practical Go reference for AST shape, diagnostics, catalog matching, and strict/guided behavior. | +| https://github.com/XpycT/ears-spec-engine | Useful TypeScript workflow inspiration, but not the parser core. | +| https://github.github.com/spec-kit/ | Agentic SDD reference: spec, clarify, plan, checklist, tasks, analyze, implement, converge. | +| https://openspec.dev/docs | CLI facade reference: setup, status, validate, instructions, JSON outputs for agents. | +| https://kiro.dev/docs/specs/ | Spec artifact reference: requirements, design, tasks, plus quick-spec mode. | +| https://github.com/dhruv-15-03/spec-kit-ears | Prior EARS extension reference: author, lint, convert, traceability, additive artifact rules. | +| `ears-research-claude.md` | Market, positioning, distribution, HN critique, and public-claim caveats. Not a parser authority. | + +## Manager Prompt + +Use this prompt for the Claude Code manager agent. + +```text +You are the orchestration manager for the EARS JS initiative. + +Goal: +Build a Node.js and TypeScript toolkit for EARS, the Easy Approach to Requirements Syntax. The first release must provide deterministic parsing, linting, diagnostics, extraction, and a CLI. + +Primary references: +- Canonical EARS guide: https://alistairmavin.com/ears/ +- Go parser/linter reference: https://pkg.go.dev/github.com/labeth/ears-lint-go +- TypeScript workflow inspiration: https://github.com/XpycT/ears-spec-engine +- Agentic SDD reference: https://github.github.com/spec-kit/ +- CLI facade reference: https://openspec.dev/docs +- Spec artifact reference: https://kiro.dev/docs/specs/ +- Prior EARS extension reference: https://github.com/dhruv-15-03/spec-kit-ears +- Local research brief: ears-research-claude.md + +Important exclusion: +- Ignore xmidt-org/ears for this initiative. It is Event Async Routing Service, not Easy Approach to Requirements Syntax. + +Your job: +1. Freeze the public contracts before parallel implementation starts. +2. Assign agents by ownership area. +3. Prevent agents from changing shared types, diagnostic codes, or fixture format without explicit manager approval. +4. Keep @earsyntax/core deterministic and runtime-independent. +5. Require every implementation agent to add tests with its code. +6. Integrate only after package-level tests pass. +7. Produce a release candidate with examples, docs, CLI verification, and an agent-facing spec-to-.ears loop. + +Default constraints: +- Use TypeScript. +- Preserve source spans in parser output and diagnostics. +- Prefer small hand-written parsers over regex-only validation. +- Keep parser/linter behavior deterministic. +- Do not add LLM calls to @earsyntax/core. +- Do not make @earsyntax/core depend on the CLI, file system, editor APIs, or workflow packages. +- The CLI binary is `earsyntax`, shipped by `@earsyntax/cli` from `packages/cli`. +- Do not call the command `ears-lint`; linting is one command within the `earsyntax` facade. +- Semantic derivation from natural-language specs is performed by the external coding agent, not by deterministic core. +``` + +## Product Boundary + +The project is not a Cucumber clone in v1. + +The project parses and validates requirements like: + +```text +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. +While the payment provider is unavailable, the billing service shall queue retryable events. +``` + +It returns structured results: + +```json +{ + "valid": true, + "pattern": "event-driven", + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "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." + }, + "references": [], + "diagnostics": [] +} +``` + +The parser validates `.ears` files. It does not infer product intent from prose. + +The CLI facade owns the workflow around parser validation: + +```text +source spec markdown, PRD, issue, or prompt + -> earsyntax instructions author|convert + -> coding agent writes .ears + -> earsyntax validate + -> coding agent repairs + -> earsyntax validate --strict + -> human reviews + -> earsyntax accept +``` + +Later packages may generate acceptance-test skeletons or spec-driven documents, but that is separate from the parser. + +## Agentic Loop Boundary + +Earsyntax must keep three responsibilities separate. + +| Owner | Responsibility | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CLI facade | Resolve paths, create work directories, return instructions, validate artifacts, report status, record acceptance, detect stale source hashes. | +| Coding agent | Read the natural-language source, decide which behaviors are requirements, write or repair `.ears` content, and surface questions when the source is ambiguous. | +| Human | Approve behavior, answer ambiguity questions, accept or reject generated `.ears`, and decide whether generated requirements should be copied into another spec system. | + +The CLI must not pretend to understand the source spec semantically. It can inspect structure, hash files, extract existing EARS statements, validate EARS grammar, and provide conversion rules. The coding agent performs prose-to-EARS translation. + +## Workflow Lessons From Other Tools + +Use these observations when designing the facade. + +| Tool | Lesson for Earsyntax | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Spec Kit | Agentic commands are workflow steps. Invocation differs by agent, so the durable contract must be files plus CLI output, not a slash-command name. | +| OpenSpec | Terminal commands should expose JSON for agents: `status`, `validate`, `instructions`, templates, and next steps. Human commands and agent commands can share a binary. | +| Kiro | Requirements, design, and tasks are separate artifacts. Quick paths are useful, but review gates matter when requirements quality matters. | +| `spec-kit-ears` | Author, lint, and convert are the right user verbs. EARS artifacts should be additive and traceable, and unclear source text should become questions instead of guessed requirements. | + +## Research Findings To Preserve + +The local research brief `ears-research-claude.md` is a positioning and ecosystem input. It should shape roadmap and launch priorities, but it must not override the parser contract. + +### Open Lane + +The practical opportunity is a deterministic, git-friendly, CI-friendly EARS parser/linter/facade for JavaScript and TypeScript projects. + +The research brief argues that the public EARS tooling ecosystem is fragmented: + +- many prompt packs, agent skills, and small editor helpers +- enterprise requirements-quality tools with GUI or vendor workflows +- no obvious dominant JS/TS package that acts like `eslint` for `.ears` +- no official formal grammar published by Mavin's site + +Treat this as the wedge: + +```text +Earsyntax is ESLint for EARS requirements. +It is deterministic, offline, scriptable, and agent-agnostic. +``` + +Do not frame v0.1 as a full requirements-management platform, a methodology, or an agentic IDE feature. + +### Grammar-First Moat + +The research brief's strongest technical recommendation is to publish a formal grammar as a product artifact. + +For implementation, this means: + +- `docs/grammar.md` is not optional polish; it is part of the product. +- the grammar should be written in a readable EBNF-like form before launch +- the test fixture corpus should be traceable to grammar rules +- unsupported edge cases should be documented as deliberate grammar choices +- future tree-sitter or language-server work should consume the same grammar decisions + +The grammar does not need to be the only possible interpretation of EARS. It needs to be stable, documented, deterministic, and aligned with Mavin's public rules. + +### Public Claim Policy + +The research brief contains discovery claims that may change or conflict with local/internal verification. Before public docs, launch posts, README claims, or issue comments mention ecosystem status, the release agent must reverify: + +- whether `labeth/ears-lint-go` is public and still available +- whether `XpycT/ears-spec-engine` or `ears-spec-engine` is public and still available +- current package names, stars, issues, and maintainer responses +- Spec Kit issue numbers, dates, and status +- Kiro, OpenSpec, StrictDoc, and related integration status + +Internal implementation can still use reachable repos as references. Public messaging must not say "does not exist," "unclaimed," "first," or "only" unless those claims are freshly checked near release. + +### Launch Positioning + +If this becomes public, lead with determinism and reproducibility: + +- zero LLM in parser/linter +- offline +- sub-second local CLI +- JSON output for agents +- CI exit codes +- source-to-`.ears` traceability +- stale-source detection +- a formal grammar and fixture corpus + +Avoid leading with "spec-driven development" as a methodology. HN and practitioner audiences may hear that as waterfall. Better framing: + +```text +Earsyntax is a deterministic lint gate for requirements text that teams and agents already write. +``` + +### Distribution Targets + +Keep the first integrations lightweight and additive: + +- Spec Kit: offer Earsyntax as the validator behind EARS-related commands or templates +- OpenSpec: provide an external validator/facade rather than a forked workflow +- Kiro: validate `.kiro/specs/**/requirements.md` or generated `.ears` files +- StrictDoc/Doorstop/sphinx-needs: position as a reusable grammar/linter engine +- Claude/Codex/Cursor: provide wrapper prompts or command files that call the same CLI facade + +Do not make any integration a hard dependency of `@earsyntax/core` or `@earsyntax/cli`. + +## Workspace Layout + +Default Earsyntax work should be self-contained and tool-agnostic. + +```text +.earsyntax/ + config.json + work/ + checkout-webhooks/ + manifest.json + source.md + requirements.ears + questions.md + validation.json + validation.md + traceability.json +``` + +The source file is not copied by default unless the user passes `--snapshot-source`. `manifest.json` stores the source path, source hash, output path, status, timestamps, and acceptance metadata. + +Recommended status states: + +```text +missing -> scaffolded -> drafted -> invalid -> valid -> accepted -> stale +``` + +`stale` means the source hash changed after the last valid or accepted `.ears` file. + +## Package Ownership + +| Package | Purpose | Depends On | +| -------------------- | --------------------------------------------------------------- | ---------------------------------------- | +| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. | None outside standard runtime libraries. | +| `@earsyntax/extract` | Extract EARS statements from common file formats. | `@earsyntax/core` types only. | +| `@earsyntax/cli` | Provide the `earsyntax` facade for humans, scripts, and agents. | `@earsyntax/core`, `@earsyntax/extract`. | + +## Shared Public API + +The architect agent owns this API. Other agents may propose changes but must not commit incompatible changes without manager approval. + +```ts +export function lintEars(text: string, catalog?: Catalog, options?: Options): LintResult; + +export function lintEarsBatch( + items: RequirementInput[], + catalog?: Catalog, + options?: Options, +): LintResult[]; + +export function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult; +``` + +Recommended types: + +```ts +export type Mode = 'strict' | 'guided'; + +export type Pattern = + | 'ubiquitous' + | 'state-driven' + | 'event-driven' + | 'optional-feature' + | 'unwanted-behaviour' + | 'complex'; + +export interface Options { + mode?: Mode; + commaAsAnd?: boolean; + vagueTerms?: string[]; +} + +export interface RequirementInput { + id?: string; + text: string; + source?: SourceLocation; +} + +export interface LintResult { + id?: string; + valid: boolean; + pattern?: Pattern; + ast?: EarsAst; + references: ReferenceMatch[]; + diagnostics: Diagnostic[]; +} + +export interface ParseResult { + pattern?: Pattern; + ast?: EarsAst; + diagnostics: Diagnostic[]; +} + +export interface EarsAst { + pattern: Pattern; + preconditions?: ClauseExpr; + trigger?: ClauseExpr; + feature?: ClauseExpr; + unwanted?: ClauseExpr; + system: TermMatch; + responses: string[]; + raw: string; +} + +export type ClauseExpr = TermExpr | AndExpr | OrExpr | NotExpr | GroupExpr | FreeTextExpr; + +export interface TermExpr { + kind: 'term'; + span?: Span; + text: string; + term?: TermMatch; +} + +export interface AndExpr { + kind: 'and'; + span?: Span; + items: ClauseExpr[]; +} + +export interface OrExpr { + kind: 'or'; + span?: Span; + items: ClauseExpr[]; +} + +export interface NotExpr { + kind: 'not'; + span?: Span; + item: ClauseExpr; +} + +export interface GroupExpr { + kind: 'group'; + span?: Span; + item: ClauseExpr; +} + +export interface FreeTextExpr { + kind: 'free-text'; + span?: Span; + text: string; +} + +export interface Span { + start: number; + end: number; +} +``` + +## Supported EARS Shell Patterns + +The core parser must support these shell patterns in v1: + +| Pattern | Template | +| ------------------ | ---------------------------------------------------------- | +| Ubiquitous | `The shall ` | +| State-driven | `While , the shall ` | +| Event-driven | `When , the shall ` | +| Optional feature | `Where , the shall ` | +| Unwanted behaviour | `If , then the shall ` | +| Complex | Any supported combination with more than one shell clause. | + +Default accepted shell clause order: + +```text +While* -> Where* -> When* -> If* -> the shall +``` + +The parser should be case-insensitive for shell keywords: + +```text +while +WHILE +While +The +THE +shall +SHALL +``` + +The formatter may choose a house style, but parsing must not require uppercase keywords. + +## Clause Expression Grammar + +Inside `While`, `Where`, `When`, and `If` clauses, support: + +```text +A and B +A or B +not A +(A or B) and C +``` + +Precedence: + +```text +not > and > or +``` + +Comma handling: + +| Option | Behavior | +| ------------------- | ---------------------------------------------------------------------- | +| `commaAsAnd: false` | A comma ends a shell clause only. | +| `commaAsAnd: true` | Commas inside clause bodies may be treated as `and` where unambiguous. | + +## Catalog Matching + +Catalog matching must be deterministic. + +```ts +export interface Catalog { + systems?: CatalogEntry[]; + actors?: CatalogEntry[]; + events?: CatalogEntry[]; + states?: CatalogEntry[]; + features?: CatalogEntry[]; + modes?: CatalogEntry[]; + conditions?: CatalogEntry[]; + dataTerms?: CatalogEntry[]; +} + +export interface CatalogEntry { + id: string; + name: string; + aliases?: string[]; +} +``` + +Match policy: + +1. Exact canonical name. +2. Exact alias. +3. Ambiguous if more than one entry matches. +4. Unresolved if no entry matches. + +No fuzzy matching in v1. + +## Diagnostic Codes + +The diagnostics agent owns the registry. These codes are the initial shared set. + +| Code | Meaning | +| ---------------------------------- | ------------------------------------------------------------------- | +| `ears.no_match` | The text does not match any supported EARS shell. | +| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | +| `ears.missing_system` | The system name is absent or empty. | +| `ears.missing_shall` | The requirement does not contain a valid `shall` response boundary. | +| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | +| `ears.invalid_if_then_form` | An `If` clause is missing the required `then` boundary. | +| `ears.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | +| `ears.empty_response` | The response after `shall` is empty. | +| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | +| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | +| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | +| `expr.operator_precedence_warning` | A mixed `and` and `or` expression may need parentheses. | +| `expr.unknown_term` | A clause term cannot be matched to the expected catalog role. | +| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | +| `catalog.system_unresolved` | The system name does not match a known system. | +| `catalog.system_ambiguous` | The system name matches more than one known system. | +| `catalog.state_unresolved` | A state term is unknown. | +| `catalog.event_unresolved` | An event term is unknown. | +| `catalog.feature_unresolved` | A feature term is unknown. | +| `lint.multiple_responses` | The response contains multiple semicolon-separated responses. | +| `lint.vague_response` | The response contains a configured vague term. | +| `lint.unparsed_tail` | Text remains after the parsed requirement. | +| `lint.alias_used` | A catalog alias matched, but canonical naming is preferred. | +| `lint.suspicious_text_shape` | The sentence shape is likely accidental or malformed. | + +Severity rules: + +| Mode | Behavior | +| -------- | --------------------------------------------------------------------------------- | +| `strict` | Structural parse failures and unresolved systems are errors. | +| `guided` | Structural parse failures may be warnings where partial AST recovery is possible. | + +`valid` is `false` if any diagnostic has severity `error`; otherwise `true`. + +## Input Formats + +The extractor package must support common authoring locations. + +### `.ears` + +One requirement per non-empty line: + +```text +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. +``` + +### Markdown + +Support bullets, numbered lists, and tables: + +```md +- 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. +``` + +```md +| ID | Requirement | +| ------- | ---------------------------------------------------------------------------------------- | +| REQ-001 | When a payment webhook is received, the billing service shall verify the HMAC signature. | +``` + +### YAML + +```yaml +requirements: + - id: REQ-001 + text: When a payment webhook is received, the billing service shall verify the HMAC signature. +``` + +### JSON + +```json +{ + "requirements": [ + { + "id": "REQ-001", + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature." + } + ] +} +``` + +## CLI Facade Contract + +The CLI agent owns the `earsyntax` binary. This is the stable facade that humans, scripts, and agent-specific wrappers use. + +The package is: + +```text +packages/cli +@earsyntax/cli +``` + +The binary is: + +```bash +earsyntax +``` + +Do not expose `ears-lint` as the primary command. Linting and validation are subcommands of `earsyntax`. + +### Facade Principles + +1. Every agent-compatible command supports `--json`. +2. Every JSON response includes `version`, `command`, `ok`, and `next`. +3. Commands that write files state every written path in JSON. +4. Commands that inspect or validate never modify files. +5. `instructions` commands are read-only. +6. `validate` is deterministic and never asks questions. +7. Agent wrappers are thin: they call `earsyntax instructions`, write files, then call `earsyntax validate`. +8. The CLI never silently overwrites a human-edited `.ears` file. +9. The CLI never edits the source spec by default. +10. Acceptance records source hashes so changed specs become `stale`. + +### Global Options + +These options should work where they make sense: + +```bash +earsyntax --json +earsyntax --no-color +earsyntax --cwd +earsyntax --config +earsyntax --no-interactive +``` + +### Command Summary + +| Command | Purpose | Agent-Compatible | +| ------------------------ | --------------------------------------------------------------- | ---------------- | +| `earsyntax init` | Initialize `.earsyntax/` and optional agent command/rule files. | Yes | +| `earsyntax doctor` | Read-only project health report. | Yes | +| `earsyntax version` | Version and feature discovery. | Yes | +| `earsyntax new` | Create a work item for a source spec or prompt. | Yes | +| `earsyntax list` | List EARS work items. | Yes | +| `earsyntax status` | Show one work item's state and next steps. | Yes | +| `earsyntax instructions` | Return the exact rules an agent should follow for a step. | Yes | +| `earsyntax validate` | Validate `.ears` files and emit diagnostics. | Yes | +| `earsyntax accept` | Mark a valid `.ears` artifact as human-accepted. | Partly | +| `earsyntax show` | Show resolved artifact paths or content. | Yes | +| `earsyntax export` | Export accepted `.ears` to another representation. | Later | + +### `earsyntax init` + +Initialize Earsyntax in the current project. + +```bash +earsyntax init +earsyntax init --tools claude,codex,cursor +earsyntax init --tools none --json +earsyntax init --force +``` + +Expected writes: + +```text +.earsyntax/config.json +.earsyntax/work/.gitkeep +``` + +Optional tool writes: + +```text +.claude/commands/earsyntax-author.md +.claude/commands/earsyntax-convert.md +.claude/commands/earsyntax-repair.md +AGENTS.md section or tool-specific equivalent +``` + +Tool support is a convenience layer. The workflow must still work when `--tools none` is used. + +JSON shape: + +```json +{ + "version": "0.1.0", + "command": "init", + "ok": true, + "root": "/repo", + "written": [".earsyntax/config.json", ".earsyntax/work/.gitkeep"], + "tools": ["claude", "codex"], + "next": [ + { + "command": "earsyntax new checkout-webhooks --source specs/checkout.md --json", + "reason": "Create a work item for a source spec" + } + ] +} +``` + +### `earsyntax doctor` + +Report project health without making changes. + +```bash +earsyntax doctor +earsyntax doctor --json +``` + +Checks: + +- `.earsyntax/config.json` exists and parses +- configured work directory exists +- package version supports the requested facade features +- referenced sources still exist +- accepted work items are not stale +- tool wrapper files are present if configured + +### `earsyntax version` + +Expose installed capabilities for scripts and agents. + +```bash +earsyntax version +earsyntax version --features +earsyntax version --features --json +``` + +Feature JSON should let an agent choose a supported path without guessing: + +```json +{ + "version": "0.1.0", + "command": "version", + "ok": true, + "features": { + "facade": 1, + "workItems": true, + "instructions": ["author", "convert", "repair", "review"], + "inputFormats": [".ears", "markdown", "yaml", "json"], + "outputFormats": ["pretty", "json"], + "sarif": false + }, + "next": [] +} +``` + +### `earsyntax new` + +Create a work item. This command does not generate EARS content. It prepares the place where the agent will write it. + +Convert an existing source spec: + +```bash +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json +``` + +Author from a prompt: + +```bash +earsyntax new checkout-webhooks --prompt "Checkout webhooks must validate signatures and record successful payments." --mode author --json +``` + +Use an explicit output path: + +```bash +earsyntax new checkout-webhooks --source .kiro/specs/checkout/requirements.md --out .kiro/specs/checkout/requirements.ears --json +``` + +Default output: + +```text +.earsyntax/work//requirements.ears +``` + +Default work item files: + +```text +.earsyntax/work//manifest.json +.earsyntax/work//requirements.ears +.earsyntax/work//questions.md +.earsyntax/work//traceability.json +``` + +JSON shape: + +```json +{ + "version": "0.1.0", + "command": "new", + "ok": true, + "work": { + "id": "checkout-webhooks", + "mode": "convert", + "status": "scaffolded", + "source": "specs/checkout.md", + "sourceHash": "sha256:...", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears" + }, + "written": [ + ".earsyntax/work/checkout-webhooks/manifest.json", + ".earsyntax/work/checkout-webhooks/requirements.ears", + ".earsyntax/work/checkout-webhooks/questions.md", + ".earsyntax/work/checkout-webhooks/traceability.json" + ], + "next": [ + { + "command": "earsyntax instructions convert --work checkout-webhooks --json", + "reason": "Give the coding agent rules for writing the .ears file" + } + ] +} +``` + +### `earsyntax instructions` + +Return agent-facing instructions. This is the key facade command. + +```bash +earsyntax instructions author --work checkout-webhooks --json +earsyntax instructions convert --work checkout-webhooks --json +earsyntax instructions repair --work checkout-webhooks --json +earsyntax instructions review --work checkout-webhooks --json +``` + +Instruction modes: + +| Mode | Use | +| --------- | ---------------------------------------------------------- | +| `author` | Turn a prompt or brief into a first `.ears` draft. | +| `convert` | Convert an existing Markdown/spec/PRD source into `.ears`. | +| `repair` | Repair a `.ears` file after validation diagnostics. | +| `review` | Produce a human review checklist for a valid `.ears` file. | + +JSON shape: + +```json +{ + "version": "0.1.0", + "command": "instructions convert", + "ok": true, + "work": { + "id": "checkout-webhooks", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "rules": [ + "Read the source before writing requirements.", + "Write only EARS requirements in the .ears output file.", + "Use one requirement per line.", + "Give every requirement a stable ID.", + "Preserve source traceability with source file and line references when possible.", + "Do not invent behavior that is not present in the source.", + "Do not hide ambiguity inside vague wording.", + "Split compound behavior into separate requirements.", + "Use questions.md for unclear behavior instead of guessing.", + "Do not edit the source spec unless the user explicitly asks." + ], + "format": { + "line": "REQ-001 [source: specs/checkout.md:14]: When , the shall .", + "allowedPatterns": [ + "The shall .", + "When , the shall .", + "While , the shall .", + "Where , the shall .", + "If , then the shall ." + ] + }, + "next": [ + { + "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", + "reason": "Validate the generated .ears file" + } + ] +} +``` + +### `earsyntax validate` + +Validate `.ears` files deterministically. + +```bash +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json +earsyntax validate "specs/**/*.ears" --catalog catalog.json --format json +earsyntax validate "specs/**/*.ears" --format sarif +earsyntax validate requirements.ears --mode guided +earsyntax validate requirements.ears --mode strict +earsyntax validate requirements.ears --comma-as-and +``` + +Validation responsibilities: + +- parse EARS shell syntax +- parse clause expressions +- apply catalog matching when a catalog is supplied +- validate stable IDs +- validate optional source references +- detect vague terms +- detect multiple responses +- detect source-hash staleness when run against a work item +- emit diagnostics and next steps + +JSON shape: + +```json +{ + "version": "0.1.0", + "command": "validate", + "ok": false, + "summary": { + "files": 1, + "requirements": 7, + "valid": 5, + "errors": 2, + "warnings": 1 + }, + "results": [ + { + "id": "REQ-002", + "file": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 2, + "valid": false, + "pattern": "unwanted-behaviour", + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error", + "message": "If requirements must include a then boundary before the system response.", + "span": { "start": 0, "end": 42 } + } + ] + } + ], + "next": [ + { + "command": "earsyntax instructions repair --work checkout-webhooks --json", + "reason": "Give the coding agent targeted repair rules" + } + ] +} +``` + +### `earsyntax status` + +Show the state of a work item. + +```bash +earsyntax status +earsyntax status checkout-webhooks +earsyntax status checkout-webhooks --json +``` + +JSON shape: + +```json +{ + "version": "0.1.0", + "command": "status", + "ok": true, + "work": { + "id": "checkout-webhooks", + "status": "valid", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "sourceHash": "sha256:...", + "acceptedHash": null, + "stale": false + }, + "next": [ + { + "command": "earsyntax instructions review --work checkout-webhooks --json", + "reason": "Prepare human review" + }, + { + "command": "earsyntax accept checkout-webhooks", + "reason": "Record acceptance after human approval" + } + ] +} +``` + +### `earsyntax accept` + +Record that a human accepted the generated `.ears` file. + +```bash +earsyntax accept checkout-webhooks +earsyntax accept checkout-webhooks --by "omer" --json +``` + +Rules: + +- refuse if current status is not `valid` +- refuse if the source hash is stale +- write acceptance metadata to `manifest.json` +- do not modify the source spec +- do not modify the `.ears` content + +### `earsyntax show` + +Resolve paths or print content. + +```bash +earsyntax show checkout-webhooks --json +earsyntax show checkout-webhooks --artifact requirements +earsyntax show checkout-webhooks --artifact questions +earsyntax show checkout-webhooks --artifact validation --json +``` + +### `earsyntax list` + +List work items. + +```bash +earsyntax list +earsyntax list --json +earsyntax list --status stale --json +``` + +### `earsyntax export` + +Planned, not required for v0.1. Export accepted `.ears` into other systems without making those systems dependencies of core. + +Potential forms: + +```bash +earsyntax export checkout-webhooks --format markdown +earsyntax export checkout-webhooks --format json +earsyntax export checkout-webhooks --format speckit +earsyntax export checkout-webhooks --format openspec +earsyntax export checkout-webhooks --format kiro +``` + +### Exit Codes + +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------------------------------------- | +| `0` | Command completed and no error diagnostics were produced. | +| `1` | Validation completed and at least one error diagnostic exists. | +| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work item resolution error. | +| `3` | Refused write, stale source, overwrite protection, or human confirmation required in non-interactive mode. | + +### Agentic Conversion Loop + +Agent-specific slash commands or skills should be wrappers around this sequence: + +```bash +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json +earsyntax instructions convert --work checkout-webhooks --json +# agent writes .earsyntax/work/checkout-webhooks/requirements.ears +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json +# if invalid: +earsyntax instructions repair --work checkout-webhooks --json +# agent repairs and reruns validate until clean +earsyntax status checkout-webhooks --json +``` + +The loop ends with a human review and explicit acceptance: + +```bash +earsyntax instructions review --work checkout-webhooks --json +earsyntax accept checkout-webhooks +``` + +## Facade Data Contracts + +All facade JSON should be stable, camelCase, and path-explicit. Paths are relative to the resolved project root unless an absolute path was supplied. + +### Base Response + +Every `--json` command returns this shape: + +```ts +export interface FacadeResponse { + version: string; + command: string; + ok: boolean; + root?: string; + data?: T; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; +} + +export interface NextAction { + command: string; + reason: string; + blocking?: boolean; + forAgent?: boolean; +} + +export interface FacadeDiagnostic { + code: string; + severity: 'error' | 'warning' | 'info'; + message: string; + path?: string; + line?: number; +} +``` + +Command-specific examples may also expose top-level convenience keys such as `work`, `written`, `summary`, and `results`, but the base fields must always exist. + +### Work Manifest + +`earsyntax new` creates and later commands update this manifest: + +```ts +export type WorkMode = 'author' | 'convert'; + +export type WorkStatus = + 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; + +export interface WorkManifest { + schemaVersion: 1; + id: string; + mode: WorkMode; + status: WorkStatus; + source?: { + path?: string; + hash?: string; + kind: 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; + snapshotPath?: string; + }; + prompt?: string; + output: { + path: string; + hash?: string; + }; + artifacts: { + questions: string; + traceability: string; + validationJson: string; + validationMarkdown: string; + }; + createdAt: string; + updatedAt: string; + accepted?: { + at: string; + by?: string; + sourceHash?: string; + outputHash: string; + }; +} +``` + +### Instruction Response + +`earsyntax instructions --json` returns: + +```ts +export interface InstructionResponse { + work: WorkSummary; + mode: 'author' | 'convert' | 'repair' | 'review'; + rules: string[]; + format: { + line: string; + allowedPatterns: string[]; + metadataPrefixes: string[]; + }; + source?: { + path?: string; + hash?: string; + excerpts?: SourceExcerpt[]; + }; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; +} + +export interface WorkSummary { + id: string; + status: WorkStatus; + source?: string; + output: string; + questions: string; + traceability: string; +} + +export interface SourceExcerpt { + path: string; + startLine: number; + endLine: number; + text: string; +} +``` + +### Validation Response + +`earsyntax validate --json` returns: + +```ts +export interface ValidationResponse { + summary: { + files: number; + requirements: number; + valid: number; + errors: number; + warnings: number; + }; + results: ValidationResult[]; + work?: WorkSummary; + stale?: boolean; + next: NextAction[]; +} + +export interface ValidationResult { + id?: string; + file: string; + line?: number; + valid: boolean; + pattern?: Pattern; + ast?: EarsAst; + references: ReferenceMatch[]; + diagnostics: Diagnostic[]; +} +``` + +### Traceability Contract + +Agents maintain traceability; the CLI validates only structure and references when possible. + +```ts +export interface TraceabilityFile { + requirements: RequirementTrace[]; + questions: QuestionTrace[]; +} + +export interface RequirementTrace { + id: string; + source?: { + path: string; + startLine?: number; + endLine?: number; + quote?: string; + }; + pattern?: Pattern; + confidence?: 'high' | 'medium' | 'low'; + notes?: string[]; +} + +export interface QuestionTrace { + source?: { + path: string; + startLine?: number; + endLine?: number; + }; + question: string; + blocksRequirements?: string[]; +} +``` + +## Spec-To-EARS Agent Rules + +These rules are what `earsyntax instructions author|convert|repair|review` should return to coding agents. Agent-specific command files may summarize them, but the CLI JSON is the source of truth. + +### Read The Source First + +Before writing `.ears`, the agent must read the full source artifact: + +- Spec Kit `spec.md` +- OpenSpec `proposal.md`, delta specs, or `spec.md` +- Kiro `requirements.md` +- PRD Markdown +- issue description +- design brief +- any user-supplied Markdown, YAML, JSON, or plain text + +The agent must identify: + +- actors and users +- systems or components under requirement +- events that trigger behavior +- states where behavior applies +- optional features or feature flags +- unwanted or error conditions +- observable system responses +- constraints that are genuinely requirements +- ambiguous source statements that require human clarification + +### Write Only Requirements + +The `.ears` file should contain one requirement per non-empty line. No explanatory paragraphs belong in the `.ears` file. + +Use this line format: + +```text +REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature. +``` + +Allowed metadata prefix: + +```text +REQ-001: +REQ-001 [source: path:line]: +REQ-001 [source: path:line-line]: +``` + +The parser must accept bare requirement lines without metadata, but agent-generated `.ears` files should include stable IDs and source references whenever possible. + +### Classify Each Requirement + +Use the narrowest EARS pattern that fits the source behavior. + +| Source Shape | EARS Pattern | Template | +| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | +| Always true system behavior | Ubiquitous | `The shall .` | +| Triggered by an event | Event-driven | `When , the shall .` | +| Applies only during a state | State-driven | `While , the shall .` | +| Applies only when a feature exists or is enabled | Optional feature | `Where , the shall .` | +| Handles an error, invalid input, threat, or unwanted condition | Unwanted behaviour | `If , then the shall .` | +| Requires multiple shell clauses | Complex | `While ..., when ..., the shall ...` | + +Do not force everything into `When`. If the source says behavior applies during maintenance mode, use `While`. If the source says behavior applies only with enterprise SSO enabled, use `Where`. If the source describes an invalid or exceptional condition, use `If ..., then`. + +### Split Compound Source Text + +Split compound source requirements into separate EARS requirements when the response contains more than one observable obligation. + +Source: + +```text +When a webhook arrives, validate the signature, persist the event, and enqueue a job. +``` + +Generated: + +```text +REQ-001 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. +REQ-002 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. +REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. +``` + +Do not split when the second phrase is only a qualifier of the first response. + +### Do Not Invent Behavior + +The agent must not add behavior because it seems reasonable, common, secure, scalable, or useful. If the source does not state the behavior, the `.ears` file should not contain it. + +Examples of forbidden inventions: + +- adding audit logging because security is mentioned +- adding retry behavior because webhooks are involved +- adding rate limits because the endpoint is public +- adding admin permissions because a dashboard is mentioned +- adding database persistence because a UI displays data + +If the missing behavior matters, write a question. + +### Put Ambiguity In Questions + +When source text is vague, conflicting, or underspecified, the agent must write questions to `questions.md`, not invent a precise requirement. + +Question format: + +```md +- [REQ?] Source `specs/checkout.md:18` says "notify the customer quickly." What channel should be used, and what is the time bound? +``` + +Ambiguity triggers: + +- vague terms such as `fast`, `quickly`, `appropriate`, `user-friendly`, `robust`, `as needed` +- missing actor or system +- missing trigger +- missing error response +- conflicting source statements +- implementation detail without observable behavior +- behavior that depends on product judgment + +### Preserve Traceability + +The agent should maintain `traceability.json` for generated requirements. + +```json +{ + "requirements": [ + { + "id": "REQ-001", + "source": { + "path": "specs/checkout.md", + "startLine": 12, + "endLine": 12, + "quote": "When a webhook arrives, validate the signature..." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": [] + } + ], + "questions": [ + { + "source": { + "path": "specs/checkout.md", + "startLine": 18 + }, + "question": "What notification channel should be used?", + "blocksRequirements": ["REQ-007"] + } + ] +} +``` + +Traceability is not a substitute for validation. It gives the human reviewer a way to decide whether the agent faithfully translated the source. + +### Repair From Diagnostics + +When `earsyntax validate --json` returns diagnostics, the agent must repair the `.ears` file by changing only what the diagnostics justify. + +Repair rules: + +- preserve requirement IDs unless the duplicate ID diagnostic requires a change +- preserve source references +- preserve the intended behavior +- do not delete failing requirements to make validation pass +- do not weaken a requirement because the wording is harder to parse +- if the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing + +### Review Before Accept + +Before `earsyntax accept`, the agent should present a review summary: + +- how many requirements were generated +- pattern distribution +- unresolved questions +- validation status +- source lines with low-confidence translation +- any behavior that was intentionally not converted + +Acceptance is a human decision. Agents can recommend acceptance only when validation is clean and no blocking questions remain. + +## Agent Roster + +The manager activates agents in this order unless a dependency forces a change. + +### 1. Architect Agent + +Mission: Freeze the shared contracts and package boundaries. + +Owns: + +- package graph +- public TypeScript types +- exported API names +- fixture schema +- compatibility policy +- versioning policy + +Must not: + +- implement parser internals before contracts are reviewed +- add runtime dependencies to `@earsyntax/core` + +Deliverables: + +- `packages/core/src/types.ts` +- `packages/core/src/index.ts` +- `fixtures/schema.md` +- `docs/public-api.md` + +Acceptance: + +- all downstream agents can implement against the types without inventing new public shapes +- example JSON output is included +- manager approves API freeze + +### 2. Grammar Agent + +Mission: Convert EARS references into a formal implementation matrix. + +Owns: + +- accepted shell patterns +- clause order rules +- strict and guided parsing examples +- invalid example catalog +- edge-case matrix + +Must not: + +- implement production parser code +- create new patterns without marking them experimental + +Deliverables: + +- `fixtures/valid/*.json` +- `fixtures/invalid/*.json` +- `docs/grammar.md` + +Acceptance: + +- every canonical Mavin pattern has at least three valid examples +- unwanted behaviour has first-class examples +- complex requirements include multi-clause examples +- invalid fixtures name expected diagnostic codes + +### 3. Shell Parser Agent + +Mission: Implement the outer EARS sentence parser. + +Owns: + +- shell tokenization +- clause boundary detection +- pattern classification +- system and response extraction +- shell-level spans + +Must not: + +- parse boolean internals beyond passing clause text to the expression parser +- perform catalog matching +- emit diagnostics not registered by the diagnostics agent + +Deliverables: + +- `packages/core/src/shell-parser.ts` +- shell parser tests + +Acceptance: + +- parses all supported shell patterns +- classifies complex requirements correctly +- preserves spans for clauses, system, and response +- catches missing `shall`, multiple `shall`, missing system, empty response, and invalid `If ... then ...` + +### 4. Expression Parser Agent + +Mission: Parse boolean-like expressions inside EARS clauses. + +Owns: + +- expression tokenizer +- `not`, `and`, `or`, and parentheses +- expression AST +- expression spans +- precedence diagnostics + +Must not: + +- classify EARS shell patterns +- perform system parsing +- perform file extraction + +Deliverables: + +- `packages/core/src/expression-parser.ts` +- expression parser tests + +Acceptance: + +- implements `not > and > or` +- handles parenthesized groups +- reports unbalanced parentheses +- reports invalid operator sequences +- preserves spans for terms and groups + +### 5. Catalog Agent + +Mission: Implement deterministic term matching. + +Owns: + +- catalog normalization +- exact name matching +- exact alias matching +- ambiguous and unresolved references +- role-specific matching helpers + +Must not: + +- add fuzzy matching +- add semantic matching +- change catalog type names without architect approval + +Deliverables: + +- `packages/core/src/catalog.ts` +- catalog tests + +Acceptance: + +- exact canonical matches win +- exact alias matches are identified as aliases +- ambiguous matches list all candidates +- unresolved terms produce deterministic results +- diagnostics are stable across repeated runs + +### 6. Diagnostics Agent + +Mission: Own diagnostic construction, severity, ordering, and messages. + +Owns: + +- diagnostic code registry +- strict/guided severity mapping +- stable diagnostic sorting +- diagnostic helper functions +- message wording + +Must not: + +- couple diagnostics to CLI output +- remove a diagnostic code used by fixtures without manager approval + +Deliverables: + +- `packages/core/src/diagnostics.ts` +- `docs/diagnostics.md` +- diagnostic tests + +Acceptance: + +- diagnostics sort by span, code, message, and severity +- `valid` is derived only from error severity +- strict/guided behavior is covered by tests +- every emitted code is documented + +### 7. Core Integration Agent + +Mission: Connect shell parsing, expression parsing, catalog matching, and diagnostics into the public API. + +Owns: + +- `lintEars` +- `lintEarsBatch` +- `parseEars` +- default options +- response splitting +- vague-term linting + +Must not: + +- change parser internals without the owning agent +- add CLI or file-system concerns to core + +Deliverables: + +- `packages/core/src/lint.ts` +- `packages/core/src/options.ts` +- integration tests + +Acceptance: + +- `lintEars` returns complete `LintResult` +- `lintEarsBatch` preserves input order +- default vague terms are configurable +- semicolon-separated responses are split and flagged +- no external calls or nondeterministic behavior + +### 8. Extractor Agent + +Mission: Extract requirement inputs from files people actually write. + +Owns: + +- `.ears` extractor +- Markdown bullet/list/table extractor +- YAML extractor +- JSON extractor +- source location metadata + +Must not: + +- lint extracted statements directly +- duplicate parser behavior + +Deliverables: + +- `packages/extract/src/index.ts` +- extractor tests +- `docs/input-formats.md` + +Acceptance: + +- extracts IDs and text from supported formats +- preserves source file and line information +- ignores non-requirement prose safely +- reports malformed structured files clearly + +### 9. Agentic Facade Agent + +Mission: Define the tool-agnostic loop that lets any coding agent derive `.ears` files from source specs. + +Owns: + +- `earsyntax instructions author` +- `earsyntax instructions convert` +- `earsyntax instructions repair` +- `earsyntax instructions review` +- work item status model +- manifest schema +- traceability schema +- source-to-EARS rules +- agent wrapper contracts + +Must not: + +- implement semantic prose-to-EARS conversion inside deterministic CLI code +- require Claude, Codex, Cursor, Kiro, or any single agent runtime +- let generated artifacts overwrite source specs by default +- blur human acceptance with validation success + +Deliverables: + +- `docs/agentic-loop.md` +- `docs/facade-api.md` +- `docs/agent-rules.md` +- manifest and traceability fixture examples +- instruction JSON fixtures + +Acceptance: + +- a generic coding agent can follow `earsyntax instructions convert --json` without additional hidden context +- instructions clearly separate source reading, `.ears` writing, validation, repair, questions, and review +- source hash and stale-state behavior are specified +- every instruction response includes next commands +- agent-specific slash commands can be implemented as thin wrappers + +### 10. CLI Agent + +Mission: Build the user-facing command-line tool. + +Owns: + +- argument parsing +- glob/file loading +- catalog loading +- `earsyntax` binary wiring +- work item file operations +- pretty, JSON, and later SARIF output +- exit codes +- config file discovery +- `init`, `doctor`, `version`, `new`, `list`, `status`, `instructions`, `validate`, `accept`, `show` + +Must not: + +- reimplement parser rules +- hide core diagnostics +- expose `ears-lint` as the primary binary +- mutate source specs unless the user explicitly requests a write + +Deliverables: + +- `packages/cli/src/cli.ts` +- CLI tests +- `docs/cli.md` +- `docs/facade-api.md` + +Acceptance: + +- supports `.ears`, Markdown, YAML, and JSON inputs through `@earsyntax/extract` +- exits `1` when core reports any error +- exits `2` on CLI usage or file errors +- exits `3` for refused writes, stale source, overwrite protection, or confirmation required +- `earsyntax instructions convert --json` returns a complete agent contract +- `earsyntax new` creates a work item without generating semantic requirements +- `earsyntax accept` refuses invalid or stale work +- JSON output is stable +- SARIF output is planned but not required for the first facade milestone + +### 11. Compatibility Agent + +Mission: Build the fixture corpus and compare selected behavior against `ears-lint-go`. + +Owns: + +- golden fixtures +- AST snapshots +- diagnostic snapshots +- compatibility notes + +Must not: + +- force bug-for-bug compatibility where the manager has chosen a different policy + +Deliverables: + +- `fixtures/canonical` +- `fixtures/ears-lint-go-parity` +- `docs/compatibility.md` + +Acceptance: + +- every fixture has expected pattern and diagnostics +- known deviations from `ears-lint-go` are documented +- spans are tested for representative examples + +### 12. Documentation Agent + +Mission: Produce practical docs that make the toolkit usable. + +Owns: + +- README +- quickstart +- API reference examples +- CLI examples +- authoring guide +- agentic loop guide +- source-to-EARS conversion examples + +Must not: + +- document planned features as shipped +- invent commands or flags + +Deliverables: + +- `README.md` +- `docs/quickstart.md` +- `docs/authoring-ears.md` +- `docs/api.md` + +Acceptance: + +- first screen shows a working example +- each command has expected output +- limitations are explicit +- docs match implemented flags and exports +- docs explain that agents perform semantic derivation and the CLI validates it + +### 13. Spec Workflow Agent + +Mission: Build optional workflow helpers inspired by `ears-spec-engine`. + +Owns: + +- requirement document model +- ambiguity analysis +- simple conflict heuristics +- design/task skeleton helpers +- dependency wave analysis + +Must not: + +- merge spec workflow into `@earsyntax/core` +- require LLM access +- claim semantic correctness from heuristic checks + +Deliverables: + +- `packages/spec/src/index.ts` +- spec workflow tests +- `docs/spec-workflow.md` + +Acceptance: + +- consumes `@earsyntax/core` results +- works without network or model calls +- labels conflict and completeness checks as heuristics +- keeps requirement parsing delegated to core + +### 14. Release Agent + +Mission: Prepare the repository for a v0.1 release candidate. + +Owns: + +- package metadata +- build scripts +- type declarations +- changelog +- release checklist +- npm dry-run +- facade capability report +- public-claim audit +- launch positioning notes + +Must not: + +- change parser behavior during release hardening +- broaden v0.1 scope to include semantic conversion inside the CLI +- publish ecosystem claims from `ears-research-claude.md` without fresh verification + +Deliverables: + +- package manifests +- release notes +- `docs/release.md` +- `earsyntax version --features --json` fixture +- `docs/grammar.md` launch-ready grammar summary +- `docs/public-claims-audit.md` + +Acceptance: + +- package dry-runs pass +- public exports are intentional +- tests, type-checks, and lint pass +- release notes include limitations and known gaps +- release notes explicitly state that coding agents perform semantic derivation and Earsyntax validates artifacts +- public docs avoid "first," "only," "unclaimed," and "does not exist" claims unless freshly verified +- launch copy frames Earsyntax as a deterministic linter/facade, not a methodology + +## Execution Phases + +### Phase 0: Repository Setup + +Manager tasks: + +1. Choose monorepo tooling. +2. Create packages. +3. Add TypeScript, linting, test runner, and formatting. +4. Add fixture directories. +5. Open tracking issues or task files for each agent. + +Recommended structure: + +```text +packages/ + core/ + src/ + test/ + extract/ + src/ + test/ + cli/ + src/ + test/ +fixtures/ + canonical/ + invalid/ + compatibility/ +docs/ +``` + +### Phase 1: Contract Freeze + +Agents active: + +- Architect Agent +- Grammar Agent +- Diagnostics Agent + +Exit criteria: + +- public API approved +- diagnostic code registry approved +- fixture schema approved +- grammar matrix approved + +### Phase 2: Parallel Core Implementation + +Agents active: + +- Shell Parser Agent +- Expression Parser Agent +- Catalog Agent +- Diagnostics Agent + +Exit criteria: + +- package-local tests pass +- each agent has fixtures +- no shared API drift + +### Phase 3: Core Integration + +Agents active: + +- Core Integration Agent +- Compatibility Agent + +Exit criteria: + +- `lintEars` and `lintEarsBatch` work end to end +- canonical fixtures pass +- invalid fixtures produce expected diagnostics +- spans are stable + +### Phase 4: Agentic Facade + +Agents active: + +- Agentic Facade Agent +- CLI Agent +- Documentation Agent + +Exit criteria: + +- `earsyntax` is the only primary binary name +- `earsyntax new` creates work items +- `earsyntax instructions author|convert|repair|review --json` returns complete agent contracts +- `earsyntax status --json` reports work item state and next commands +- work item manifests record source path, output path, source hash, status, and acceptance metadata +- agent rules for writing `.ears` from source specs are documented + +### Phase 5: User Surfaces + +Agents active: + +- Extractor Agent +- CLI Agent +- Documentation Agent + +Exit criteria: + +- CLI can validate real files +- Markdown, `.ears`, YAML, and JSON extraction works +- README and quickstart are accurate +- quickstart demonstrates source Markdown to `.ears` through the agent loop + +### Phase 6: Release Candidate + +Agents active: + +- Release Agent +- Manager Agent + +Exit criteria: + +- tests pass +- type-check passes +- lint passes +- package dry-runs pass +- examples run +- known limitations are documented + +## Dependency Graph + +```text +Architect + -> Grammar + -> Diagnostics + -> Shell Parser + -> Expression Parser + -> Catalog + -> Core Integration + -> Extractor + -> Agentic Facade + -> CLI + -> Documentation + -> Release + +Compatibility starts after Grammar and continues through Core Integration. +The Agentic Facade starts after the core result shapes are stable and can proceed before every CLI subcommand is implemented. +``` + +## Manager Coordination Rules + +The manager must enforce these rules: + +1. One agent owns each file family. +2. Shared types are frozen after Phase 1. +3. Diagnostic codes are append-only after fixtures depend on them. +4. Every implementation task includes tests. +5. Core code cannot depend on CLI, file system, editor APIs, network calls, or LLM calls. +6. The CLI facade can depend on core and extract, but core cannot depend on the facade. +7. Fixtures are source-of-truth examples, not incidental tests. +8. Agents may propose a contract change, but the manager must approve and broadcast it. +9. Integration happens through pull requests or controlled patch batches. +10. Release hardening may fix bugs, but must not broaden scope. +11. Agent-specific wrappers must be thin wrappers over `earsyntax instructions` and `earsyntax validate`. +12. Generated `.ears` files are not accepted until a human approves them. + +## Agent Handoff Template + +Each agent must return this at handoff: + +```md +## Handoff: + +### Completed + +- ... + +### Files Changed + +- ... + +### Tests Added + +- ... + +### Verification + +- Command: `...` +- Result: ... + +### Public Contract Changes + +- None + +### Open Questions + +- ... + +### Known Risks + +- ... +``` + +## Manager Status Template + +The manager should maintain this status after each phase: + +```md +# EARS JS Initiative Status + +## Current Phase + +Phase N: ... + +## Green + +- ... + +## Blocked + +- ... + +## Contract Changes + +- ... + +## Next Agent Assignments + +- ... + +## Verification + +- ... +``` + +## Sub-Agent Prompt Template + +Use this template when activating an implementation agent. + +```text +You are the for the EARS JS initiative. + +Read first: +- This orchestration file. +- The public types in packages/core/src/types.ts. +- The fixture schema. +- Your package README or task brief. + +Your mission: + + +You own: + + +You must not: + + +Implementation requirements: +- Keep behavior deterministic. +- Preserve source spans where your code touches parsed text. +- Add focused tests with your implementation. +- Do not change public API types or diagnostic codes without manager approval. +- Keep changes scoped to your ownership area. + +Before handoff: +- Run package-local tests. +- Run type-check if available. +- Update fixtures or docs only when they describe implemented behavior. +- Return the standard handoff report. +``` + +## First-Week Work Plan + +Day 1: + +- Manager creates repository/package skeleton. +- Architect freezes types and exports. +- Grammar agent writes canonical and invalid fixture matrix. +- Diagnostics agent publishes diagnostic registry. +- Agentic Facade agent freezes the work item manifest, instruction JSON, and traceability JSON shapes. + +Day 2: + +- Shell parser starts implementation. +- Expression parser starts implementation. +- Catalog agent starts deterministic matcher. +- Compatibility agent starts fixture snapshots. +- CLI agent fixes the package facade around `@earsyntax/cli`, `packages/cli`, and the `earsyntax` binary. + +Day 3: + +- Core integration agent connects parser pieces. +- Diagnostics agent verifies severity and ordering. +- Grammar agent fills edge cases from failures. +- Agentic Facade agent writes the source-to-EARS instruction fixtures for author, convert, repair, and review. + +Day 4: + +- Extractor agent adds `.ears`, Markdown, YAML, and JSON input support. +- CLI agent builds `new`, `instructions`, `status`, `validate`, and JSON output. +- Documentation agent writes quickstart against real examples. + +Day 5: + +- Compatibility pass. +- Span accuracy pass. +- CLI facade fixture pass. +- Agent loop dry run: source Markdown to `.ears`, validation, repair, status, and acceptance refusal/acceptance. +- Release agent prepares package dry-runs. +- Manager cuts v0.1 release candidate checklist. + +## Test Matrix + +Required test groups: + +| Group | Examples | +| ------------------------- | ---------------------------------------------------------------------------- | +| Canonical valid shells | one fixture for each EARS pattern | +| Case-insensitive keywords | `when`, `WHEN`, `When` | +| Clause order | valid and invalid complex ordering | +| If/then | valid unwanted behaviour and invalid missing `then` | +| Missing parts | missing system, missing `shall`, empty response | +| Expression precedence | `not A and B or C` | +| Parentheses | `(A or B) and C`, unbalanced groups | +| Catalog matching | exact, alias, ambiguous, unresolved | +| Diagnostics | code, severity, span, stable sort | +| Batch API | order preserved | +| Extractors | `.ears`, Markdown list, Markdown table, YAML, JSON | +| Work items | manifest create/read/update, source hash, stale state | +| Instructions | author, convert, repair, review JSON contracts | +| Agent loop | source Markdown -> scaffold -> instructions -> `.ears` -> validate -> status | +| Questions | questions file path and blocking question reporting | +| Traceability | generated requirement maps back to source path and line | +| CLI | binary name, exit codes, JSON output, refused writes | +| Acceptance | refuses invalid, refuses stale, records accepted hash | + +## Compatibility Policy + +Match `ears-lint-go` where it aligns with the project goals: + +- AST-level shell pattern classification +- boolean clause expression parsing +- deterministic catalog matching +- strict/guided mode concept +- stable diagnostics +- response splitting by semicolon + +Intentional differences are allowed when documented: + +- TypeScript naming may use `optional-feature` consistently instead of `optional`. +- The parser should accept canonical mixed-case EARS, not only uppercase house style. +- Unwanted behaviour must be first-class even if a workflow package collapses it into complex requirements. +- Workflow-level `WITHIN` support should be optional or experimental unless added to core grammar deliberately. + +## Definition Of Done + +The v0.1 release candidate is done when: + +1. `@earsyntax/core` parses all canonical EARS patterns. +2. `@earsyntax/core` returns stable AST, references, diagnostics, and validity. +3. Strict and guided modes are implemented. +4. Clause expressions support `not`, `and`, `or`, and parentheses. +5. Catalog matching is deterministic and tested. +6. Extractors support `.ears`, Markdown, YAML, and JSON. +7. `@earsyntax/cli` ships the `earsyntax` binary from `packages/cli`. +8. CLI supports `init`, `doctor`, `version`, `new`, `list`, `status`, `instructions`, `validate`, `accept`, and `show`. +9. CLI supports pretty and JSON output. +10. CLI exit codes are documented and tested. +11. `earsyntax instructions author|convert|repair|review --json` returns complete agent-facing contracts. +12. The source-to-`.ears` loop is documented and covered by fixtures. +13. Work item status supports `missing`, `scaffolded`, `drafted`, `invalid`, `valid`, `accepted`, and `stale`. +14. Acceptance refuses invalid or stale work and records source/output hashes. +15. Docs include a quickstart, API example, CLI facade example, input format guide, diagnostics reference, grammar summary, agentic loop guide, and limitations. +16. Public-claim audit is complete for ecosystem, competitor, and integration claims. +17. All tests and type-checks pass. + +## Explicit Non-Goals For v0.1 + +- No Cucumber-compatible runner. +- No automatic step definition binding. +- No fuzzy or semantic catalog matching. +- No LLM calls inside core parsing or linting. +- No claim that heuristic conflict detection proves semantic consistency. +- No editor extension. +- No requirements-management SaaS integration. +- No automatic mutation of source specs. +- No automatic human acceptance. +- No hard dependency on Spec Kit, OpenSpec, Kiro, Claude, Codex, Cursor, or any specific agent. + +## Addendum: Mid-Flight Decisions (2026-07-28) + +Decisions made during implementation that amend the brief above. + +### Packages + +The package set is four, not three: + +| Package | Purpose | +| ------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@earsyntax/core` | Parser, linter, diagnostics. Unchanged. | +| `@earsyntax/extract` | File extraction. Unchanged. | +| `@earsyntax/cli-contract` | Shared report output contracts: JSON report v1, SARIF 2.1.0, pretty model, exit codes. Facade-neutral, no I/O. | +| `@earsyntax/cli` | Command-line tool. Depends on core, extract, cli-contract. | + +### CLI facade + +- Invocation model is `npx @earsyntax/cli `. The bin entry is `earsyntax`; the name `ears-lint` is retired. +- RESOLVED: the command surface is now specified in full by the "CLI Facade Contract" section above (init, doctor, version, new, list, status, instructions, validate, accept, show; exit codes 0/1/2/3). SARIF output ships in `@earsyntax/cli-contract` already and is exposed by the CLI when the facade milestone allows. + +### Scope additions + +- Go extras included in v0.1: `lintCatalogCoverage` public API, `expr.mixed_unresolved_terms`, `catalog.term_unreferenced`. +- The diagnostic registry is 29 codes (24 from the table above, 2 Go extras, 3 generated ambiguous variants: `catalog.state_ambiguous`, `catalog.event_ambiguous`, `catalog.feature_ambiguous`). +- New deliverable: an agent-agnostic "spec-to-ears" skill. One canonical instruction body plus thin per-agent adapters (Claude Code skill/command, Cursor rules, Copilot prompt, Kiro steering, root AGENTS.md fallback). The loop: read source spec, extract one obligation per line, classify against the six patterns, write `.ears` with stable REQ IDs and canonical catalog names, run the linter, fix by diagnostic code, converge on exit 0. + +### Approved deviations from ears-lint-go + +Documented in full in `docs/compatibility.md`. + +1. Spans are UTF-16 code unit offsets (JS string indices); Go uses UTF-8 bytes. +2. `If` clause bodies map to a dedicated `ast.unwanted` field; Go folds them into `trigger`. +3. The system term emits only `catalog.system_unresolved`/`catalog.system_ambiguous`; Go also emits `expr.unknown_term`/`expr.ambiguous_term` for it. +4. Exact duplicate diagnostics (same code, span, severity, message) are deduplicated; Go can emit duplicates. +5. Specific structural codes replace blanket `ears.no_match` where the failure is identifiable; `ears.no_match` is reserved for genuinely unstructured text. +6. `ears.empty_clause` and `ears.empty_response` are emitted (Go does not check these); both mode-dependent. +7. Clause order `When ... If ... then` is valid complex per the order rule `While* -> Where* -> When* -> If*`; Go flags it invalid. +8. Empty and undefined catalogs both mean no-catalog mode (matching skipped); Go resolves against the empty struct. +9. Diagnostic sort adds span end as a secondary key for insertion-order independence. +10. Responses keep Go behavior: trailing period stripped, semicolon split at lint level. + +### Security + +`eslint-config-prettier` is pinned to exact `10.1.8`; the `^10.0.1` range could resolve to the compromised `10.1.6`/`10.1.7` releases. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fd1f95 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# earsyntax + +A deterministic parser, linter, extractor, and CLI for EARS requirements in Node.js and TypeScript. + +Give it a requirement written in the Easy Approach to Requirements Syntax: + +```text +When a payment webhook is received, the billing service shall verify the HMAC signature. +``` + +Lint it in code: + +```ts +import { lintEars } from '@earsyntax/core'; + +const result = lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', +); +``` + +`result` is a structured object: the classified pattern, the parsed AST, catalog references, and a sorted list of diagnostics. + +```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 } } + } +} +``` + +Or lint a file from the command line. Validation exits `0` when every requirement is clean and `1` when any error diagnostic remains, so it works as a CI gate: + +```bash +npx @earsyntax/cli validate requirements.ears +``` + +```text + +3/3 valid, 0 errors, 0 warnings +``` + +## What EARS is + +EARS, the Easy Approach to Requirements Syntax, is a small set of sentence templates for writing requirements that are unambiguous and testable. Each requirement names a system and one observable response, optionally guarded by a state, an event, an optional feature, or an unwanted condition. The patterns are documented at [alistairmavin.com/ears](https://alistairmavin.com/ears/). + +This toolkit parses those templates and reports where a requirement departs from them. It is deterministic: for a given input it always returns the same result, with no LLM, no network, no file system access in the parser, and no fuzzy matching. The parser validates `.ears` grammar; it does not infer product intent from prose. Turning natural-language specifications into `.ears` is a separate job done by a coding agent through the CLI facade, and validated by the same parser. + +## Packages + +| Package | Purpose | +| ------------------------- | ------------------------------------------------------------------------------------------------ | +| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. No runtime dependencies. | +| `@earsyntax/extract` | Extract EARS requirements from `.ears`, Markdown, YAML, and JSON files. Depends on core types. | +| `@earsyntax/cli-contract` | Shared report output contracts: the JSON report, SARIF 2.1.0, the pretty model, and exit codes. | +| `@earsyntax/cli` | The `earsyntax` command-line facade for humans, scripts, and coding agents. | + +## Install + +```bash +npm install @earsyntax/core +# or the extractor and CLI +npm install @earsyntax/extract +npm install --global @earsyntax/cli +``` + +The CLI also runs without a global install through `npx @earsyntax/cli `. Node 22 or newer is required. + +## Usage + +Lint one requirement or a batch in code with `@earsyntax/core`: + +```ts +import { lintEars, lintEarsBatch } from '@earsyntax/core'; + +const one = lintEars('The billing service shall verify the HMAC signature.'); + +const many = 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.' }, +]); +``` + +Validate files from the command line. `validate` reads `.ears`, Markdown, YAML, and JSON: + +```bash +npx @earsyntax/cli validate "specs/**/*.ears" +npx @earsyntax/cli validate requirements.md --json +npx @earsyntax/cli validate requirements.ears --mode guided +``` + +The API reference is in [docs/api.md](docs/api.md), the full command reference in [docs/cli.md](docs/cli.md), the diagnostics registry in [docs/diagnostics.md](docs/diagnostics.md), and a five-minute walkthrough in [docs/quickstart.md](docs/quickstart.md). To learn how to write EARS by hand, see [docs/authoring-ears.md](docs/authoring-ears.md). + +## The agentic loop in brief + +A coding agent can convert a natural-language specification into a validated `.ears` file through the CLI facade. The CLI resolves paths and validates; the agent reads the source and writes requirements; a human accepts the result. The loop keeps those three responsibilities separate. See [docs/agentic-loop.md](docs/agentic-loop.md) for the state machine. + +1. `earsyntax new --source --mode convert` scaffolds a work item and hashes the source. +2. `earsyntax instructions convert --work ` returns the rules the agent follows for that step. +3. The agent reads the source and writes `.ears` requirements, one per line, with stable IDs. +4. `earsyntax validate --work ` checks the file and records the result on the work item. +5. `earsyntax instructions repair --work ` returns targeted fixes when validation reports diagnostics; the agent repairs and re-validates until clean. +6. `earsyntax status ` confirms the state and the next steps. +7. A human reviews and runs `earsyntax accept `. The CLI never accepts on the agent's behalf. + +## Limitations + +v0.1 is a deterministic parser, linter, and facade. It is intentionally narrow. + +- No fuzzy or semantic catalog matching. Terms match by exact canonical name, then exact alias, then ambiguous, then unresolved. +- No LLM calls in `@earsyntax/core`. The parser and linter are pure and offline. +- Semantic derivation is not done by the CLI. Coding agents translate prose into `.ears`; the CLI only scaffolds, returns instructions, and validates. A human accepts the result; validation success and acceptance are two separate events. +- SARIF output is not exposed by the CLI yet. The SARIF 2.1.0 builder ships in `@earsyntax/cli-contract`, but `validate` emits pretty and JSON only. `version --features` reports `"sarif": false`. +- The `export` command is planned, not implemented in v0.1. + +The full set of non-goals and the approved differences from the `ears-lint-go` reference are recorded in [docs/compatibility.md](docs/compatibility.md). \ No newline at end of file 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..242d3ca --- /dev/null +++ b/docs/agent-rules.md @@ -0,0 +1,237 @@ +# Spec-to-EARS agent rules + +This document is the canonical instruction body for coding agents that turn source specifications into `.ears` files. The `earsyntax instructions author|convert|repair|review --json` command returns these rules; the CLI JSON is the source of truth at runtime, and this file is the human-readable version of the same content. + +Per-agent wrappers (a Claude Code command, a Cursor rule, an `AGENTS.md` section, a Kiro steering file) are thin pointers to the CLI commands. They must not fork or paraphrase these rules; they call `earsyntax instructions` and follow whatever it returns, so there is one place to change the rules. + +The rules split cleanly by step: read the source, write only requirements, classify, split compounds, do not invent, raise questions, preserve traceability, repair from diagnostics, and review before accept. + +## Read the source first + +Before writing any `.ears`, read the full source artifact. Sources include a Spec Kit `spec.md`, an OpenSpec `proposal.md` or delta spec, a Kiro `requirements.md`, a PRD, an issue description, a design brief, or any user-supplied Markdown, YAML, JSON, or plain text. + +While reading, identify: + +- actors and users +- systems or components under requirement +- events that trigger behavior +- states during which behavior applies +- optional features or feature flags that gate behavior +- unwanted or error conditions +- observable system responses +- constraints that are genuinely requirements +- ambiguous statements that need human clarification + +In `convert` mode the `instructions` response includes source excerpts with line numbers. Read the whole file anyway; the excerpts are a starting point, not the full source. + +## Write only requirements + +The `.ears` file contains one requirement per non-empty line and nothing else. No headings, no explanatory paragraphs, no prose. Explanation belongs in `traceability.json` and open items belong in `questions.md`. + +Line format: + +```text +REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature. +``` + +Allowed metadata prefixes: + +```text +REQ-001: +REQ-001 [source: path:line]: +REQ-001 [source: path:line-line]: +``` + +Give every requirement a stable ID such as `REQ-001`. Include a `[source: path:line]` reference whenever the source line is known; in `author` mode, where there is no source, use the bare `REQ-001:` prefix. The parser accepts bare requirement lines without any prefix, but agent-generated files should carry IDs and, in convert mode, source references. + +## Classify each requirement + +Use the narrowest EARS pattern that fits the source behavior. Do not force everything into `When`. + +| Source shape | EARS pattern | Template | +| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | +| Always-true system behavior | Ubiquitous | `The shall .` | +| Triggered by an event | Event-driven | `When , the shall .` | +| Applies only during a state | State-driven | `While , the shall .` | +| Applies only when a feature exists or is enabled | Optional feature | `Where , the shall .` | +| Handles an error, invalid input, threat, or unwanted condition | Unwanted behaviour | `If , then the shall .` | +| Requires more than one shell clause | Complex | `While ..., when ..., the shall .` | + +Guidance: + +- If behavior applies during maintenance mode, use `While`, not `When`. +- If behavior applies only with a feature enabled (for example enterprise SSO), use `Where`. +- If the source describes an invalid or exceptional condition, use `If ..., then ...`. +- Accepted clause order for complex requirements is `While* -> Where* -> When* -> If* -> the shall `. + +## Split compound source text + +Split a compound source requirement into separate EARS requirements when the response holds more than one observable obligation. + +Source: + +```text +When a webhook arrives, validate the signature, persist the event, and enqueue a job. +``` + +Generated: + +```text +REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. +REQ-004 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. +REQ-005 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. +``` + +Do not split when the second phrase only qualifies the first response (for example "reject the webhook with a 400 status" is one obligation, not two). When you split, record all resulting IDs against the same source line in `traceability.json`. + +## Do not invent behavior + +Do not add behavior because it seems reasonable, common, secure, scalable, or useful. If the source does not state the behavior, the `.ears` file must not contain it. + +Forbidden inventions: + +- adding audit logging because security is mentioned +- adding retry behavior because webhooks are involved +- adding rate limits because the endpoint is public +- adding admin permissions because a dashboard is mentioned +- adding database persistence because a UI displays data + +If missing behavior matters, write a question instead. + +## Put ambiguity in questions + +When source text is vague, conflicting, or underspecified, write a question to `questions.md` and record it in `traceability.json`. Do not invent a precise requirement to fill the gap. + +Question format in `questions.md`: + +```md +- [REQ?] Source `specs/checkout.md:19` says "notify the customer quickly." What channel should be used, and what is the time bound? +``` + +Ambiguity triggers: + +- vague terms such as `fast`, `quickly`, `appropriate`, `user-friendly`, `robust`, `as needed` +- a missing actor or system +- a missing trigger +- a missing error response +- conflicting source statements +- an implementation detail with no observable behavior +- behavior that depends on product judgment + +A question that blocks a requirement lists that requirement id under `blocksRequirements` in `traceability.json`. Blocking questions must be resolved by a human before acceptance. + +## Preserve traceability + +Maintain `traceability.json` for every generated requirement and question. It gives the human reviewer a way to judge whether the source was translated faithfully; it is not a substitute for validation. + +```json +{ + "requirements": [ + { + "id": "REQ-001", + "source": { + "path": "specs/checkout.md", + "startLine": 7, + "endLine": 8, + "quote": "When a payment webhook is received, the billing service must verify the HMAC signature..." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": [] + } + ], + "questions": [ + { + "source": { "path": "specs/checkout.md", "startLine": 19 }, + "question": "What notification channel should be used?", + "blocksRequirements": ["REQ-008"] + } + ] +} +``` + +Rules: + +- Every requirement id in the `.ears` file has a matching entry in `traceability.json`. +- Set `confidence` to `high`, `medium`, or `low`. Use `medium` or `low` for a translation the human should double-check (for example a source that used "should" rather than a firm obligation), and call those out in the review summary. +- In `author` mode leave the `source` object off requirement entries; there is no source line to cite. + +## Repair from diagnostics + +When `earsyntax validate --json` reports diagnostics, repair the `.ears` file by changing only what the diagnostics justify. The `instructions repair` response includes the exact diagnostics to address. + +General repair rules: + +- Preserve requirement IDs unless a duplicate-ID diagnostic requires a change. +- Preserve `[source: path:line]` references. +- Preserve the intended behavior. +- Do not delete a failing requirement to make validation pass. +- Do not weaken a requirement because its wording is harder to parse. +- If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing. +- Re-run `validate` after each repair pass and repeat until no error-severity diagnostics remain. + +The table below maps every diagnostic code in the registry (see `docs/diagnostics.md`) to what to change. Error-severity codes must be fixed to reach a `valid` state; warning-severity codes do not block validity but the review summary reports them. + +### `ears.*` shell structure + +| Code | What to change | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `ears.no_match` | The line is not an EARS requirement. Rewrite it into one of the allowed templates, or move the text out of the `.ears` file. | +| `ears.invalid_clause_order` | Reorder shell clauses to `While -> Where -> When -> If -> the shall `. | +| `ears.missing_system` | Insert the system name before `shall`: `the shall ...`. | +| `ears.missing_shall` | Add a single `shall` response boundary. A requirement must state one obligation with `shall`. | +| `ears.multiple_shall` | Split into separate requirements, one `shall` each, following the compound-splitting rule. | +| `ears.invalid_if_then_form` | Add the missing `then`: `If , then the shall .` | +| `ears.empty_clause` | Fill the empty `While`/`Where`/`When`/`If` clause body, or remove the clause if it was accidental. | +| `ears.empty_response` | Add the response after `shall`, or raise a question if the source does not state one. | + +### `expr.*` clause expressions + +| Code | What to change | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `expr.unbalanced_parentheses` | Balance the parentheses in the clause expression. | +| `expr.invalid_operator_sequence` | Fix the malformed operator run (for example `A or or B`, a trailing `and`, a leading `or`). | +| `expr.empty_subexpression` | Remove the empty group or supply the missing operand (for example `A and ()`). | +| `expr.operator_precedence_warning` | Warning. Add parentheses to a mixed `and`/`or` expression to make grouping explicit if intent is unclear. | +| `expr.unknown_term` | Warning. Align the clause term with a catalog entry, or add the term to the catalog if it is correct. | +| `expr.ambiguous_term` | Warning. Disambiguate the term so it matches one catalog entry, or use the canonical name. | +| `expr.mixed_unresolved_terms` | Warning. One clause mixes resolved and unresolved terms; align the unresolved term with the catalog. | + +### `catalog.*` term matching + +| Code | What to change | +| ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| `catalog.system_unresolved` | Error in strict mode. Use the catalog's canonical system name, or confirm the system with the catalog owner. | +| `catalog.system_ambiguous` | Error in strict mode. Use the specific canonical system name so it matches exactly one entry. | +| `catalog.state_unresolved` | Warning. Use the canonical state name, or add the state to the catalog if it is correct. | +| `catalog.state_ambiguous` | Warning. Use the specific canonical state name. | +| `catalog.event_unresolved` | Warning. Use the canonical event name, or add the event to the catalog if it is correct. | +| `catalog.event_ambiguous` | Warning. Use the specific canonical event name. | +| `catalog.feature_unresolved` | Warning. Use the canonical feature name, or add the feature to the catalog if it is correct. | +| `catalog.feature_ambiguous` | Warning. Use the specific canonical feature name. | +| `catalog.term_unreferenced` | Warning (coverage). A cataloged term is never referenced. Add a requirement that uses it if one is missing, or note the gap; do not invent behavior to satisfy coverage. | + +### `lint.*` style advice + +| Code | What to change | +| ---------------------------- | ------------------------------------------------------------------------------------------------------ | +| `lint.multiple_responses` | Warning. The response holds several semicolon-joined responses. Split into separate requirements. | +| `lint.vague_response` | Warning. Replace the vague term with an observable, bounded response, or raise a question if the bound is unknown. | +| `lint.unparsed_tail` | Warning. Text remains after the parsed requirement. Move it into the requirement or remove it. | +| `lint.alias_used` | Warning. A catalog alias matched. Prefer the canonical name. | +| `lint.suspicious_text_shape` | Warning. The sentence shape looks accidental. Rewrite it into a clean EARS template. | + +## Review before accept + +Before `earsyntax accept`, produce a review summary. Acceptance is a human decision; the agent presents evidence and may recommend, but never accepts. + +The summary states: + +- how many requirements were generated +- the distribution across the six EARS patterns +- every unresolved question and the requirements each one blocks +- the validation status and the command that produced it +- source lines translated with medium or low confidence +- any source behavior intentionally not converted, with the reason + +Recommend acceptance only when validation is clean (no error diagnostics) and no blocking questions remain. If either condition fails, say so and point at the next step (repair, or a human answer to a question) instead of recommending acceptance. \ No newline at end of file diff --git a/docs/agentic-loop.md b/docs/agentic-loop.md new file mode 100644 index 0000000..da7293d --- /dev/null +++ b/docs/agentic-loop.md @@ -0,0 +1,152 @@ +# Agentic loop + +This document describes how any coding agent derives `.ears` files from source specifications using the `earsyntax` facade. The loop is tool-agnostic: it works with Claude Code, Cursor, Codex, Kiro, or a plain shell script, because the durable contract is files plus CLI JSON, not any one agent's command names. + +Three parties have separate responsibilities, and the loop keeps them separate. + +| Party | Responsibility | +| ------------ | -------------------------------------------------------------------------------------------------------------------- | +| CLI facade | Resolve paths, scaffold work directories, return instructions, validate `.ears`, report status, record acceptance, detect stale sources. | +| Coding agent | Read the source, decide which behaviors are requirements, write and repair `.ears`, and raise questions when the source is unclear. | +| Human | Approve behavior, answer questions, and accept or reject the generated `.ears`. | + +The CLI never interprets the source semantically. The agent never accepts its own work. Validation success and human acceptance are two different events. + +## The loop at a glance + +```text +earsyntax new -> scaffold a work item (choose author or convert mode) +earsyntax instructions -> get the rules for the current step + (agent writes or repairs the .ears file) +earsyntax validate -> check the .ears file, deterministically + (repeat instructions repair + validate until clean) +earsyntax status -> confirm state and next steps +earsyntax instructions review -> build a human review summary + (human reviews) +earsyntax accept -> record human acceptance +``` + +Every JSON response ends with a `next` array, so an agent can walk the loop by following `next[].command` without hard-coding the sequence. Actions with `forAgent: true` are safe for the agent to run; actions with `blocking: true` require a human. + +## Work-item state machine + +A work item moves through these states. The state lives in `manifest.json` as `status`. + +```text +missing -> scaffolded -> drafted -> invalid -> valid -> accepted + ^ | | | + | v | | + +---------- (repair) | | + v v + stale <----- stale +``` + +### States + +| State | Meaning | +| ------------ | ------------------------------------------------------------------------------------------- | +| `missing` | No manifest exists for the queried id. Reported by `status`; never stored. | +| `scaffolded` | `earsyntax new` created the directory and empty artifacts. No requirements written yet. | +| `drafted` | The `.ears` file has content but has not passed a clean validation. | +| `invalid` | The last `validate` produced at least one error-severity diagnostic. | +| `valid` | The last `validate` produced no error diagnostics. Eligible for review and acceptance. | +| `accepted` | A human accepted the `.ears` file. `accepted` metadata and hashes are recorded. | +| `stale` | The source hash changed after the last `valid` or `accepted` state. The `.ears` may no longer reflect the source. | + +### Transitions + +Each transition names the command or event that triggers it. + +| From | To | Trigger | +| ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | +| `missing` | `scaffolded` | `earsyntax new ` creates the manifest and artifact files. | +| `scaffolded` | `drafted` | The next `validate` observes a non-empty `.ears` output. (The agent writing the file is not a CLI event; the state advances when the CLI next reads it.) | +| `drafted` | `invalid` | `earsyntax validate` produces at least one error-severity diagnostic. | +| `drafted` | `valid` | `earsyntax validate` produces no error-severity diagnostic. | +| `invalid` | `invalid` | `earsyntax validate` still finds error diagnostics after a repair pass. | +| `invalid` | `valid` | `earsyntax validate` finds no error diagnostics after a repair pass. | +| `valid` | `invalid` | A later `validate` (for example after an edit) finds error diagnostics again. | +| `valid` | `accepted` | `earsyntax accept ` after a human approves. Refused unless status is `valid` and the source is not stale. | +| `valid` | `stale` | The source content hash no longer matches the hash recorded at the `valid` transition. | +| `accepted` | `stale` | The source content hash no longer matches `accepted.sourceHash`. | +| `stale` | `drafted` | Re-running the loop (`instructions convert`, rewrite, `validate`) against the changed source. | + +[DECIDED] `drafted` is CLI-observed, not agent-signaled. The CLI has no hook that fires when the agent writes the file, so `scaffolded` advances to `drafted`, `valid`, or `invalid` at the next `validate`. `status` reports `scaffolded` until then. Rationale: the CLI only changes state on its own commands; it does not watch the filesystem. + +### Staleness + +`stale` is computed, not stored as a terminal decision. On every `status`, `doctor`, and work-item `validate`, the CLI hashes the current source content and compares it to the hash recorded at the last `valid` or `accepted` transition: + +- If the item is `valid` and the source hash differs from the valid-time hash, the reported status is `stale`. +- If the item is `accepted` and the source hash differs from `accepted.sourceHash`, the reported status is `stale`. +- A `stale` item is not silently repaired. The agent re-enters the loop against the new source; a human accepts again. + +Staleness does not block `validate` from linting the `.ears` file, but it does block `accept`, which refuses stale work with exit `3`. + +## Mode one: convert from an existing source + +Use `convert` when a Markdown spec, PRD, issue, or requirements document already exists. + +```bash +# 1. Scaffold. Hashes the source, reserves the output path. +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json +# -> status: scaffolded + +# 2. Get the convert rules and source excerpts. +earsyntax instructions convert --work checkout-webhooks --json + +# 3. The agent reads specs/checkout.md in full, then writes: +# .earsyntax/work/checkout-webhooks/requirements.ears +# and maintains questions.md and traceability.json. + +# 4. Validate. +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json +# -> status: invalid or valid + +# 5. If invalid, get targeted repair rules, fix, and re-validate. +earsyntax instructions repair --work checkout-webhooks --json +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json +# repeat until no error diagnostics -> status: valid + +# 6. Confirm state. +earsyntax status checkout-webhooks --json + +# 7. Build the human review summary. +earsyntax instructions review --work checkout-webhooks --json + +# 8. Human gate. A person reviews and, if satisfied, accepts. +earsyntax accept checkout-webhooks --by "omer" +# -> status: accepted +``` + +## Mode two: author from a prompt + +Use `author` when there is no source document, only a brief or prompt. The loop is identical except that step 1 supplies a prompt and step 2 requests author rules. There is no `source` block in the instructions and no source excerpts; traceability records requirements without source lines. + +```bash +earsyntax new checkout-webhooks \ + --prompt "Checkout webhooks must validate signatures and record successful payments." \ + --mode author --json +# -> status: scaffolded + +earsyntax instructions author --work checkout-webhooks --json +# agent writes requirements.ears from the prompt only + +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --json +# repair loop, status, review, accept as in convert mode +``` + +Because there is no source file, an author-mode item never becomes `stale` from source drift. It can still move `valid -> invalid` if the `.ears` file is edited and re-validated. + +## Human gates + +Two points in the loop require a human, and the CLI enforces both. + +1. Answering questions. When the agent writes questions to `questions.md`, those questions block the requirements listed in `traceability.json` under `blocksRequirements`. The review summary surfaces them. A human answers before acceptance. +2. Acceptance. `earsyntax accept` refuses unless the status is `valid`, the source is not stale, and the `.ears` file is unchanged since validation. The agent may recommend acceptance, but only a human runs it. Every `next` action that leads to acceptance is marked `blocking: true` and never `forAgent: true`. + +The CLI never accepts on the agent's behalf and never edits the source spec. + +## Convergence + +The repair sub-loop (`instructions repair` then `validate`) repeats until `validate` reports no error-severity diagnostics. The agent must not force convergence by deleting failing requirements or weakening wording; if the intended behavior is unclear, it writes a question and leaves a clear placeholder. The rules for this are in `docs/agent-rules.md`. Warnings (for example `lint.vague_response`) do not block a `valid` state, but the review summary reports them so the human can decide whether they matter. \ No newline at end of file diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..6f3c57d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,296 @@ +# 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. + +`extractFromContent` dispatches on the file name's extension: + +```ts +import { extractFromContent } from '@earsyntax/extract'; + +const md = [ + '# Billing requirements', + '', + '- REQ-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.', +].join('\n'); + +const { items, errors } = extractFromContent(md, 'requirements.md'); +``` + +```json +{ + "items": [ + { + "id": "REQ-001", + "text": "When a payment webhook is received, the billing service shall verify the HMAC signature.", + "source": { "file": "requirements.md", "line": 3 } + }, + { + "text": "If the HMAC signature is invalid, then the billing service shall reject the webhook.", + "source": { "file": "requirements.md", "line": 4 } + } + ], + "errors": [] +} +``` + +The heading is ignored, the first bullet's `REQ-001:` prefix becomes the item `id`, and the second bullet keeps its text with no id. Pass the `items` to `lintEarsBatch` to lint them. `extractFromFile` reads from disk instead of a string and is the only function in the package that touches the file system. + +## `@earsyntax/cli-contract` + +`@earsyntax/cli-contract` defines the shared report output shapes so external tools can consume linting results without depending on the CLI. It is pure data and serializers: no I/O, no argument parsing. You collect one item per requirement, group them by file, and pass the array to a builder. + +```ts +import { lintEars } from '@earsyntax/core'; +import { buildJsonReport, exitCodeForReport } from '@earsyntax/cli-contract'; + +const inputs = [ + { id: 'REQ-001', text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', source: { file: 'requirements.ears', line: 1 } }, + { id: 'REQ-002', text: 'If the HMAC signature is invalid, the billing service shall reject the webhook.', source: { file: 'requirements.ears', line: 2 } }, +]; + +const report = buildJsonReport([ + { + path: 'requirements.ears', + items: inputs.map((input) => ({ input, result: lintEars(input.text) })), + }, +]); + +exitCodeForReport(report); // 1, because REQ-002 has an error diagnostic +``` + +`report.summary` is `{ files: 1, requirements: 2, errors: 1, warnings: 0, infos: 0, valid: false }`, and `report.files[0].requirements` holds one entry per requirement with its `id`, `line`, `text`, `valid`, `pattern`, and `diagnostics`. The package also exports `buildSarifLog` for SARIF 2.1.0 and `buildPrettyModel` for terminal rendering. `serializeJsonReport` produces a byte-stable string for the same input. + +Note: the CLI does not expose SARIF output yet. The builder is available here, but `earsyntax validate` emits pretty and JSON only. \ No newline at end of file diff --git a/docs/authoring-ears.md b/docs/authoring-ears.md new file mode 100644 index 0000000..f9a619a --- /dev/null +++ b/docs/authoring-ears.md @@ -0,0 +1,185 @@ +# 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, catalog matching, 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 example below was validated with the CLI. + +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. Keywords are case-insensitive, so `When`, `when`, and `WHEN` all parse; 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.invalid_if_then_form`). + +```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. +``` + +## 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.invalid_clause_order`). `When ... if ... then ...` is valid, because `When` precedes `If`. `When ... while ...` is not, because `While` must come before `When`. + +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 (`expr.operator_precedence_warning`) so you can make the grouping explicit if it is not obvious. Unbalanced parentheses, doubled operators such as `or or`, and empty groups are errors. + +By default a comma ends a clause. Pass `--comma-as-and` to `validate` (or `commaAsAnd: true` in the API) to read unambiguous commas inside a clause body as `and`. The explicit `and` keyword works either way. + +## Catalog matching + +A catalog is an optional list of the domain terms you consider canonical: systems, events, states, features, and more. When you supply one, the linter checks the system and clause terms against it. Matching is exact: canonical name first, then alias, then ambiguous if more than one entry matches, then unresolved if none does. There is no fuzzy or semantic matching. + +Given this catalog: + +```json +{ + "systems": [ + { "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] } + ] +} +``` + +validating these two lines in strict mode: + +```text +The BFF shall verify the HMAC signature. +The reconciliation engine shall verify the HMAC signature. +``` + +produces: + +```text +cat.ears:1 warning lint.alias_used alias used instead of canonical term +cat.ears:2 error catalog.system_unresolved unresolved catalog term + +1/2 valid, 1 errors, 1 warnings +``` + +`BFF` matched through an alias, so the linter accepts it but warns that `billing service` is the canonical name. `reconciliation engine` matched no entry, and an unresolved system is an error in strict mode. In guided mode both stay warnings. Unresolved states, events, and features are always warnings, never errors. Pass a catalog to the CLI with `--catalog `. + +## Vague terms + +A response that hedges instead of stating an observable obligation gets a warning (`lint.vague_response`). The default vague terms are `appropriate`, `sufficient`, and `as needed`; configure the list through `Options.vagueTerms`. + +```text +The billing service shall retry failed charges as needed. +``` + +```text +warn.ears:1 warning lint.vague_response The response contains the vague term "as needed". +``` + +Vague terms do not fail validation. They flag a requirement that a reader cannot turn into a test. Replace the vague term with a bounded, observable response, or raise a question if you do not yet know the bound. + +## Common diagnostics and fixes + +These are the diagnostics you meet most often while authoring. In strict mode the structural codes are errors; the style codes are always warnings. The [diagnostics reference](diagnostics.md) documents all 29 codes and their severity in each mode. + +| Code | What it means | Fix | +| --------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------ | +| `ears.missing_shall` | No single `shall` response boundary. | State one obligation with `shall`: `the billing service shall ...`. | +| `ears.missing_system` | No system name before `shall`. | Name the system: `... the billing service shall ...`. | +| `ears.invalid_if_then_form` | An `If` clause has no `then`. | Add `then`: `If , then the shall .` | +| `ears.multiple_shall` | More than one `shall` in the sentence. | Split into separate requirements, one obligation each. | +| `ears.invalid_clause_order` | Clauses are out of order. | Reorder to `While -> Where -> When -> If`. | +| `lint.multiple_responses` | Semicolon-joined responses in one requirement. | Split into separate requirements. | +| `lint.vague_response` | The response contains a configured vague term. | Replace it with an observable, bounded response. | +| `expr.operator_precedence_warning` | Mixed `and`/`or` without parentheses. | Add parentheses to make the grouping explicit. | + +For example, these four lines 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 +err.ears:1 error ears.missing_shall The requirement does not contain exactly one 'shall' response boundary. +err.ears:2 error ears.missing_system The requirement is missing the system name before 'shall'. +err.ears:3 error ears.invalid_if_then_form The 'If' clause is missing the required 'then' boundary. +err.ears:4 error ears.multiple_shall The requirement contains more than one shell-level 'shall'. + +0/4 valid, 4 errors, 0 warnings +``` + +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 by mode, 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. \ No newline at end of file diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..4dd8f17 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,295 @@ +# CLI reference + +`earsyntax` is the command-line facade for humans, scripts, and coding agents. It resolves paths, scaffolds work items, returns agent instructions, validates `.ears` files, reports status, and records human acceptance. It never derives requirements from prose; that is the coding agent's job. The normative JSON contract is in `docs/facade-api.md`; this page is the usage guide with a runnable example and expected output for every command. + +Install and run through the package binary: + +```bash +npx @earsyntax/cli [options] +``` + +## Global options + +These work on every command where they make sense: + +| Option | Effect | +| ------------------- | ----------------------------------------------------------------- | +| `--json` | Emit the machine-readable facade response instead of pretty text. | +| `--no-color` | Strip ANSI color from pretty output. | +| `--cwd ` | Resolve the project from this directory instead of the process working directory. | +| `--config ` | Use an explicit config file instead of `.earsyntax/config.json`. | +| `--no-interactive` | Never prompt; a step that would require confirmation is refused with exit 3. | + +Every `--json` response shares a base shape: `{ version, command, ok, root?, next: [] }` plus command-specific keys. Paths are relative to the resolved project root (the directory containing `.earsyntax/`) unless you passed an absolute path. + +## Exit codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------------------------- | +| `0` | Completed with no error-severity diagnostic. | +| `1` | Validation completed with at least one error diagnostic. Only `validate` returns `1`. | +| `2` | Usage, config, missing file, unparseable input, or work-item resolution error. | +| `3` | Refused write, stale source, overwrite protection, or confirmation required in non-interactive mode. | + +## `init` + +Create `.earsyntax/` and, optionally, thin agent wrapper files. Refuses to overwrite an existing workspace without `--force` (exit 3). + +```bash +earsyntax init +earsyntax init --tools claude,codex,cursor +earsyntax init --tools none --json +earsyntax init --force +``` + +Expected output (pretty): + +```text +Initialized earsyntax in .earsyntax + wrote .earsyntax/config.json + wrote .earsyntax/work/.gitkeep + tools: none +``` + +`--tools claude` writes `.claude/commands/earsyntax-{author,convert,repair}.md`; `codex` adds a marked section to `AGENTS.md`; `cursor` writes `.cursor/rules/earsyntax.mdc`. Each wrapper only points back at `earsyntax instructions` and `earsyntax validate`, so the rules live in one place. + +## `doctor` + +Read-only project health report. Never writes. + +```bash +earsyntax doctor +earsyntax doctor --json +``` + +Expected output (pretty): + +```text + [ok] config: .earsyntax/config.json exists and parses. + [ok] workDir: Work directory .earsyntax/work exists. + [ok] version: earsyntax 0.1.0 supports facade contract 1. +``` + +With `--json`, each check is an entry in `checks`, and any non-passing check is mirrored into the base `diagnostics` array. `ok` is `true` unless a check produced an error. + +## `version` + +Report the installed version and, with `--features`, the capability map agents branch on. + +```bash +earsyntax version +earsyntax version --features --json +``` + +Expected output (`--features --json`): + +```json +{ + "version": "0.1.0", + "command": "version", + "ok": true, + "features": { + "facade": 1, + "workItems": true, + "instructions": ["author", "convert", "repair", "review"], + "inputFormats": [".ears", "markdown", "yaml", "json"], + "outputFormats": ["pretty", "json"], + "sarif": false + }, + "next": [] +} +``` + +## `new` + +Scaffold a work item. This prepares the directory an agent writes into; it never generates EARS content. Convert mode requires `--source `, author mode requires `--prompt `. + +```bash +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json +earsyntax new checkout-webhooks --prompt "Checkout webhooks validate signatures." --mode author --json +``` + +Expected output (convert, `--json`, hashes vary): + +```json +{ + "version": "0.1.0", + "command": "new", + "ok": true, + "root": "/repo", + "work": { + "id": "checkout-webhooks", + "mode": "convert", + "status": "scaffolded", + "source": "specs/checkout.md", + "sourceHash": "sha256:...", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears" + }, + "written": [ + ".earsyntax/work/checkout-webhooks/requirements.ears", + ".earsyntax/work/checkout-webhooks/questions.md", + ".earsyntax/work/checkout-webhooks/traceability.json", + ".earsyntax/work/checkout-webhooks/manifest.json" + ], + "next": [ + { + "command": "earsyntax instructions convert --work checkout-webhooks --json", + "reason": "Give the coding agent the rules for writing the .ears file.", + "forAgent": true + } + ] +} +``` + +Pass `--out ` to reserve a different output path, `--snapshot-source` to copy the source into the work directory, and `--force` to recreate an existing item (otherwise it refuses with exit 3). + +## `list` + +List work items, sorted by id. Filter with `--status `. + +```bash +earsyntax list +earsyntax list --status stale --json +``` + +Expected output (pretty): + +```text +checkout-webhooks valid +``` + +## `status` + +Report one work item's computed state and next steps. With no slug it resolves the single work item when there is exactly one. + +```bash +earsyntax status checkout-webhooks --json +``` + +Expected output (`--json`, hashes vary): + +```json +{ + "version": "0.1.0", + "command": "status", + "ok": true, + "root": "/repo", + "work": { + "id": "checkout-webhooks", + "status": "valid", + "mode": "convert", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "sourceHash": "sha256:...", + "outputHash": "sha256:...", + "acceptedHash": null, + "stale": false + }, + "next": [ + { "command": "earsyntax instructions review --work checkout-webhooks --json", "reason": "Prepare the human review summary.", "forAgent": true }, + { "command": "earsyntax accept checkout-webhooks", "reason": "Record acceptance after human approval.", "blocking": true } + ] +} +``` + +`status` reports `stale` when the source changed after the last `valid` or `accepted` state, even though the stored status is still `valid` or `accepted`. + +## `instructions` + +Return the rules a coding agent follows for one loop step. Read-only. The modes are `author`, `convert`, `repair`, and `review`. + +```bash +earsyntax instructions convert --work checkout-webhooks --json +earsyntax instructions repair --work checkout-webhooks --json +``` + +Expected output (convert, `--json`, abridged): + +```json +{ + "version": "0.1.0", + "command": "instructions convert", + "ok": true, + "root": "/repo", + "mode": "convert", + "work": { "id": "checkout-webhooks", "status": "scaffolded", "source": "specs/checkout.md", "output": "...", "questions": "...", "traceability": "..." }, + "rules": ["Read the full source before writing requirements.", "..."], + "format": { + "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", + "allowedPatterns": ["The shall .", "..."], + "metadataPrefixes": ["REQ-001:", "REQ-001 [source: path:line]:", "REQ-001 [source: path:line-line]:"] + }, + "source": { "path": "specs/checkout.md", "hash": "sha256:...", "excerpts": [{ "path": "specs/checkout.md", "startLine": 3, "endLine": 3, "text": "..." }] }, + "next": [ + { "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", "reason": "Validate the generated .ears file once it is written.", "forAgent": true } + ] +} +``` + +`author` mode omits the `source` block. `repair` mode adds a `diagnostics` array (read from the work item's last `validation.json`) listing what to fix. `review` mode returns review-summary rules and points `next` at reading questions and recording acceptance. + +## `validate` + +Validate `.ears` files deterministically. Reads `.ears`, Markdown, YAML, and JSON through `@earsyntax/extract`. Never writes source, never asks questions. This is the only command that returns exit `1`. + +```bash +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --work checkout-webhooks --json +earsyntax validate "specs/**/*.ears" --catalog catalog.json --format json +earsyntax validate requirements.ears --mode guided --comma-as-and +``` + +Expected output (pretty, one file with one error): + +```text +requirements.ears:2 error ears.invalid_if_then_form If requirements must include a then boundary before the system response. + +7/8 valid, 1 errors, 0 warnings +``` + +`--json` returns `{ summary, results, work?, stale?, next }`. `ok` is `false` and the exit code is `1` when `summary.errors > 0`. When the target is a work item (via `--work` or a path inside `.earsyntax/work/`), `validate` updates the manifest status to `valid` or `invalid`, writes `validation.json` and `validation.md`, and reports `stale` if the source drifted. A validated path under `.earsyntax/work//` is treated as that work item automatically, so `--work` is optional when you validate a work item's own output path. Duplicate requirement IDs are reported as an error diagnostic in the base `diagnostics` array. + +Options: `--source ` records the source for staleness, `--catalog ` supplies a term catalog, `--mode strict|guided`, and `--comma-as-and` treats unambiguous commas inside clause bodies as `and`. + +## `accept` + +Record that a human accepted the generated `.ears` file. The human gate. + +```bash +earsyntax accept checkout-webhooks --by "omer" --json +``` + +Expected output (pretty): + +```text +Accepted "checkout-webhooks" by omer. +``` + +`accept` refuses with exit `3` when the status is not `valid`, when the source is stale, or when the `.ears` file changed since it was validated. On success it writes an `accepted` block (`at`, `by`, `sourceHash`, `outputHash`) to the manifest and sets the status to `accepted`. It never edits the source or the `.ears` content. + +## `show` + +Resolve artifact paths or print artifact content. Read-only. + +```bash +earsyntax show checkout-webhooks --json +earsyntax show checkout-webhooks --artifact questions --json +``` + +Without `--artifact`, the response lists resolved paths for every artifact. With `--artifact requirements|questions|traceability|validation|manifest`, it returns `{ path, exists, content }`, where `content` is the file text when it exists and `null` otherwise, so an agent needs no second read. + +## The loop + +The commands compose into one loop that keeps the CLI, the coding agent, and the human separate. See `docs/agentic-loop.md` for the state machine. + +```bash +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json +earsyntax instructions convert --work checkout-webhooks --json +# the agent writes .earsyntax/work/checkout-webhooks/requirements.ears +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --work checkout-webhooks --json +# if invalid: +earsyntax instructions repair --work checkout-webhooks --json +# repair and re-validate until clean, then: +earsyntax status checkout-webhooks --json +earsyntax instructions review --work checkout-webhooks --json +earsyntax accept checkout-webhooks --by "omer" +``` \ No newline at end of file diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..f10b246 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,209 @@ +# 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. | + +## 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`. + +## 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/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..d08cb16 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,107 @@ +# 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. + +## Code registry + +Codes are grouped by prefix. "Severity" columns show the value in each mode. + +### `ears.*` shell structure + +These report defects in the outer EARS sentence shape. All are mode-dependent. + +| Code | Meaning | strict | guided | Example trigger | +| --------------------------- | ---------------------------------------------------------------- | ------- | --------- | --------------------------------------------------------------------------- | +| `ears.no_match` | The text does not match any supported EARS shell pattern. | `error` | `warning` | `The quick brown fox.` | +| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | `error` | `warning` | `When the timer fires, while idle, the system shall reset.` | +| `ears.missing_system` | The system name before `shall` is absent or empty. | `error` | `warning` | `When the timer fires, shall reset.` | +| `ears.missing_shall` | The requirement has no single `shall` response boundary. | `error` | `warning` | `The system resets the timer.` | +| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | `error` | `warning` | `The system shall reset and shall log the event.` | +| `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.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | `error` | `warning` | `When , the system shall reset.` | +| `ears.empty_response` | The response after `shall` is empty. | `error` | `warning` | `The system shall .` | + +### `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. + +| Code | Meaning | strict | guided | Example trigger | +| ---------------------------------- | ------------------------------------------------------------- | --------- | --------- | ------------------------------------------------------- | +| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | `error` | `warning` | `When (A and B, the system shall reset.` | +| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | `error` | `warning` | `While A or or B, the system shall reset.` | +| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | `error` | `warning` | `While A and (), the system shall reset.` | +| `expr.operator_precedence_warning` | A mixed `and`/`or` expression may need parentheses. | `warning` | `warning` | `While A and B or C, the system shall reset.` | +| `expr.unknown_term` | A clause term matches no catalog entry for its role. | `warning` | `warning` | A `When` term absent from the catalog. | +| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | `warning` | `warning` | A term whose name collides across catalog groups. | +| `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. + +| Code | Meaning | strict | guided | Example trigger | +| ---------------------------- | ------------------------------------------------------------- | --------- | --------- | ----------------------------------------------------------------- | +| `catalog.system_unresolved` | The system name matches no known system. | `error` | `warning` | `The invoicing engine shall ...` when only `BFF` is cataloged. | +| `catalog.system_ambiguous` | The system name matches more than one known system. | `error` | `warning` | A system name that two catalog entries share. | +| `catalog.state_unresolved` | A state term matches no known state. | `warning` | `warning` | `While the queue is draining, ...` with no such state. | +| `catalog.state_ambiguous` | A state term matches more than one known state. | `warning` | `warning` | A state name two entries share. | +| `catalog.event_unresolved` | An event term matches no known event. | `warning` | `warning` | `When a refund is requested, ...` with no such event. | +| `catalog.event_ambiguous` | An event term matches more than one known event. | `warning` | `warning` | An event name two entries share. | +| `catalog.feature_unresolved` | A feature term matches no known feature. | `warning` | `warning` | `Where retries are enabled, ...` with no such feature. | +| `catalog.feature_ambiguous` | A feature term matches more than one known feature. | `warning` | `warning` | A feature name two entries share. | +| `catalog.term_unreferenced` | A cataloged term is never referenced by any requirement text. | `warning` | `warning` | A `payment-http` entry no requirement mentions (coverage check). | + +### `lint.*` style advice + +Stylistic findings that never affect validity. All are always warnings. + +| Code | Meaning | strict | guided | Example trigger | +| --------------------------- | ---------------------------------------------------------- | --------- | --------- | ----------------------------------------------------------- | +| `lint.multiple_responses` | The response holds several semicolon-joined responses. | `warning` | `warning` | `The system shall log the event; notify the operator.` | +| `lint.vague_response` | The response contains a configured vague term. | `warning` | `warning` | `The system shall respond as needed.` | +| `lint.unparsed_tail` | Text remains after the parsed requirement. | `warning` | `warning` | Trailing tokens the expression parser could not consume. | +| `lint.alias_used` | A catalog alias matched; the canonical name is preferred. | `warning` | `warning` | `db` matching a `Postgres` entry via an alias. | +| `lint.suspicious_text_shape`| The sentence shape is likely accidental or malformed. | `warning` | `warning` | Guided-mode text that resembles no EARS shell. | + +## 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..2b185d2 --- /dev/null +++ b/docs/facade-api.md @@ -0,0 +1,465 @@ +# Facade API reference + +This is the normative reference for the `earsyntax` facade JSON contracts. Every command that supports `--json` returns a stable, camelCase object. Humans, scripts, and coding agents read these shapes; the CLI agent implements against them. + +Golden examples live in `fixtures/facade/`. Where this document and a fixture disagree, the fixture is the value pinned for tests and this document is the description of intent; report the mismatch rather than guessing. + +Related documents: + +- `docs/agentic-loop.md` describes the loop and the work-item state machine. +- `docs/agent-rules.md` is the instruction body the `instructions` command returns. +- `docs/diagnostics.md` is the 29-code diagnostic registry validation emits. + +## Conventions + +- JSON keys are camelCase. +- Paths are relative to the resolved project root unless the caller supplied an absolute path. The root is the directory containing `.earsyntax/`, discovered by walking up from `--cwd` (default: process working directory). +- Timestamps are ISO 8601 in UTC with millisecond precision (for example `2026-07-28T10:00:00.000Z`). +- Source and output hashes are `sha256:` followed by 64 lowercase hex characters. +- Every `--json` response includes the base fields below, even on failure. + +## Base response + +Every `--json` command returns this shape. + +```ts +interface FacadeResponse { + version: string; + command: string; + ok: boolean; + root?: string; + data?: T; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; +} + +interface NextAction { + command: string; + reason: string; + blocking?: boolean; + forAgent?: boolean; +} + +interface FacadeDiagnostic { + code: string; + severity: 'error' | 'warning' | 'info'; + message: string; + path?: string; + line?: number; +} +``` + +Field descriptions: + +| Field | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | The installed `@earsyntax/cli` package version, for example `0.1.0`. | +| `command` | The command that produced the response. Subcommands use a space, for example `instructions convert`. | +| `ok` | `true` when the command completed with no error-severity diagnostic. See each command for what counts as an error. | +| `root` | The resolved project root as an absolute path. Present on commands that resolve a project. | +| `data` | Reserved generic payload. In v0.1 commands expose their payload through top-level convenience keys instead (see the note below). | +| `diagnostics` | Facade-level diagnostics (config, path, or work-item problems). Distinct from the core linting `Diagnostic` inside a validate result. | +| `next` | Ordered next actions. Always present; may be empty. | + +`NextAction`: + +| Field | Description | +| ---------- | ------------------------------------------------------------------------------------------------- | +| `command` | A runnable `earsyntax` command. Every value is a real command from this contract. | +| `reason` | One factual sentence explaining why this action follows. | +| `blocking` | `true` when the action requires a human decision (for example acceptance). Optional; default `false`. | +| `forAgent` | `true` when a coding agent can run the action without human input. Optional; default `false`. | + +[DECIDED] Convenience keys over `data`. The orchestration brief shows command payloads as top-level keys (`work`, `written`, `summary`, `results`) rather than nested under `data`. v0.1 follows the examples: each command adds its own top-level keys and leaves `data` unset. Rationale: it matches every example in the brief and keeps the JSON one level flatter for agents. + +`FacadeDiagnostic.severity` may be `info`; the core linting registry emits only `error` and `warning` in v1, but facade-level notices (for example a missing optional tool file from `doctor`) may be `info`. + +## Exit codes + +Exit codes are uniform across commands. + +| Code | Meaning | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| `0` | The command completed and produced no error-severity diagnostic. | +| `1` | Validation completed and at least one error-severity diagnostic exists. Only `validate` returns `1`. | +| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work-item resolution error. | +| `3` | Refused write, stale source, overwrite protection, or a human confirmation required in non-interactive mode. | + +Per-command notes: + +| Command | `0` | `1` | `2` | `3` | +| -------------- | ---------------------------- | ----------------------- | ------------------------------------------------ | ------------------------------------------------------- | +| `init` | Initialized or already valid | not used | bad `--tools` value, unwritable root | `.earsyntax/` exists and `--force` not passed | +| `doctor` | all checks pass or warn | not used | config unreadable | not used | +| `version` | always | not used | not used | not used | +| `new` | work item created | not used | bad slug, missing `--source` file, missing mode | work item exists and `--force` not passed | +| `list` | always | not used | config unreadable | not used | +| `status` | work item resolved | not used | unknown work item | not used | +| `instructions` | instructions returned | not used | unknown work item, unknown mode | not used | +| `validate` | no error diagnostics | one or more error diags | missing file, bad glob, unreadable catalog | source stale relative to a work item (see `validate`) | +| `accept` | acceptance recorded | not used | unknown work item | status not `valid`, or source stale | +| `show` | artifact resolved | not used | unknown work item, unknown artifact | not used | + +[DECIDED] Only `validate` returns exit `1`. Exit `1` means "error diagnostics exist", which is a linting outcome. Other commands surface problems as exit `2` (resolution) or `3` (refusal), never `1`. Rationale: keeps `1` a reliable signal that requirements failed validation, which is what scripts branch on. + +[DECIDED] `validate` on stale work. When `validate` runs with `--source` against a work item whose source hash no longer matches the manifest, validation of the `.ears` file still runs and reports linting results, and the response sets `stale: true`. The exit code stays driven by diagnostics (`0` or `1`); staleness alone does not force exit `3` on `validate`. Rationale: staleness is a warning about drift, not a parse failure; `accept` is the gate that refuses stale work with exit `3`. + +## `init` + +Initialize `.earsyntax/` and optional agent wrapper files. + +Top-level keys beyond the base: + +| Key | Type | Description | +| --------- | ---------- | ------------------------------------------------------------------ | +| `written` | `string[]` | Every path written, relative to root. Empty when nothing changed. | +| `tools` | `string[]` | Tool wrappers written, for example `["claude", "codex"]`. | + +```json +{ + "version": "0.1.0", + "command": "init", + "ok": true, + "root": "/repo", + "written": [".earsyntax/config.json", ".earsyntax/work/.gitkeep"], + "tools": ["claude", "codex"], + "next": [ + { + "command": "earsyntax new checkout-webhooks --source specs/checkout.md --json", + "reason": "Create a work item for a source spec.", + "forAgent": true + } + ] +} +``` + +`init` refuses to overwrite an existing `.earsyntax/` without `--force` and returns exit `3` in that case. + +## `doctor` + +Read-only project health report. Never writes. + +Top-level keys beyond the base: + +| Key | Type | Description | +| -------- | ------------------- | ----------------------------------------------------------------------- | +| `checks` | `DoctorCheck[]` | One entry per health check, each with `name`, `ok`, and a `message`. | + +[DECIDED] `checks` shape. The brief lists the checks doctor performs but not a JSON field. v0.1 returns `checks: { name: string; ok: boolean; severity: 'error'|'warning'|'info'; message: string }[]`, and mirrors any non-passing check into the base `diagnostics` array. Rationale: agents can read one array (`diagnostics`) for problems while humans get the full ordered checklist in `checks`. + +Checks performed: `.earsyntax/config.json` exists and parses; the configured work directory exists; the package version supports the requested facade features; referenced sources still exist; accepted work items are not stale; configured tool wrapper files are present. A failed check is a `FacadeDiagnostic`; `ok` is `true` only when no check produced an `error`. + +## `version` + +Expose installed capabilities. Never resolves a project, so `root` may be absent. + +Top-level keys beyond the base: + +| Key | Type | Description | +| ---------- | -------- | ---------------------------------------------------- | +| `features` | `object` | Capability map for agents to branch on without guessing. | + +```json +{ + "version": "0.1.0", + "command": "version", + "ok": true, + "features": { + "facade": 1, + "workItems": true, + "instructions": ["author", "convert", "repair", "review"], + "inputFormats": [".ears", "markdown", "yaml", "json"], + "outputFormats": ["pretty", "json"], + "sarif": false + }, + "next": [] +} +``` + +`features.facade` is an integer facade contract version. It increments only on a breaking change to these shapes. + +## `new` + +Create a work item. Does not generate EARS content; it prepares the directory the agent writes into. + +Top-level keys beyond the base: + +| Key | Type | Description | +| --------- | ------------- | ------------------------------------------------------ | +| `work` | `WorkSummary` | The created work item summary. | +| `written` | `string[]` | Every path written, relative to root. | + +```json +{ + "version": "0.1.0", + "command": "new", + "ok": true, + "root": "/repo", + "work": { + "id": "checkout-webhooks", + "mode": "convert", + "status": "scaffolded", + "source": "specs/checkout.md", + "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears" + }, + "written": [ + ".earsyntax/work/checkout-webhooks/manifest.json", + ".earsyntax/work/checkout-webhooks/requirements.ears", + ".earsyntax/work/checkout-webhooks/questions.md", + ".earsyntax/work/checkout-webhooks/traceability.json" + ], + "next": [ + { + "command": "earsyntax instructions convert --work checkout-webhooks --json", + "reason": "Give the coding agent rules for writing the .ears file.", + "forAgent": true + } + ] +} +``` + +Mode selection: `--mode convert` requires `--source `; `--mode author` requires `--prompt `. The default output path is `.earsyntax/work//requirements.ears`, overridable with `--out`. The source file is not copied unless `--snapshot-source` is passed, in which case the copy path is recorded in the manifest `source.snapshotPath`. `new` refuses to clobber an existing work item without `--force` and returns exit `3`. + +## Work manifest + +`new` creates this file; later commands update it. It is the durable state of a work item and lives at `.earsyntax/work//manifest.json`. + +```ts +type WorkMode = 'author' | 'convert'; + +type WorkStatus = + | 'missing' + | 'scaffolded' + | 'drafted' + | 'invalid' + | 'valid' + | 'accepted' + | 'stale'; + +interface WorkManifest { + schemaVersion: 1; + id: string; + mode: WorkMode; + status: WorkStatus; + source?: { + path?: string; + hash?: string; + kind: 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; + snapshotPath?: string; + }; + prompt?: string; + output: { + path: string; + hash?: string; + }; + artifacts: { + questions: string; + traceability: string; + validationJson: string; + validationMarkdown: string; + }; + createdAt: string; + updatedAt: string; + accepted?: { + at: string; + by?: string; + sourceHash?: string; + outputHash: string; + }; +} +``` + +Notes on state: + +- `status` is the state machine value. Its transitions are specified in `docs/agentic-loop.md`. +- `source.hash` is the hash of the source content at the last command that read it. `stale` is detected by comparing the current source hash to the hash recorded at the last `valid` or `accepted` transition. +- `output.hash` is the hash of the `.ears` content at the last validation. It lets `accept` detect a `.ears` file edited after validation. +- `missing` never appears in a stored manifest; it is the reported status when a queried work item has no manifest. + +## `list` + +List work items. Supports `--status ` to filter. + +Top-level keys beyond the base: + +| Key | Type | Description | +| ------- | --------------- | ------------------------------- | +| `items` | `WorkSummary[]` | Work items, sorted by `id`. | + +## `status` + +Report one work item's state and next steps. + +Top-level keys beyond the base: + +| Key | Type | Description | +| ------ | -------- | ------------------------------------ | +| `work` | `object` | Work item state (shape below). | + +```json +{ + "version": "0.1.0", + "command": "status", + "ok": true, + "root": "/repo", + "work": { + "id": "checkout-webhooks", + "status": "valid", + "mode": "convert", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "acceptedHash": null, + "stale": false + }, + "next": [ + { + "command": "earsyntax instructions review --work checkout-webhooks --json", + "reason": "Prepare the human review summary.", + "forAgent": true + }, + { + "command": "earsyntax accept checkout-webhooks", + "reason": "Record acceptance after human approval.", + "blocking": true + } + ] +} +``` + +`acceptedHash` is the `accepted.outputHash` from the manifest, or `null` when the item was never accepted. `stale` is the computed staleness flag. `next` reflects the current state: a `scaffolded` item points at `instructions`, an `invalid` item points at `validate` then `instructions repair`, a `valid` item points at review and accept, a `stale` item points at re-running `instructions convert`. + +## `instructions` + +Return the rules a coding agent follows for one step. Read-only. The four modes are `author`, `convert`, `repair`, and `review`. + +```ts +interface InstructionResponse { + work: WorkSummary; + mode: 'author' | 'convert' | 'repair' | 'review'; + rules: string[]; + format: { + line: string; + allowedPatterns: string[]; + metadataPrefixes: string[]; + }; + source?: { + path?: string; + hash?: string; + excerpts?: SourceExcerpt[]; + }; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; +} + +interface WorkSummary { + id: string; + status: WorkStatus; + source?: string; + output: string; + questions: string; + traceability: string; +} + +interface SourceExcerpt { + path: string; + startLine: number; + endLine: number; + text: string; +} +``` + +The response carries `InstructionResponse` fields as top-level keys alongside the base fields, so the object shape is `{ version, command, ok, root, mode, work, rules, format, source?, diagnostics?, next }`. + +Per-mode content: + +| Mode | `source` present | `diagnostics` present | `rules` focus | +| --------- | --------------------------- | ------------------------------ | ---------------------------------------------------------------- | +| `author` | no (prompt-based work item) | no | Author from the prompt only; do not invent behavior. | +| `convert` | yes (path, hash, excerpts) | no | Convert the source; preserve traceability; split compounds. | +| `repair` | yes when the item has one | yes (the diagnostics to fix) | Change only what the diagnostics justify; keyed to codes. | +| `review` | yes when the item has one | no | Produce a human review summary; recommend accept only if clean. | + +The full rule text is specified in `docs/agent-rules.md`. The `command` field is `instructions ` (for example `instructions convert`). Fixtures: `fixtures/facade/instructions-*.json`. + +## `validate` + +Validate `.ears` files deterministically. Never writes, never asks questions. This is the only command that returns exit `1`. + +```ts +interface ValidationResponse { + summary: { + files: number; + requirements: number; + valid: number; + errors: number; + warnings: number; + }; + results: ValidationResult[]; + work?: WorkSummary; + stale?: boolean; + next: NextAction[]; +} + +interface ValidationResult { + id?: string; + file: string; + line?: number; + valid: boolean; + pattern?: Pattern; + ast?: EarsAst; + references: ReferenceMatch[]; + diagnostics: Diagnostic[]; +} +``` + +The response carries these as top-level keys alongside the base fields. `ok` is `false` when `summary.errors > 0`, and the exit code is `1` in that case. `work` and `stale` are present only when validation ran against a work item (via `--work` or a path inside `.earsyntax/work/`). The `diagnostics` inside each `ValidationResult` are core linting `Diagnostic` objects (see `docs/diagnostics.md`), not `FacadeDiagnostic` objects. Fixture: `fixtures/facade/validate.response.json`. + +## `accept` + +Record that a human accepted the generated `.ears` file. This is the human gate. + +Top-level keys beyond the base: + +| Key | Type | Description | +| ------ | -------- | ------------------------------------------- | +| `work` | `object` | Updated work summary including `accepted`. | + +Rules, each a refusal with exit `3` when violated: + +- Refuse if the current status is not `valid`. +- Refuse if the source hash is stale relative to the recorded valid hash. +- Refuse if the `.ears` output hash changed since the last validation (the file was edited after it was validated). + +On success it writes the `accepted` block to `manifest.json` and sets status to `accepted`. It never modifies the source spec or the `.ears` content. `--by ` records who accepted. + +[DECIDED] `accept` is `Partly` agent-compatible, per the command summary. It has a `--json` shape and can be scripted, but the brief classifies acceptance as a human decision, so `next` actions that lead to `accept` carry `blocking: true` and never `forAgent: true`. + +## `show` + +Resolve artifact paths or print artifact content. Read-only. + +Selectors: `--artifact requirements | questions | traceability | validation | manifest`. Without `--artifact`, `show` returns resolved paths for every artifact. + +Top-level keys beyond the base: + +| Key | Type | Description | +| ---------- | -------- | -------------------------------------------------------------------- | +| `work` | `object` | Work summary with resolved artifact paths. | +| `artifact` | `object` | Present when `--artifact` selects one: `{ path, exists, content? }`. | + +[DECIDED] `show` content inclusion. With `--json` and a single `--artifact`, the response includes the artifact `content` as a string when the file exists and is UTF-8 text; binary or missing files set `content` to `null` and `exists` accordingly. Rationale: agents reading `questions` or `validation` in a loop should not need a second file read. + +## `export` + +Planned, not implemented in v0.1. Documented in the orchestration brief for forward reference only. It does not appear in `version.features` until shipped. + +## Stability guarantees + +- The base fields (`version`, `command`, `ok`, `next`) are present on every `--json` response and do not change shape within a facade contract version. +- `features.facade` is the contract version. Field removals or renames increment it. +- New optional keys may be added within a contract version; consumers must ignore unknown keys. +- `next[].command` values are always runnable `earsyntax` commands from this contract. +- Diagnostic codes inside validation results come from the frozen 29-code registry and are append-only. \ No newline at end of file diff --git a/docs/grammar.md b/docs/grammar.md new file mode 100644 index 0000000..fb8da69 --- /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..3d7687c --- /dev/null +++ b/docs/input-formats.md @@ -0,0 +1,238 @@ +# Input Formats + +`@earsyntax/extract` turns the files people actually write into the +`RequirementInput` shape that `@earsyntax/core` lints. It supports four formats: +`.ears`, Markdown, YAML, and JSON. Every extractor is a pure function of its +input string, returns items in document order, and records a 1-based source +line for each requirement. + +```ts +import { extractFromContent } from '@earsyntax/extract'; + +const md = '- REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; +const { items, errors } = extractFromContent(md, 'requirements.md'); +// items[0] === { +// id: 'REQ-001', +// text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', +// source: { file: 'requirements.md', line: 1 }, +// } +``` + +This package extracts requirements only. It never lints them or parses EARS +grammar; pass the extracted `items` to `@earsyntax/core` for that. + +## What every extractor returns + +Each extractor returns an `ExtractResult`: + +```ts +interface ExtractResult { + items: RequirementInput[]; // in document order + errors: ExtractError[]; // empty on a clean extraction +} + +interface ExtractError { + message: string; + file?: string; + line?: number; +} +``` + +Extraction is tolerant. A malformed file or a single bad entry produces an +`ExtractError` rather than a thrown exception, and the good entries around it +are still returned. IDs are always optional: when a source provides one it is +lifted into `item.id`, otherwise the item carries `text` and `source` only. + +## `.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. +``` + +```ts +import { extractEars } from '@earsyntax/extract'; + +const { items } = extractEars(content, 'billing.ears'); +// items[0].id === 'REQ-001' +// items[0].source === { file: 'billing.ears', line: 2 } +``` + +A colon inside the requirement text is not mistaken for an ID prefix, because an +ID cannot contain spaces. `When the report is ready, the billing service shall +emit: a summary.` extracts with no `id` and its full text intact. + +### 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. +``` + +The id and the `[source: ...]` segment are stripped from the text before it is +handed to the linter, so the requirement classifies correctly. When a +`[source: path:line]` reference is present it becomes the item's source +location, overriding the physical `.ears` file and line: + +```ts +const line = + 'REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature.'; +const { items } = extractEars(line, 'requirements.ears'); +// items[0] === { +// id: 'REQ-001', +// text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', +// source: { file: 'specs/checkout.md', line: 14 }, +// } +``` + +For a `line-line` range the start line is used. Because `SourceLocation` holds a +single file and line, the physical `.ears` position is not retained separately +once a declared reference is present. A malformed `[source: ...]` segment (one +that does not parse as `path:line`) is stripped from the text, the id is still +extracted, and the source falls back to the physical `.ears` file and line. + +## Markdown + +The Markdown extractor reads requirements from three structures and ignores +everything else. Prose paragraphs, headings, and fenced code blocks (both +` ``` ` and `~~~`) are skipped entirely. + +### Bullet and numbered lists + +Bullets (`-`, `*`, `+`) and numbered items (`1.`, `1)`) each become one +requirement. The same metadata prefix as `.ears` applies inside the item: a bare +`REQ-001:` id, or `REQ-001 [source: path:line]:` with a declared source +reference that becomes the item's source location. + +```md +- REQ-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. +1. REQ-003 [source: specs/checkout.md:20]: The billing service shall retain receipts for seven years. +``` + +The first item extracts as `id: 'REQ-001'`; the second has no id; the third +carries `id: 'REQ-003'` with `source: { file: 'specs/checkout.md', line: 20 }`. + +### Tables + +GFM pipe tables are supported in two forms. A table with `ID` and `Requirement` +columns takes the id from the `ID` column: + +```md +| ID | Requirement | +| ------- | ---------------------------------------------------------------------------------------- | +| 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. | +``` + +A single-column requirement table also works, and an `ID:` prefix inside a cell +is lifted just like in a bullet: + +```md +| Requirement | +| --- | +| REQ-001: The billing service shall verify the HMAC signature. | +| The billing service shall reject invalid webhooks. | +``` + +Column headers are matched case-insensitively. `ID` selects the id column; +`Requirement`, `Requirements`, `Text`, or `Statement` selects the text column. +A table needs a separator row (`| --- |`) with a header row directly above it; +pipe lines without one are treated as prose and ignored. + +## 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. +``` + +```ts +import { extractYaml } from '@earsyntax/extract'; + +const { items, errors } = extractYaml(content, 'requirements.yaml'); +``` + +The first entry extracts with `id: '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 in `errors`. Malformed YAML stops extraction; a +single bad entry is skipped while the others are still returned. + +```ts +// requirements: +// - id: REQ-001 +// text: "unterminated +// errors[0].message === 'Malformed YAML: ...' +// errors[0].line === 3 +``` + +## 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." + } + ] +} +``` + +```ts +import { extractJson } from '@earsyntax/extract'; + +const { items, errors } = extractJson(content, 'requirements.json'); +``` + +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 in +`errors` rather than thrown. + +## Dispatch by extension + +`extractFromContent` picks the right extractor from a file name's extension and +records that name as each item's source file: + +| Extension | Extractor | +| -------------------- | ----------------- | +| `.ears` | `extractEars` | +| `.md`, `.markdown` | `extractMarkdown` | +| `.yaml`, `.yml` | `extractYaml` | +| `.json` | `extractJson` | + +An unsupported extension returns no items and a single `ExtractError`. + +```ts +import { extractFromContent, extractFromFile } from '@earsyntax/extract'; + +// From a string you already hold: +const result = extractFromContent(content, 'requirements.md'); + +// Or read from disk. This is the only function in the package that touches +// the file system; a read failure is reported in `errors`, not thrown. +const fromDisk = extractFromFile('specs/requirements.yaml'); +``` diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..63c1e59 --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,180 @@ +# Public API Reference + +`@earsyntax/core` exposes a small, frozen surface. Every type is defined in `packages/core/src/types.ts` and re-exported from the package root. The functions below are the entire public API for v0.1. + +```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 name, then exact alias). +- Diagnostics are stably sorted by span, then code, then message, then severity. +- `lintEarsBatch` preserves input order: result `i` corresponds to input `i`. +- `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` with `valid`, `pattern`, `ast`, `references`, and sorted `diagnostics`. + +### Example + +```ts +const catalog = { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-WEBHOOK', name: 'a payment webhook is received' }], +}; + +const result = lintEars( + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + catalog, + { mode: 'strict' }, +); +``` + +The billing-webhook example from the orchestration brief produces: + +```json +{ + "valid": true, + "pattern": "event-driven", + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "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." + }, + "references": [], + "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`. + +### Example + +```ts +const results = 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.' }, + ], + catalog, + { mode: 'strict' }, +); + +// results[0].id === 'REQ-001', results[1].id === 'REQ-002' +``` + +## `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. + +### Example + +```ts +const parsed = parseEars( + 'While the payment provider is unavailable, the billing service shall queue retryable events.', +); + +// parsed.pattern === 'state-driven' +// parsed.ast.preconditions describes "the payment provider is unavailable" +``` + +## `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). + +### Example + +```ts +const diagnostics = 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' }, +); +``` + +Since the ledger service is never mentioned, 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" + } +] +``` + +## Types + +All shapes are defined and documented in `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 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..08c103a --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,186 @@ +# Quickstart + +This page takes you from an empty directory to a clean `earsyntax validate` in about five minutes, then shows the same result through the work-item loop a coding agent uses. 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. + +## Validate a file in five minutes + +### 1. Write three requirements + +Create `requirements.ears` with one requirement per line: + +```text +When a payment webhook is received, the billing service shall verify the HMAC signature. +If the HMAC signature is invalid, the billing service shall reject the webhook. +While the payment provider is unavailable, the billing service shall queue retryable events. +``` + +### 2. Validate + +```bash +npx @earsyntax/cli validate requirements.ears +``` + +Line 2 is missing the `then` boundary that an `If ... then` requirement needs, so validation reports one error and exits `1`: + +```text +requirements.ears:2 error ears.invalid_if_then_form The 'If' clause is missing the required 'then' boundary. + +2/3 valid, 1 errors, 0 warnings +``` + +### 3. Read the diagnostic + +The line format is `file:line severity code message`. Here the code is `ears.invalid_if_then_form`, and the message names the fix: an `If` clause needs a `then` before the system response. The [diagnostics reference](diagnostics.md) documents every code and its severity. + +### 4. Fix and re-validate + +Add `then` to line 2: + +```text +If the HMAC signature is invalid, then the billing service shall reject the webhook. +``` + +Validate again: + +```bash +npx @earsyntax/cli validate requirements.ears +``` + +```text + +3/3 valid, 0 errors, 0 warnings +``` + +The exit code is now `0`. That is the CI signal: `0` means every requirement is clean, `1` means at least one error diagnostic remains. + +### Strict and guided modes + +By default `validate` runs in `strict` mode, where structural defects are errors. In `guided` mode the same defects are downgraded to warnings, so a partly-formed file still exits `0`: + +```bash +npx @earsyntax/cli validate requirements.ears --mode guided +``` + +Guided mode is useful while drafting; keep strict mode in CI. + +## The work-item loop + +The commands above validate a file you already wrote. The work-item loop is the fuller path a coding agent follows to convert a source specification into a validated `.ears` file. The CLI scaffolds and validates, the agent writes requirements, and a human accepts. See [the agentic loop](agentic-loop.md) for the state machine and [the agent rules](agent-rules.md) for what the agent is told to do. + +The run below uses [`specs/checkout.md`](../specs/checkout.md) as the source. + +### 1. Initialize the workspace + +```bash +earsyntax init --tools none +``` + +```text +Initialized earsyntax in .earsyntax + wrote .earsyntax/config.json + wrote .earsyntax/work/.gitkeep + tools: none +``` + +### 2. Create a work item + +```bash +earsyntax new checkout-webhooks --source specs/checkout.md --mode convert +``` + +```text +Created work item "checkout-webhooks" (convert, scaffolded). + wrote .earsyntax/work/checkout-webhooks/requirements.ears + wrote .earsyntax/work/checkout-webhooks/questions.md + wrote .earsyntax/work/checkout-webhooks/traceability.json + wrote .earsyntax/work/checkout-webhooks/manifest.json +``` + +`new` does not generate requirements. It hashes the source, reserves the output path, and writes empty artifacts. The status is `scaffolded`. + +### 3. Get the conversion rules + +```bash +earsyntax instructions convert --work checkout-webhooks +``` + +```text +instructions convert for "checkout-webhooks" + - Read the full source before writing requirements. + - Write only EARS requirements in the .ears output file. + - Use one requirement per non-empty line. + - Give every requirement a stable ID such as REQ-001. + - Preserve source traceability with a [source: path:line] prefix when the source line is known. + - Use the narrowest EARS pattern that fits the source behavior. + - Do not invent behavior that is not stated in the source. + - Do not hide ambiguity inside vague wording. + - Split compound behavior into separate requirements when the response holds more than one observable obligation. + - Write unclear behavior to questions.md instead of guessing a precise requirement. + - Maintain traceability.json for every generated requirement and question. + - Do not edit the source spec unless the user explicitly asks. +``` + +With `--json` the same response carries source excerpts and a `next` action pointing at `validate`. The JSON contract is in [the facade API reference](facade-api.md). + +### 4. Write the requirements + +The agent reads the source and writes `.earsyntax/work/checkout-webhooks/requirements.ears`. Converting `specs/checkout.md` gives: + +```text +REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-002 [source: specs/checkout.md:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook. +REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. +REQ-004 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. +REQ-005 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. +REQ-006 [source: specs/checkout.md:15]: While the payment provider is unavailable, the billing service shall retry queued events. +REQ-007 [source: specs/checkout.md:17]: Where dunning management is enabled, the billing service shall retry declined charges. +``` + +The compound sentence on lines 12 to 13 of the source ("validate the signature, persist the event, and enqueue a processing job") became three separate requirements, REQ-003 through REQ-005. The declined-payment behavior on line 19 uses the vague term "quickly", so the agent raises a question in `questions.md` rather than inventing a channel and a time bound. + +### 5. Validate the work item + +```bash +earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --work checkout-webhooks +``` + +```text + +7/7 valid, 0 errors, 0 warnings +``` + +Because the target is a work item, `validate` records the result on the manifest and moves the status to `valid`. Had it reported errors, `earsyntax instructions repair --work checkout-webhooks` would return the diagnostics to fix, and you would repair and re-validate until clean. + +### 6. Confirm the state + +```bash +earsyntax status checkout-webhooks +``` + +```text +checkout-webhooks: valid (convert) + source: specs/checkout.md + output: .earsyntax/work/checkout-webhooks/requirements.ears +``` + +### 7. Accept + +Acceptance is a human decision, and the CLI enforces it: `accept` refuses unless the status is `valid`, the source is unchanged since validation, and the `.ears` file is unchanged since validation. + +```bash +earsyntax accept checkout-webhooks --by "omer" +``` + +```text +Accepted "checkout-webhooks" by omer. +``` + +The work item is now `accepted`, with the accepting user and the source and output hashes recorded in the manifest. If the source later changes, `status` reports the item as `stale`, and the loop runs again against the new source. + +## Next steps + +- [Author EARS by hand](authoring-ears.md): the six patterns, clause order, and common diagnostics. +- [API reference](api.md): `lintEars`, `lintEarsBatch`, `parseEars`, and `lintCatalogCoverage`. +- [CLI reference](cli.md): every command, its flags, and its output. \ No newline at end of file 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/demo/catalog.json b/fixtures/demo/catalog.json new file mode 100644 index 0000000..eae0501 --- /dev/null +++ b/fixtures/demo/catalog.json @@ -0,0 +1,26 @@ +{ + "systems": [ + { "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] }, + { "id": "SYS-PAYMENT", "name": "payment gateway", "aliases": ["payment service"] }, + { "id": "SYS-WEBHOOK", "name": "webhook processor", "aliases": ["webhook handler"] } + ], + "events": [ + { "id": "EVT-WEBHOOK", "name": "a payment webhook is received", "aliases": ["payment webhook received"] }, + { "id": "EVT-REFUND", "name": "a refund is requested", "aliases": ["refund requested"] }, + { "id": "EVT-INVOICE", "name": "an invoice is finalized", "aliases": ["invoice finalized"] }, + { "id": "EVT-PAYMENT-DECLINED", "name": "a payment is declined", "aliases": ["payment declined"] } + ], + "states": [ + { "id": "STATE-PROVIDER-UNAVAIL", "name": "the payment provider is unavailable", "aliases": ["provider unavailable"] }, + { "id": "STATE-MAINTENANCE", "name": "the system is in maintenance mode", "aliases": ["maintenance mode"] } + ], + "features": [ + { "id": "FEAT-DUNNING", "name": "dunning management is enabled", "aliases": ["dunning enabled", "dunning"] }, + { "id": "FEAT-TAX", "name": "tax calculation is enabled", "aliases": ["tax calculation"] } + ], + "conditions": [ + { "id": "COND-HMAC-INVALID", "name": "the HMAC signature is invalid", "aliases": ["invalid HMAC signature"] }, + { "id": "COND-PAYMENT-DECLINED", "name": "the payment is declined", "aliases": ["payment declined"] }, + { "id": "COND-STALE-TIMESTAMP", "name": "the webhook timestamp is stale", "aliases": ["stale webhook timestamp"] } + ] +} diff --git a/fixtures/demo/requirements.ears b/fixtures/demo/requirements.ears new file mode 100644 index 0000000..1135d7f --- /dev/null +++ b/fixtures/demo/requirements.ears @@ -0,0 +1,19 @@ +REQ-001: The billing service shall verify the HMAC signature of every incoming webhook. +REQ-002: The billing service shall retry failed webhook deliveries up to five times. +REQ-003: The payment gateway shall record an audit log entry for every processed webhook. +REQ-004: While the payment provider is unavailable, the billing service shall queue retryable events. +REQ-005: While the system is in maintenance mode, the billing service shall reject incoming webhooks. +REQ-006: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-007: When a refund is requested, the billing service shall issue a refund to the original payment method. +REQ-008: When an invoice is finalized, the billing service shall send the invoice to the customer. +REQ-009: Where dunning management is enabled, the billing service shall retry declined charges. +REQ-010: Where tax calculation is enabled, the billing service shall add sales tax to each invoice. +REQ-011: If the HMAC signature is invalid, then the billing service shall reject the webhook. +REQ-012: If the payment is declined, then the billing service shall notify the customer. +REQ-013: If the webhook timestamp is stale, then the billing service shall discard the event. +REQ-014: While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. +REQ-015: When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook. +REQ-016: The billing service verifies the HMAC signature. +REQ-017: If the payment is declined, the billing service shall notify the customer. +REQ-018: The billing service shall respond to webhook failures in an appropriate manner. +REQ-019: While the payment provider is unavailable, the billing service shall shall queue retryable events. diff --git a/fixtures/demo/valid-only.ears b/fixtures/demo/valid-only.ears new file mode 100644 index 0000000..df86f21 --- /dev/null +++ b/fixtures/demo/valid-only.ears @@ -0,0 +1,15 @@ +REQ-001: The billing service shall verify the HMAC signature of every incoming webhook. +REQ-002: The billing service shall retry failed webhook deliveries up to five times. +REQ-003: The payment gateway shall record an audit log entry for every processed webhook. +REQ-004: While the payment provider is unavailable, the billing service shall queue retryable events. +REQ-005: While the system is in maintenance mode, the billing service shall reject incoming webhooks. +REQ-006: When a payment webhook is received, the billing service shall verify the HMAC signature. +REQ-007: When a refund is requested, the billing service shall issue a refund to the original payment method. +REQ-008: When an invoice is finalized, the billing service shall send the invoice to the customer. +REQ-009: Where dunning management is enabled, the billing service shall retry declined charges. +REQ-010: Where tax calculation is enabled, the billing service shall add sales tax to each invoice. +REQ-011: If the HMAC signature is invalid, then the billing service shall reject the webhook. +REQ-012: If the payment is declined, then the billing service shall notify the customer. +REQ-013: If the webhook timestamp is stale, then the billing service shall discard the event. +REQ-014: While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. +REQ-015: When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook. 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..a0014cc --- /dev/null +++ b/fixtures/ears-lint-go-parity/A301.json @@ -0,0 +1,388 @@ +{ + "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..b836ba5 --- /dev/null +++ b/fixtures/ears-lint-go-parity/A303.json @@ -0,0 +1,392 @@ +{ + "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..1d3d7c4 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I402.json @@ -0,0 +1,380 @@ +{ + "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..08c0495 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I404.json @@ -0,0 +1,380 @@ +{ + "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..3eba7c8 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I405.json @@ -0,0 +1,369 @@ +{ + "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..f7cc8a5 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I406.json @@ -0,0 +1,369 @@ +{ + "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..42ea964 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I410.json @@ -0,0 +1,389 @@ +{ + "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..e5c8211 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I501.json @@ -0,0 +1,384 @@ +{ + "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..62f3b10 --- /dev/null +++ b/fixtures/ears-lint-go-parity/I502.json @@ -0,0 +1,373 @@ +{ + "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..8fbfd3e --- /dev/null +++ b/fixtures/ears-lint-go-parity/I506.json @@ -0,0 +1,380 @@ +{ + "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..321cf62 --- /dev/null +++ b/fixtures/ears-lint-go-parity/K901.json @@ -0,0 +1,398 @@ +{ + "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": 7, + "end": 124 + } + }, + "trigger": { + "kind": "group", + "span": { + "start": 136, + "end": 222 + } + }, + "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..959b845 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-alias-used.json @@ -0,0 +1,64 @@ +{ + "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..bb75abe --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json @@ -0,0 +1,74 @@ +{ + "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..6db9d9c --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-comma-as-and.json @@ -0,0 +1,60 @@ +{ + "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..9f172fe --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-complex-expr.json @@ -0,0 +1,59 @@ +{ + "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..620d335 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-determinism.json @@ -0,0 +1,72 @@ +{ + "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..5c96778 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-event-driven.json @@ -0,0 +1,66 @@ +{ + "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..9eda9c8 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json @@ -0,0 +1,57 @@ +{ + "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..e2bb6e1 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json @@ -0,0 +1,64 @@ +{ + "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..d8b2115 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json @@ -0,0 +1,59 @@ +{ + "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..7067f64 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json @@ -0,0 +1,64 @@ +{ + "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..057b0bb --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-optional-feature.json @@ -0,0 +1,59 @@ +{ + "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..4aaec7d --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-state-driven.json @@ -0,0 +1,59 @@ +{ + "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..287779f --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-system-unresolved.json @@ -0,0 +1,64 @@ +{ + "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..58777e6 --- /dev/null +++ b/fixtures/ears-lint-go-parity/LIB-ubiquitous.json @@ -0,0 +1,59 @@ +{ + "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..3ee0e5f --- /dev/null +++ b/fixtures/ears-lint-go-parity/N701.json @@ -0,0 +1,369 @@ +{ + "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..30d74bc --- /dev/null +++ b/fixtures/ears-lint-go-parity/S801.json @@ -0,0 +1,375 @@ +{ + "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..d80bc41 --- /dev/null +++ b/fixtures/ears-lint-go-parity/S803.json @@ -0,0 +1,375 @@ +{ + "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..fbe605b --- /dev/null +++ b/fixtures/ears-lint-go-parity/V001.json @@ -0,0 +1,375 @@ +{ + "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..0d9e75a --- /dev/null +++ b/fixtures/ears-lint-go-parity/V002.json @@ -0,0 +1,382 @@ +{ + "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..4faf8e0 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V003.json @@ -0,0 +1,375 @@ +{ + "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..32c944e --- /dev/null +++ b/fixtures/ears-lint-go-parity/V004.json @@ -0,0 +1,382 @@ +{ + "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..22c040c --- /dev/null +++ b/fixtures/ears-lint-go-parity/V005.json @@ -0,0 +1,382 @@ +{ + "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..0ef620d --- /dev/null +++ b/fixtures/ears-lint-go-parity/V006.json @@ -0,0 +1,375 @@ +{ + "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..f3813b1 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V102.json @@ -0,0 +1,389 @@ +{ + "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": 90 + } + }, + "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..8b65834 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V105.json @@ -0,0 +1,375 @@ +{ + "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..5bf3cfd --- /dev/null +++ b/fixtures/ears-lint-go-parity/V108.json @@ -0,0 +1,375 @@ +{ + "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..042a541 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V109.json @@ -0,0 +1,375 @@ +{ + "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..277fea0 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V110.json @@ -0,0 +1,380 @@ +{ + "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..1fa6562 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V201.json @@ -0,0 +1,384 @@ +{ + "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..2f0c9de --- /dev/null +++ b/fixtures/ears-lint-go-parity/V202.json @@ -0,0 +1,388 @@ +{ + "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..ecbb556 --- /dev/null +++ b/fixtures/ears-lint-go-parity/V204.json @@ -0,0 +1,384 @@ +{ + "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/facade/README.md b/fixtures/facade/README.md new file mode 100644 index 0000000..97ba7c3 --- /dev/null +++ b/fixtures/facade/README.md @@ -0,0 +1,42 @@ +# Facade fixtures + +Golden JSON examples for the `earsyntax` facade contracts. The CLI agent tests its output against these files. They are documents, not code: each pins the exact shape of one contract so that a change in CLI output that drifts from the contract shows up as a fixture diff. + +All fixtures use one running example, the `checkout-webhooks` work item converted from `specs/checkout.md`. Values that would otherwise vary by run are fixed so the fixtures are deterministic: + +- Source hash (version 1): `sha256:1111...1111` +- Output hash: `sha256:2222...2222` +- Source hash (version 2, the changed source that triggers `stale`): `sha256:3333...3333` +- Timestamps are fixed ISO 8601 UTC values on 2026-07-28. + +The normative field descriptions live in `docs/facade-api.md`. The loop and state machine live in `docs/agentic-loop.md`. The instruction bodies live in `docs/agent-rules.md`. + +## Manifests + +Each manifest pins the `WorkManifest` shape at one `WorkStatus` state. They share one id and progress through the loop. + +| File | Pins | +| -------------------------- | ------------------------------------------------------------------------------------------------------- | +| `scaffolded.manifest.json` | State right after `earsyntax new`: source hashed, output path reserved, no output hash, no acceptance. | +| `valid.manifest.json` | State after a clean `earsyntax validate`: output hash recorded, status `valid`, no acceptance yet. | +| `accepted.manifest.json` | State after `earsyntax accept`: an `accepted` block with `at`, `by`, `sourceHash`, and `outputHash`. | +| `stale.manifest.json` | Source hash changed after acceptance: `source.hash` differs from `accepted.sourceHash`, status `stale`. | + +## Instruction responses + +Each pins the `instructions --json` response for one mode. Every `next[]` entry is a real `earsyntax` command from the facade contract. + +| File | Pins | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `instructions-author.json` | Author mode: no `source` block, rules forbid inventing behavior beyond the prompt. | +| `instructions-convert.json` | Convert mode: a `source` block with hash and excerpts, full convert rule set. | +| `instructions-repair.json` | Repair mode: a `diagnostics` array the repair must address, rules keyed to the reported codes. | +| `instructions-review.json` | Review mode: review-summary rules, `next[]` points at reading questions and recording acceptance. | + +## Other responses + +| File | Pins | +| ------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `traceability.json` | The `TraceabilityFile` shape: requirement traces with source lines and confidence, plus a blocking question. | +| `validate.response.json` | A `validate --json` response with one error result, one warning result, and a `work` summary; `ok` is false. | +| `status.response.json` | A `status --json` response for a `valid` work item, with review and accept as next actions. | \ No newline at end of file diff --git a/fixtures/facade/accepted.manifest.json b/fixtures/facade/accepted.manifest.json new file mode 100644 index 0000000..b4ddce0 --- /dev/null +++ b/fixtures/facade/accepted.manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "id": "checkout-webhooks", + "mode": "convert", + "status": "accepted", + "source": { + "path": "specs/checkout.md", + "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "kind": "markdown" + }, + "output": { + "path": ".earsyntax/work/checkout-webhooks/requirements.ears", + "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "artifacts": { + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", + "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", + "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" + }, + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T10:30:00.000Z", + "accepted": { + "at": "2026-07-28T10:30:00.000Z", + "by": "omer", + "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + } +} diff --git a/fixtures/facade/instructions-author.json b/fixtures/facade/instructions-author.json new file mode 100644 index 0000000..c64af9c --- /dev/null +++ b/fixtures/facade/instructions-author.json @@ -0,0 +1,48 @@ +{ + "version": "0.1.0", + "command": "instructions author", + "ok": true, + "root": "/repo", + "mode": "author", + "work": { + "id": "checkout-webhooks", + "status": "scaffolded", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "rules": [ + "Treat the prompt as the only source of behavior; do not add requirements it does not state.", + "Write only EARS requirements in the .ears output file.", + "Use one requirement per non-empty line.", + "Give every requirement a stable ID such as REQ-001.", + "Use the narrowest EARS pattern that fits the described behavior.", + "Do not invent behavior that seems reasonable, secure, or common but is absent from the prompt.", + "Do not hide ambiguity inside vague wording.", + "Split compound behavior into separate requirements when the response holds more than one observable obligation.", + "Write unclear behavior to questions.md instead of guessing a precise requirement.", + "Record each generated requirement in traceability.json; author-mode requirements have no source line, so leave the source object empty and set confidence." + ], + "format": { + "line": "REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.", + "allowedPatterns": [ + "The shall .", + "When , the shall .", + "While , the shall .", + "Where , the shall .", + "If , then the shall ." + ], + "metadataPrefixes": [ + "REQ-001:", + "REQ-001 [source: path:line]:", + "REQ-001 [source: path:line-line]:" + ] + }, + "next": [ + { + "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --json", + "reason": "Validate the authored .ears file once it is written.", + "forAgent": true + } + ] +} \ No newline at end of file diff --git a/fixtures/facade/instructions-convert.json b/fixtures/facade/instructions-convert.json new file mode 100644 index 0000000..3fe4984 --- /dev/null +++ b/fixtures/facade/instructions-convert.json @@ -0,0 +1,75 @@ +{ + "version": "0.1.0", + "command": "instructions convert", + "ok": true, + "root": "/repo", + "mode": "convert", + "work": { + "id": "checkout-webhooks", + "status": "scaffolded", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "rules": [ + "Read the full source before writing requirements.", + "Write only EARS requirements in the .ears output file.", + "Use one requirement per non-empty line.", + "Give every requirement a stable ID such as REQ-001.", + "Preserve source traceability with a [source: path:line] prefix when the source line is known.", + "Use the narrowest EARS pattern that fits the source behavior.", + "Do not invent behavior that is not stated in the source.", + "Do not hide ambiguity inside vague wording.", + "Split compound behavior into separate requirements when the response holds more than one observable obligation.", + "Write unclear behavior to questions.md instead of guessing a precise requirement.", + "Maintain traceability.json for every generated requirement and question.", + "Do not edit the source spec unless the user explicitly asks." + ], + "format": { + "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", + "allowedPatterns": [ + "The shall .", + "When , the shall .", + "While , the shall .", + "Where , the shall .", + "If , then the shall ." + ], + "metadataPrefixes": [ + "REQ-001:", + "REQ-001 [source: path:line]:", + "REQ-001 [source: path:line-line]:" + ] + }, + "source": { + "path": "specs/checkout.md", + "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "excerpts": [ + { + "path": "specs/checkout.md", + "startLine": 7, + "endLine": 8, + "text": "When a payment webhook is received, the billing service must verify the HMAC signature before doing anything else." + }, + { + "path": "specs/checkout.md", + "startLine": 10, + "endLine": 10, + "text": "If the HMAC signature is invalid, the webhook must be rejected." + }, + { + "path": "specs/checkout.md", + "startLine": 12, + "endLine": 13, + "text": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." + } + ] + }, + "next": [ + { + "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", + "reason": "Validate the generated .ears file once it is written.", + "forAgent": true + } + ] +} \ No newline at end of file diff --git a/fixtures/facade/instructions-repair.json b/fixtures/facade/instructions-repair.json new file mode 100644 index 0000000..eb9c7fd --- /dev/null +++ b/fixtures/facade/instructions-repair.json @@ -0,0 +1,64 @@ +{ + "version": "0.1.0", + "command": "instructions repair", + "ok": true, + "root": "/repo", + "mode": "repair", + "work": { + "id": "checkout-webhooks", + "status": "invalid", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "rules": [ + "Change only what the diagnostics justify; leave passing requirements untouched.", + "Preserve requirement IDs unless a duplicate-ID diagnostic forces a change.", + "Preserve the [source: path:line] references on repaired lines.", + "Preserve the intended behavior; do not weaken a requirement because it is harder to parse.", + "Do not delete a failing requirement to make validation pass.", + "For ears.invalid_if_then_form, add the missing then boundary: If , then the shall .", + "For lint.vague_response, replace the vague term with an observable, bounded response, or raise a question in questions.md if the bound is unknown.", + "If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing.", + "Update traceability.json if an ID or source reference changes." + ], + "format": { + "line": "REQ-002 [source: specs/checkout.md:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook.", + "allowedPatterns": [ + "The shall .", + "When , the shall .", + "While , the shall .", + "Where , the shall .", + "If , then the shall ." + ], + "metadataPrefixes": [ + "REQ-001:", + "REQ-001 [source: path:line]:", + "REQ-001 [source: path:line-line]:" + ] + }, + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error", + "message": "If requirements must include a then boundary before the system response.", + "path": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 2 + }, + { + "code": "lint.vague_response", + "severity": "warning", + "message": "The response contains a configured vague term.", + "path": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 8 + } + ], + "next": [ + { + "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", + "reason": "Re-validate after applying the repairs and repeat until validation is clean.", + "forAgent": true + } + ] +} diff --git a/fixtures/facade/instructions-review.json b/fixtures/facade/instructions-review.json new file mode 100644 index 0000000..dbb8903 --- /dev/null +++ b/fixtures/facade/instructions-review.json @@ -0,0 +1,51 @@ +{ + "version": "0.1.0", + "command": "instructions review", + "ok": true, + "root": "/repo", + "mode": "review", + "work": { + "id": "checkout-webhooks", + "status": "valid", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "rules": [ + "Present a review summary before recommending acceptance; do not accept on the human's behalf.", + "Report how many requirements were generated and the distribution across the six EARS patterns.", + "List every unresolved question in questions.md and the requirements each one blocks.", + "State the validation status and the exact command that produced it.", + "Call out source lines translated with medium or low confidence.", + "Call out any source behavior that was intentionally not converted, with the reason.", + "Recommend acceptance only when validation is clean and no blocking questions remain." + ], + "format": { + "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", + "allowedPatterns": [ + "The shall .", + "When , the shall .", + "While , the shall .", + "Where , the shall .", + "If , then the shall ." + ], + "metadataPrefixes": [ + "REQ-001:", + "REQ-001 [source: path:line]:", + "REQ-001 [source: path:line-line]:" + ] + }, + "next": [ + { + "command": "earsyntax show checkout-webhooks --artifact questions --json", + "reason": "Read the open questions the human must resolve before acceptance.", + "forAgent": true + }, + { + "command": "earsyntax accept checkout-webhooks --by \"omer\"", + "reason": "Record human acceptance once the reviewer approves.", + "blocking": true + } + ] +} \ No newline at end of file diff --git a/fixtures/facade/scaffolded.manifest.json b/fixtures/facade/scaffolded.manifest.json new file mode 100644 index 0000000..9cd8bbf --- /dev/null +++ b/fixtures/facade/scaffolded.manifest.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "checkout-webhooks", + "mode": "convert", + "status": "scaffolded", + "source": { + "path": "specs/checkout.md", + "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "kind": "markdown" + }, + "output": { + "path": ".earsyntax/work/checkout-webhooks/requirements.ears" + }, + "artifacts": { + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", + "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", + "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" + }, + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T10:00:00.000Z" +} \ No newline at end of file diff --git a/fixtures/facade/stale.manifest.json b/fixtures/facade/stale.manifest.json new file mode 100644 index 0000000..acf9e02 --- /dev/null +++ b/fixtures/facade/stale.manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "id": "checkout-webhooks", + "mode": "convert", + "status": "stale", + "source": { + "path": "specs/checkout.md", + "hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "kind": "markdown" + }, + "output": { + "path": ".earsyntax/work/checkout-webhooks/requirements.ears", + "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "artifacts": { + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", + "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", + "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" + }, + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T11:00:00.000Z", + "accepted": { + "at": "2026-07-28T10:30:00.000Z", + "by": "omer", + "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + } +} \ No newline at end of file diff --git a/fixtures/facade/status.response.json b/fixtures/facade/status.response.json new file mode 100644 index 0000000..200cd0d --- /dev/null +++ b/fixtures/facade/status.response.json @@ -0,0 +1,29 @@ +{ + "version": "0.1.0", + "command": "status", + "ok": true, + "root": "/repo", + "work": { + "id": "checkout-webhooks", + "status": "valid", + "mode": "convert", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "acceptedHash": null, + "stale": false + }, + "next": [ + { + "command": "earsyntax instructions review --work checkout-webhooks --json", + "reason": "Prepare the human review summary.", + "forAgent": true + }, + { + "command": "earsyntax accept checkout-webhooks", + "reason": "Record acceptance after human approval.", + "blocking": true + } + ] +} \ No newline at end of file diff --git a/fixtures/facade/traceability.json b/fixtures/facade/traceability.json new file mode 100644 index 0000000..970ff6b --- /dev/null +++ b/fixtures/facade/traceability.json @@ -0,0 +1,99 @@ +{ + "requirements": [ + { + "id": "REQ-001", + "source": { + "path": "specs/checkout.md", + "startLine": 7, + "endLine": 8, + "quote": "When a payment webhook is received, the billing service must verify the HMAC signature before doing anything else." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": [] + }, + { + "id": "REQ-002", + "source": { + "path": "specs/checkout.md", + "startLine": 10, + "endLine": 10, + "quote": "If the HMAC signature is invalid, the webhook must be rejected." + }, + "pattern": "unwanted-behaviour", + "confidence": "high", + "notes": [] + }, + { + "id": "REQ-003", + "source": { + "path": "specs/checkout.md", + "startLine": 12, + "endLine": 13, + "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": ["Split from a compound source obligation; see REQ-004 and REQ-005."] + }, + { + "id": "REQ-004", + "source": { + "path": "specs/checkout.md", + "startLine": 12, + "endLine": 13, + "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": ["Split from a compound source obligation; see REQ-003 and REQ-005."] + }, + { + "id": "REQ-005", + "source": { + "path": "specs/checkout.md", + "startLine": 12, + "endLine": 13, + "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." + }, + "pattern": "event-driven", + "confidence": "high", + "notes": ["Split from a compound source obligation; see REQ-003 and REQ-004."] + }, + { + "id": "REQ-006", + "source": { + "path": "specs/checkout.md", + "startLine": 15, + "endLine": 15, + "quote": "While the payment provider is unavailable, queued events should be retried." + }, + "pattern": "state-driven", + "confidence": "medium", + "notes": ["Source uses \"should\"; treated as a requirement pending human confirmation."] + }, + { + "id": "REQ-007", + "source": { + "path": "specs/checkout.md", + "startLine": 17, + "endLine": 17, + "quote": "Where dunning management is enabled, declined charges are retried." + }, + "pattern": "optional-feature", + "confidence": "high", + "notes": [] + } + ], + "questions": [ + { + "source": { + "path": "specs/checkout.md", + "startLine": 19, + "endLine": 19 + }, + "question": "Source specs/checkout.md:19 says the customer should be notified \"quickly\" when a payment is declined. What channel should be used, and what is the time bound?", + "blocksRequirements": ["REQ-008"] + } + ] +} \ No newline at end of file diff --git a/fixtures/facade/valid.manifest.json b/fixtures/facade/valid.manifest.json new file mode 100644 index 0000000..33fd380 --- /dev/null +++ b/fixtures/facade/valid.manifest.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "id": "checkout-webhooks", + "mode": "convert", + "status": "valid", + "source": { + "path": "specs/checkout.md", + "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "kind": "markdown" + }, + "output": { + "path": ".earsyntax/work/checkout-webhooks/requirements.ears", + "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "artifacts": { + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", + "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", + "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" + }, + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T10:15:00.000Z" +} \ No newline at end of file diff --git a/fixtures/facade/validate.response.json b/fixtures/facade/validate.response.json new file mode 100644 index 0000000..bab5282 --- /dev/null +++ b/fixtures/facade/validate.response.json @@ -0,0 +1,85 @@ +{ + "version": "0.1.0", + "command": "validate", + "ok": false, + "root": "/repo", + "summary": { + "files": 1, + "requirements": 8, + "valid": 7, + "errors": 1, + "warnings": 1 + }, + "results": [ + { + "id": "REQ-001", + "file": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 1, + "valid": true, + "pattern": "event-driven", + "ast": { + "pattern": "event-driven", + "trigger": { + "kind": "term", + "text": "a payment webhook is received" + }, + "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." + }, + "references": [], + "diagnostics": [] + }, + { + "id": "REQ-002", + "file": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 2, + "valid": false, + "pattern": "unwanted-behaviour", + "references": [], + "diagnostics": [ + { + "code": "ears.invalid_if_then_form", + "severity": "error", + "message": "If requirements must include a then boundary before the system response.", + "span": { "start": 0, "end": 42 } + } + ] + }, + { + "id": "REQ-008", + "file": ".earsyntax/work/checkout-webhooks/requirements.ears", + "line": 8, + "valid": true, + "pattern": "event-driven", + "references": [], + "diagnostics": [ + { + "code": "lint.vague_response", + "severity": "warning", + "message": "The response contains a configured vague term.", + "span": { "start": 41, "end": 65 } + } + ] + } + ], + "work": { + "id": "checkout-webhooks", + "status": "invalid", + "source": "specs/checkout.md", + "output": ".earsyntax/work/checkout-webhooks/requirements.ears", + "questions": ".earsyntax/work/checkout-webhooks/questions.md", + "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" + }, + "stale": false, + "next": [ + { + "command": "earsyntax instructions repair --work checkout-webhooks --json", + "reason": "Get targeted repair rules for the reported diagnostics.", + "forAgent": true + } + ] +} \ No newline at end of file diff --git a/fixtures/invalid/catalog-system-ambiguous.json b/fixtures/invalid/catalog-system-ambiguous.json new file mode 100644 index 0000000..1424c97 --- /dev/null +++ b/fixtures/invalid/catalog-system-ambiguous.json @@ -0,0 +1,35 @@ +{ + "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..4d70d78 --- /dev/null +++ b/fixtures/invalid/catalog-system-unresolved.json @@ -0,0 +1,28 @@ +{ + "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/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/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/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/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/schema.md b/fixtures/schema.md new file mode 100644 index 0000000..f7a0f59 --- /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..0d8527c --- /dev/null +++ b/fixtures/valid/case-insensitive-if-then-upper.json @@ -0,0 +1,19 @@ +{ + "id": "VAL-018", + "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", + "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..813061b --- /dev/null +++ b/fixtures/valid/case-insensitive-when-lower.json @@ -0,0 +1,19 @@ +{ + "id": "VAL-016", + "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", + "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..9e66a1f --- /dev/null +++ b/fixtures/valid/case-insensitive-when-upper.json @@ -0,0 +1,19 @@ +{ + "id": "VAL-017", + "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", + "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..eb243a4 --- /dev/null +++ b/fixtures/valid/case-insensitive-while-mixed.json @@ -0,0 +1,19 @@ +{ + "id": "VAL-019", + "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", + "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..5187fc8 --- /dev/null +++ b/fixtures/valid/catalog-clause-term-unresolved.json @@ -0,0 +1,25 @@ +{ + "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..d722e9c --- /dev/null +++ b/fixtures/valid/complex-boolean-nested.json @@ -0,0 +1,34 @@ +{ + "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..c05d297 --- /dev/null +++ b/fixtures/valid/complex-when-if.json @@ -0,0 +1,27 @@ +{ + "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-while-when.json b/fixtures/valid/complex-while-when.json new file mode 100644 index 0000000..8213573 --- /dev/null +++ b/fixtures/valid/complex-while-when.json @@ -0,0 +1,27 @@ +{ + "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..632ee93 --- /dev/null +++ b/fixtures/valid/complex-while-where-when.json @@ -0,0 +1,31 @@ +{ + "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..b5a2125 --- /dev/null +++ b/fixtures/valid/event-driven-basic.json @@ -0,0 +1,23 @@ +{ + "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..6147d07 --- /dev/null +++ b/fixtures/valid/event-driven-refund.json @@ -0,0 +1,19 @@ +{ + "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..06ac57a --- /dev/null +++ b/fixtures/valid/event-driven-subscription.json @@ -0,0 +1,19 @@ +{ + "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..475f73e --- /dev/null +++ b/fixtures/valid/expr-and-keyword-comma-off.json @@ -0,0 +1,33 @@ +{ + "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..62bfb8c --- /dev/null +++ b/fixtures/valid/expr-and.json @@ -0,0 +1,30 @@ +{ + "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..86206b4 --- /dev/null +++ b/fixtures/valid/expr-comma-as-and.json @@ -0,0 +1,25 @@ +{ + "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..f478c9f --- /dev/null +++ b/fixtures/valid/expr-group.json @@ -0,0 +1,30 @@ +{ + "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..98d3414 --- /dev/null +++ b/fixtures/valid/expr-not.json @@ -0,0 +1,25 @@ +{ + "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..e3c9374 --- /dev/null +++ b/fixtures/valid/expr-or.json @@ -0,0 +1,30 @@ +{ + "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..21b0616 --- /dev/null +++ b/fixtures/valid/expr-precedence-warning.json @@ -0,0 +1,35 @@ +{ + "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/guided-catalog-system-unresolved.json b/fixtures/valid/guided-catalog-system-unresolved.json new file mode 100644 index 0000000..0d5a83a --- /dev/null +++ b/fixtures/valid/guided-catalog-system-unresolved.json @@ -0,0 +1,19 @@ +{ + "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..e2b50cf --- /dev/null +++ b/fixtures/valid/guided-empty-subexpression.json @@ -0,0 +1,13 @@ +{ + "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..63c187b --- /dev/null +++ b/fixtures/valid/guided-if-without-then.json @@ -0,0 +1,14 @@ +{ + "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..d0e2e89 --- /dev/null +++ b/fixtures/valid/guided-invalid-clause-order.json @@ -0,0 +1,14 @@ +{ + "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..d842193 --- /dev/null +++ b/fixtures/valid/guided-missing-shall.json @@ -0,0 +1,13 @@ +{ + "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..eb6096b --- /dev/null +++ b/fixtures/valid/guided-unbalanced-parentheses.json @@ -0,0 +1,13 @@ +{ + "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/lint-alias-used.json b/fixtures/valid/lint-alias-used.json new file mode 100644 index 0000000..fa039d3 --- /dev/null +++ b/fixtures/valid/lint-alias-used.json @@ -0,0 +1,36 @@ +{ + "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..1485370 --- /dev/null +++ b/fixtures/valid/lint-multiple-responses.json @@ -0,0 +1,25 @@ +{ + "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..b8b995a --- /dev/null +++ b/fixtures/valid/lint-unparsed-tail.json @@ -0,0 +1,24 @@ +{ + "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..bcecd2c --- /dev/null +++ b/fixtures/valid/lint-vague-response.json @@ -0,0 +1,24 @@ +{ + "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/optional-feature-basic.json b/fixtures/valid/optional-feature-basic.json new file mode 100644 index 0000000..6915920 --- /dev/null +++ b/fixtures/valid/optional-feature-basic.json @@ -0,0 +1,23 @@ +{ + "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..be4a2fb --- /dev/null +++ b/fixtures/valid/optional-feature-multicurrency.json @@ -0,0 +1,19 @@ +{ + "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..c568f61 --- /dev/null +++ b/fixtures/valid/optional-feature-tax.json @@ -0,0 +1,19 @@ +{ + "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/state-driven-basic.json b/fixtures/valid/state-driven-basic.json new file mode 100644 index 0000000..86dfad0 --- /dev/null +++ b/fixtures/valid/state-driven-basic.json @@ -0,0 +1,23 @@ +{ + "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..2d06e42 --- /dev/null +++ b/fixtures/valid/state-driven-maintenance.json @@ -0,0 +1,19 @@ +{ + "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..1d49c71 --- /dev/null +++ b/fixtures/valid/state-driven-provider-degraded.json @@ -0,0 +1,19 @@ +{ + "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..bc204c3 --- /dev/null +++ b/fixtures/valid/ubiquitous-audit-log.json @@ -0,0 +1,19 @@ +{ + "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..bb5ca7c --- /dev/null +++ b/fixtures/valid/ubiquitous-basic.json @@ -0,0 +1,19 @@ +{ + "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..282eb7c --- /dev/null +++ b/fixtures/valid/ubiquitous-retry-limit.json @@ -0,0 +1,19 @@ +{ + "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..99e73e3 --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-basic.json @@ -0,0 +1,23 @@ +{ + "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..5d1a470 --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-declined.json @@ -0,0 +1,19 @@ +{ + "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..7a7a687 --- /dev/null +++ b/fixtures/valid/unwanted-behaviour-stale.json @@ -0,0 +1,19 @@ +{ + "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..45d6e58 --- /dev/null +++ b/lerna.json @@ -0,0 +1,26 @@ +{ + "$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..fefcded --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "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", + "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" + ] + } +} \ No newline at end of file diff --git a/packages/cli-contract/package.json b/packages/cli-contract/package.json new file mode 100644 index 0000000..811c103 --- /dev/null +++ b/packages/cli-contract/package.json @@ -0,0 +1,46 @@ +{ + "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" + }, + "dependencies": { + "@earsyntax/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "dependency-cruiser": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cli-contract/src/__fixtures__/sample-run.ts b/packages/cli-contract/src/__fixtures__/sample-run.ts new file mode 100644 index 0000000..2635984 --- /dev/null +++ b/packages/cli-contract/src/__fixtures__/sample-run.ts @@ -0,0 +1,92 @@ +/** + * A hand-built linting run used across the contract tests. + * + * It exercises the shapes the builders care about: multiple files, requirements + * with and without ids, source lines and spans present and absent, and all + * three severities. It does not call the core linter (still a stub); it + * assembles {@link LintResult} values directly so the builders can be tested in + * isolation. + */ + +import type { LintResult } from '@earsyntax/core'; +import type { ReportInput } from '../input.js'; + +function result(partial: Partial & Pick): LintResult { + return { references: [], diagnostics: [], ...partial }; +} + +export const sampleRun = [ + { + path: 'specs/subscribe.ears', + items: [ + { + input: { id: 'REQ-1', text: 'The BFF shall return a 200 response.', source: { line: 3 } }, + result: result({ valid: true, pattern: 'ubiquitous' }), + }, + { + input: { + id: 'REQ-2', + text: 'When alice subscribes the BFF shall notify Redis appropriately.', + source: { line: 7 }, + }, + result: result({ + valid: false, + pattern: 'event-driven', + diagnostics: [ + { + code: 'lint.vague_response', + severity: 'warning', + message: 'A response uses vague, unverifiable wording.', + span: { start: 44, end: 57 }, + }, + { + code: 'catalog.system_unresolved', + severity: 'error', + message: 'The system does not resolve to a catalog entry.', + span: { start: 20, end: 23 }, + }, + ], + }), + }, + ], + }, + { + path: 'specs/payment.ears', + items: [ + { + // No id, no source line: exercises optional-key omission and the + // span-based SARIF region path. + input: { text: 'gibberish that does not match' }, + result: result({ + valid: false, + diagnostics: [ + { + code: 'ears.no_match', + severity: 'error', + message: 'Text does not match any EARS shell pattern.', + span: { start: 0, end: 29 }, + }, + { + code: 'lint.suspicious_text_shape', + severity: 'info', + message: 'Requirement text has a suspicious shape.', + }, + ], + }), + }, + ], + }, +] satisfies ReportInput; + +/** A clean run with no diagnostics, used for the "valid" and EXIT_OK paths. */ +export const cleanRun = [ + { + path: 'specs/clean.ears', + items: [ + { + input: { id: 'OK-1', text: 'The BFF shall persist the order.', source: { line: 1 } }, + result: result({ valid: true, pattern: 'ubiquitous' }), + }, + ], + }, +] satisfies ReportInput; diff --git a/packages/cli-contract/src/diagnostic-registry.test.ts b/packages/cli-contract/src/diagnostic-registry.test.ts new file mode 100644 index 0000000..2e62fe4 --- /dev/null +++ b/packages/cli-contract/src/diagnostic-registry.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; + +describe('diagnostic registry', () => { + it('lists every described code, sorted', () => { + const expected = [...Object.keys(DIAGNOSTIC_DESCRIPTIONS)].sort(); + expect(DIAGNOSTIC_CODES).toEqual(expected); + }); + + it('gives every code a non-empty description ending with a period', () => { + for (const code of DIAGNOSTIC_CODES) { + const text = DIAGNOSTIC_DESCRIPTIONS[code]; + expect(text.length).toBeGreaterThan(0); + expect(text.endsWith('.')).toBe(true); + } + }); + + it('has no duplicate codes', () => { + expect(new Set(DIAGNOSTIC_CODES).size).toBe(DIAGNOSTIC_CODES.length); + }); +}); diff --git a/packages/cli-contract/src/diagnostic-registry.ts b/packages/cli-contract/src/diagnostic-registry.ts new file mode 100644 index 0000000..b518f9c --- /dev/null +++ b/packages/cli-contract/src/diagnostic-registry.ts @@ -0,0 +1,67 @@ +/** + * The registry of diagnostic codes with short human-readable descriptions. + * + * This is the single source of truth the SARIF builder uses to declare tool + * rules, and any renderer can use it to look up a code's short description. + * The record is keyed by the frozen {@link DiagnosticCode} union, so adding a + * code to `@earsyntax/core` without describing it here is a compile error. + */ + +import type { DiagnosticCode } from '@earsyntax/core'; + +/** + * A one-line description for every {@link DiagnosticCode}. + * + * Descriptions are stable, third-person, and neutral. They describe what the + * code means, not how to fix it. The key set is exhaustive by construction + * (`Record`). + */ +export const DIAGNOSTIC_DESCRIPTIONS: Record = { + // EARS shell diagnostics. + 'ears.no_match': 'Text does not match any EARS shell pattern.', + 'ears.invalid_clause_order': 'EARS clauses appear in an invalid order.', + 'ears.missing_system': 'Requirement is missing a system before shall.', + 'ears.missing_shall': 'Requirement is missing the shall keyword.', + 'ears.multiple_shall': 'Requirement contains more than one shall keyword.', + 'ears.invalid_if_then_form': 'If/then unwanted-behaviour form is malformed.', + 'ears.empty_clause': 'A clause body is empty.', + 'ears.empty_response': 'The response after shall is empty.', + // Expression diagnostics. + 'expr.unbalanced_parentheses': 'Clause expression has unbalanced parentheses.', + 'expr.invalid_operator_sequence': 'Clause expression has an invalid operator sequence.', + 'expr.empty_subexpression': 'Clause expression contains an empty subexpression.', + 'expr.operator_precedence_warning': 'Mixed operators rely on implicit precedence.', + 'expr.unknown_term': 'A clause term does not resolve to a catalog entry.', + 'expr.ambiguous_term': 'A clause term matches more than one catalog entry.', + 'expr.mixed_unresolved_terms': 'A clause mixes resolved and unresolved terms.', + // Catalog diagnostics: system role. + 'catalog.system_unresolved': 'The system does not resolve to a catalog entry.', + 'catalog.system_ambiguous': 'The system matches more than one catalog entry.', + // Catalog diagnostics: state role. + 'catalog.state_unresolved': 'A state term does not resolve to a catalog entry.', + 'catalog.state_ambiguous': 'A state term matches more than one catalog entry.', + // Catalog diagnostics: event role. + 'catalog.event_unresolved': 'An event term does not resolve to a catalog entry.', + 'catalog.event_ambiguous': 'An event term matches more than one catalog entry.', + // Catalog diagnostics: feature role. + 'catalog.feature_unresolved': 'A feature term does not resolve to a catalog entry.', + 'catalog.feature_ambiguous': 'A feature term matches more than one catalog entry.', + // Catalog coverage. + 'catalog.term_unreferenced': 'A catalog entry is never referenced by any requirement.', + // Lint diagnostics. + 'lint.multiple_responses': 'A requirement declares more than one response.', + 'lint.vague_response': 'A response uses vague, unverifiable wording.', + 'lint.unparsed_tail': 'Trailing text after the requirement could not be parsed.', + 'lint.alias_used': 'A term was matched via an alias rather than its canonical name.', + 'lint.suspicious_text_shape': 'Requirement text has a suspicious shape.', +}; + +/** + * Every diagnostic code, sorted lexicographically. + * + * Sorting makes the SARIF rule list deterministic regardless of the key order + * the registry object was written in. + */ +export const DIAGNOSTIC_CODES: DiagnosticCode[] = ( + Object.keys(DIAGNOSTIC_DESCRIPTIONS) as DiagnosticCode[] +).sort(); 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..4ea7a8f --- /dev/null +++ b/packages/cli-contract/src/exit-codes.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForReport } from './exit-codes.js'; +import { buildJsonReport } from './json-report.js'; +import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; + +describe('exit code constants', () => { + it('match the orchestration contract', () => { + expect(EXIT_OK).toBe(0); + expect(EXIT_LINT_ERRORS).toBe(1); + expect(EXIT_USAGE).toBe(2); + }); +}); + +describe('exitCodeForReport', () => { + it('returns EXIT_LINT_ERRORS when the report has error diagnostics', () => { + expect(exitCodeForReport(buildJsonReport(sampleRun))).toBe(EXIT_LINT_ERRORS); + }); + + it('returns EXIT_OK when the report has no error diagnostics', () => { + expect(exitCodeForReport(buildJsonReport(cleanRun))).toBe(EXIT_OK); + }); + + it('treats warnings and infos alone as EXIT_OK', () => { + const report = buildJsonReport([ + { + path: 'specs/warn.ears', + items: [ + { + input: { text: 'The BFF shall respond appropriately.' }, + result: { + valid: true, + references: [], + diagnostics: [ + { + code: 'lint.vague_response', + severity: 'warning', + message: 'A response uses vague, unverifiable wording.', + }, + ], + }, + }, + ], + }, + ]); + expect(report.summary.warnings).toBe(1); + expect(exitCodeForReport(report)).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..447937e --- /dev/null +++ b/packages/cli-contract/src/exit-codes.ts @@ -0,0 +1,33 @@ +/** + * The exit-code contract for tools that run `ears lint`. + * + * These values match the orchestration brief. A tool derives the lint-outcome + * code from a {@link JsonReport} with {@link exitCodeForReport}; it selects + * {@link EXIT_USAGE} itself for argument, config, or file-read failures, since + * those are not represented in a report. + */ + +import type { JsonReport } from './json-report.js'; + +/** No error-severity diagnostics. The run succeeded. */ +export const EXIT_OK = 0; + +/** At least one error-severity diagnostic was reported. */ +export const EXIT_LINT_ERRORS = 1; + +/** A CLI usage, configuration, or file-read error occurred. */ +export const EXIT_USAGE = 2; + +/** + * The exit code implied by a report's findings. + * + * Returns {@link EXIT_LINT_ERRORS} when the report contains any error-severity + * diagnostic, otherwise {@link EXIT_OK}. It never returns {@link EXIT_USAGE}: + * usage failures are a CLI concern, not a property of a completed report. + * + * @param report The JSON report to inspect. + * @returns {@link EXIT_OK} or {@link EXIT_LINT_ERRORS}. + */ +export function exitCodeForReport(report: JsonReport): typeof EXIT_OK | typeof EXIT_LINT_ERRORS { + return report.summary.errors > 0 ? EXIT_LINT_ERRORS : EXIT_OK; +} diff --git a/packages/cli-contract/src/index.ts b/packages/cli-contract/src/index.ts new file mode 100644 index 0000000..d988098 --- /dev/null +++ b/packages/cli-contract/src/index.ts @@ -0,0 +1,42 @@ +/** + * `@earsyntax/cli-contract` public API surface. + * + * Shared report output contracts so external tools can consume `ears lint` + * reports without depending on the CLI. This package defines report DATA + * shapes and pure serializers only: no I/O, no argument parsing, no + * `process.exit`, and no assumptions about command names or flags. + * + * Determinism contract: builders preserve input order and construct object + * keys in a fixed order, so identical input always produces identical output. + */ + +export type { ReportInput, ReportInputFile, ReportInputItem } from './input.js'; + +export { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; + +export type { + JsonDiagnostic, + JsonReport, + JsonReportFile, + JsonReportSummary, + JsonRequirement, +} from './json-report.js'; +export { buildJsonReport, JSON_REPORT_VERSION, serializeJsonReport } from './json-report.js'; + +export type { + SarifDriver, + SarifLevel, + SarifLocation, + SarifLog, + SarifPhysicalLocation, + SarifRegion, + SarifResult, + SarifRule, + SarifRun, +} from './sarif.js'; +export { buildSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './sarif.js'; + +export type { PrettyModel, PrettyRecord, PrettySummary } from './pretty.js'; +export { buildPrettyModel } from './pretty.js'; + +export { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForReport } from './exit-codes.js'; diff --git a/packages/cli-contract/src/input.ts b/packages/cli-contract/src/input.ts new file mode 100644 index 0000000..5776d34 --- /dev/null +++ b/packages/cli-contract/src/input.ts @@ -0,0 +1,40 @@ +/** + * Facade-neutral input contract for the report builders. + * + * These shapes describe the DATA a linting run produced, grouped by source + * file. They are intentionally decoupled from any CLI command surface: no flag + * names, no argv parsing, no I/O. A tool collects one {@link ReportInputItem} + * per requirement it linted, groups them into {@link ReportInputFile}s, and + * passes the array to `buildJsonReport`, `buildSarifLog`, or + * `buildPrettyModel`. + */ + +import type { LintResult, RequirementInput } from '@earsyntax/core'; + +/** + * One linted requirement: the requirement that went in, and the result that + * came out. + */ +export interface ReportInputItem { + /** The requirement that was linted (`id`, `text`, and optional `source`). */ + input: RequirementInput; + /** The result the core linter returned for {@link ReportInputItem.input}. */ + result: LintResult; +} + +/** + * All linted requirements that share one source file path. + */ +export interface ReportInputFile { + /** The source file path the items were extracted from. */ + path: string; + /** The linted requirements, in the order they appeared in the file. */ + items: ReportInputItem[]; +} + +/** + * The complete input to every report builder: files in the order they were + * processed, each carrying its linted requirements in source order. Builders + * never reorder files or items; array order is the contract. + */ +export type ReportInput = ReportInputFile[]; diff --git a/packages/cli-contract/src/json-report.test.ts b/packages/cli-contract/src/json-report.test.ts new file mode 100644 index 0000000..e4b20fb --- /dev/null +++ b/packages/cli-contract/src/json-report.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { buildJsonReport, JSON_REPORT_VERSION, serializeJsonReport } from './json-report.js'; +import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; + +describe('buildJsonReport', () => { + it('counts diagnostics by severity across all files', () => { + const report = buildJsonReport(sampleRun); + expect(report.summary).toEqual({ + files: 2, + requirements: 3, + errors: 2, + warnings: 1, + infos: 1, + valid: false, + }); + }); + + it('marks the report valid when there are no error diagnostics', () => { + const report = buildJsonReport(cleanRun); + expect(report.summary.errors).toBe(0); + expect(report.summary.valid).toBe(true); + }); + + it('carries the schema version', () => { + expect(buildJsonReport(sampleRun).version).toBe(JSON_REPORT_VERSION); + expect(JSON_REPORT_VERSION).toBe(1); + }); + + it('preserves file and requirement input order', () => { + const report = buildJsonReport(sampleRun); + expect(report.files.map((f) => f.path)).toEqual([ + 'specs/subscribe.ears', + 'specs/payment.ears', + ]); + expect(report.files[0]?.requirements.map((r) => r.id)).toEqual(['REQ-1', 'REQ-2']); + }); + + it('omits optional keys when absent and includes them when present', () => { + const report = buildJsonReport(sampleRun); + const withMeta = report.files[0]?.requirements[0]; + expect(withMeta).toMatchObject({ id: 'REQ-1', line: 3, pattern: 'ubiquitous' }); + + const noMeta = report.files[1]?.requirements[0]; + expect(Object.hasOwn(noMeta, 'id')).toBe(false); + expect(Object.hasOwn(noMeta, 'line')).toBe(false); + expect(Object.hasOwn(noMeta, 'pattern')).toBe(false); + }); + + it('preserves diagnostic order and spans', () => { + const report = buildJsonReport(sampleRun); + const diags = report.files[0]?.requirements[1]?.diagnostics; + expect(diags.map((d) => d.code)).toEqual(['lint.vague_response', 'catalog.system_unresolved']); + expect(diags[0].span).toEqual({ start: 44, end: 57 }); + }); +}); + +describe('serializeJsonReport', () => { + it('is deterministic: the same input serializes identically', () => { + const a = serializeJsonReport(buildJsonReport(sampleRun)); + const b = serializeJsonReport(buildJsonReport(sampleRun)); + expect(a).toBe(b); + }); + + it('uses 2-space indentation and a trailing newline', () => { + const out = serializeJsonReport(buildJsonReport(cleanRun)); + expect(out.endsWith('\n')).toBe(true); + expect(out).toContain('\n "version": 1'); + expect(JSON.parse(out)).toEqual(buildJsonReport(cleanRun)); + }); + + it('orders requirement keys as id, line, text, valid, pattern, diagnostics', () => { + const req = buildJsonReport(sampleRun).files[0]?.requirements[0]; + expect(Object.keys(req)).toEqual(['id', 'line', 'text', 'valid', 'pattern', 'diagnostics']); + // The serialized key order matches the object key order. + const serializedKeys = Array.from(JSON.stringify(req).matchAll(/"(\w+)":/g), (m) => m[1]); + expect(serializedKeys).toEqual(['id', 'line', 'text', 'valid', 'pattern', 'diagnostics']); + + // A requirement carrying diagnostics keeps the diagnostic keys ordered too. + const withDiags = buildJsonReport(sampleRun).files[0]?.requirements[1]; + const diagKeys = Array.from( + JSON.stringify(withDiags.diagnostics[0]).matchAll(/"(\w+)":/g), + (m) => m[1], + ); + expect(diagKeys).toEqual(['code', 'severity', 'message', 'span', 'start', 'end']); + }); +}); diff --git a/packages/cli-contract/src/json-report.ts b/packages/cli-contract/src/json-report.ts new file mode 100644 index 0000000..b11daa0 --- /dev/null +++ b/packages/cli-contract/src/json-report.ts @@ -0,0 +1,164 @@ +/** + * The JSON report contract. + * + * A stable, machine-readable serialization of a linting run that external + * tools can consume without depending on the CLI. Object keys are constructed + * in a fixed order and arrays preserve input order, so the same input always + * serializes to a byte-identical string. + */ + +import type { DiagnosticCode, Pattern, Severity, Span } from '@earsyntax/core'; +import type { ReportInput } from './input.js'; + +/** The current JSON report schema version. Bumped on breaking shape changes. */ +export const JSON_REPORT_VERSION = 1 as const; + +/** A single finding as it appears in the JSON report. */ +export interface JsonDiagnostic { + /** The registered diagnostic code. */ + code: DiagnosticCode; + /** Severity of the finding. */ + severity: Severity; + /** Human-readable explanation. */ + message: string; + /** Source span within the requirement text, when known. */ + span?: Span; +} + +/** A single linted requirement as it appears in the JSON report. */ +export interface JsonRequirement { + /** Caller-supplied identifier, when one was provided. */ + id?: string; + /** 1-based line in the source file, when known. */ + line?: number; + /** The requirement text that was linted. */ + text: string; + /** `false` when any diagnostic on this requirement is an error. */ + valid: boolean; + /** The classified shell pattern, when the requirement parsed. */ + pattern?: Pattern; + /** Findings for this requirement, in the order core produced them. */ + diagnostics: JsonDiagnostic[]; +} + +/** One source file and its linted requirements in the JSON report. */ +export interface JsonReportFile { + /** The source file path. */ + path: string; + /** The requirements linted from this file, in source order. */ + requirements: JsonRequirement[]; +} + +/** Aggregate counts across the whole report. */ +export interface JsonReportSummary { + /** Number of source files. */ + files: number; + /** Number of linted requirements. */ + requirements: number; + /** Total error-severity diagnostics. */ + errors: number; + /** Total warning-severity diagnostics. */ + warnings: number; + /** Total info-severity diagnostics. */ + infos: number; + /** `true` when there are no error-severity diagnostics anywhere. */ + valid: boolean; +} + +/** The complete JSON report. */ +export interface JsonReport { + /** Schema version. Always {@link JSON_REPORT_VERSION}. */ + version: typeof JSON_REPORT_VERSION; + /** Aggregate counts. */ + summary: JsonReportSummary; + /** Per-file results, in the order they were processed. */ + files: JsonReportFile[]; +} + +/** + * Build a {@link JsonReport} from a linting run. + * + * Files and requirements keep their input order. Optional keys (`id`, `line`, + * `pattern`, `span`) are included only when a value is present, and always in + * their fixed position, so serialization is deterministic. + * + * @param input The linted requirements grouped by source file. + * @returns The assembled JSON report. + */ +export function buildJsonReport(input: ReportInput): JsonReport { + let errors = 0; + let warnings = 0; + let infos = 0; + let requirements = 0; + + const files: JsonReportFile[] = input.map((file) => { + const reqs: JsonRequirement[] = file.items.map((item) => { + requirements += 1; + + const diagnostics: JsonDiagnostic[] = item.result.diagnostics.map((diagnostic) => { + if (diagnostic.severity === 'error') { + errors += 1; + } else if (diagnostic.severity === 'warning') { + warnings += 1; + } else { + infos += 1; + } + + // Keys constructed in fixed order: code, severity, message, span. + const jsonDiagnostic: JsonDiagnostic = { + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + }; + if (diagnostic.span !== undefined) { + jsonDiagnostic.span = { start: diagnostic.span.start, end: diagnostic.span.end }; + } + return jsonDiagnostic; + }); + + // Keys constructed in fixed order: id, line, text, valid, pattern, + // diagnostics. + const requirement = {} as JsonRequirement; + if (item.input.id !== undefined) { + requirement.id = item.input.id; + } + if (item.input.source?.line !== undefined) { + requirement.line = item.input.source.line; + } + requirement.text = item.input.text; + requirement.valid = item.result.valid; + if (item.result.pattern !== undefined) { + requirement.pattern = item.result.pattern; + } + requirement.diagnostics = diagnostics; + return requirement; + }); + + return { path: file.path, requirements: reqs }; + }); + + const summary: JsonReportSummary = { + files: files.length, + requirements, + errors, + warnings, + infos, + valid: errors === 0, + }; + + return { version: JSON_REPORT_VERSION, summary, files }; +} + +/** + * Serialize a {@link JsonReport} to a stable string. + * + * Uses 2-space indentation and appends a trailing newline. Because + * {@link buildJsonReport} constructs keys in a fixed order, the same input + * always yields a byte-identical string. + * + * @param report The report to serialize. + * @returns The pretty-printed JSON with a trailing newline. + */ +export function serializeJsonReport(report: JsonReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} diff --git a/packages/cli-contract/src/pretty.test.ts b/packages/cli-contract/src/pretty.test.ts new file mode 100644 index 0000000..5f7ea6e --- /dev/null +++ b/packages/cli-contract/src/pretty.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { buildPrettyModel } from './pretty.js'; +import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; + +describe('buildPrettyModel', () => { + it('flattens diagnostics in file then requirement then diagnostic order', () => { + const model = buildPrettyModel(sampleRun); + expect(model.records.map((r) => r.code)).toEqual([ + 'lint.vague_response', + 'catalog.system_unresolved', + 'ears.no_match', + 'lint.suspicious_text_shape', + ]); + }); + + it('carries file, line, severity, message, and requirement text', () => { + const first = buildPrettyModel(sampleRun).records[0]; + expect(first).toMatchObject({ + file: 'specs/subscribe.ears', + line: 7, + severity: 'warning', + code: 'lint.vague_response', + requirementText: 'When alice subscribes the BFF shall notify Redis appropriately.', + }); + }); + + it('omits line when the source line is unknown', () => { + const model = buildPrettyModel(sampleRun); + const noMatch = model.records.find((r) => r.code === 'ears.no_match'); + expect(noMatch).toBeDefined(); + expect(Object.hasOwn(noMatch as object, 'line')).toBe(false); + }); + + it('summarizes counts the same way the JSON report does', () => { + expect(buildPrettyModel(sampleRun).summary).toEqual({ + files: 2, + requirements: 3, + errors: 2, + warnings: 1, + infos: 1, + valid: false, + }); + }); + + it('produces no records but still counts requirements for a clean run', () => { + const model = buildPrettyModel(cleanRun); + expect(model.records).toEqual([]); + expect(model.summary).toEqual({ + files: 1, + requirements: 1, + errors: 0, + warnings: 0, + infos: 0, + valid: true, + }); + }); + + it('emits no ANSI escape codes', () => { + const serialized = JSON.stringify(buildPrettyModel(sampleRun)); + // The ESC byte (char code 27) introduces every ANSI sequence; the model + // carries none. + const esc = String.fromCharCode(27); + expect(serialized.includes(esc)).toBe(false); + }); +}); diff --git a/packages/cli-contract/src/pretty.ts b/packages/cli-contract/src/pretty.ts new file mode 100644 index 0000000..3fc3d49 --- /dev/null +++ b/packages/cli-contract/src/pretty.ts @@ -0,0 +1,110 @@ +/** + * A structured model for pretty (human) rendering. + * + * This is NOT terminal output: it carries no ANSI codes, no column widths, and + * no formatting decisions. It flattens a linting run into one record per + * diagnostic plus a summary, so a CLI can render it however it likes while the + * ordering and content stay defined here. + */ + +import type { DiagnosticCode, Severity } from '@earsyntax/core'; +import type { ReportInput } from './input.js'; + +/** One diagnostic flattened for rendering, with its file and requirement. */ +export interface PrettyRecord { + /** The source file path. */ + file: string; + /** 1-based source line, when known. */ + line?: number; + /** Severity of the diagnostic. */ + severity: Severity; + /** The diagnostic code. */ + code: DiagnosticCode; + /** The human-readable message. */ + message: string; + /** The text of the requirement the diagnostic belongs to. */ + requirementText: string; +} + +/** Aggregate counts for the pretty summary line. */ +export interface PrettySummary { + /** Number of source files. */ + files: number; + /** Number of linted requirements. */ + requirements: number; + /** Total error-severity diagnostics. */ + errors: number; + /** Total warning-severity diagnostics. */ + warnings: number; + /** Total info-severity diagnostics. */ + infos: number; + /** `true` when there are no error-severity diagnostics anywhere. */ + valid: boolean; +} + +/** The complete pretty model: flattened records plus a summary. */ +export interface PrettyModel { + /** One record per diagnostic, in file then requirement then diagnostic order. */ + records: PrettyRecord[]; + /** Aggregate counts across the run. */ + summary: PrettySummary; +} + +/** + * Build a {@link PrettyModel} from a linting run. + * + * Records follow input order: files, then requirements within each file, then + * diagnostics within each requirement (in the order core produced them). + * Requirements with no diagnostics contribute to the summary counts but add no + * records. + * + * @param input The linted requirements grouped by source file. + * @returns The flattened pretty model. + */ +export function buildPrettyModel(input: ReportInput): PrettyModel { + const records: PrettyRecord[] = []; + let errors = 0; + let warnings = 0; + let infos = 0; + let requirements = 0; + + for (const file of input) { + for (const item of file.items) { + requirements += 1; + const line = item.input.source?.line; + + for (const diagnostic of item.result.diagnostics) { + if (diagnostic.severity === 'error') { + errors += 1; + } else if (diagnostic.severity === 'warning') { + warnings += 1; + } else { + infos += 1; + } + + const record: PrettyRecord = { + file: file.path, + severity: diagnostic.severity, + code: diagnostic.code, + message: diagnostic.message, + requirementText: item.input.text, + }; + if (line !== undefined) { + record.line = line; + } + records.push(record); + } + } + } + + const summary: PrettySummary = { + files: input.length, + requirements, + errors, + warnings, + infos, + valid: errors === 0, + }; + + return { records, summary }; +} diff --git a/packages/cli-contract/src/sarif.test.ts b/packages/cli-contract/src/sarif.test.ts new file mode 100644 index 0000000..f93ca76 --- /dev/null +++ b/packages/cli-contract/src/sarif.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { buildSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './sarif.js'; +import { DIAGNOSTIC_CODES } from './diagnostic-registry.js'; +import { sampleRun } from './__fixtures__/sample-run.js'; + +describe('buildSarifLog', () => { + it('emits a single run with the earsyntax driver', () => { + const log = buildSarifLog(sampleRun); + 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('declares one sorted rule per registered diagnostic code', () => { + const rules = buildSarifLog(sampleRun).runs[0]?.tool.driver.rules ?? []; + expect(rules.map((r) => r.id)).toEqual(DIAGNOSTIC_CODES); + for (const rule of rules) { + expect(rule.shortDescription.text.length).toBeGreaterThan(0); + } + }); + + it('maps severities to SARIF levels (error, warning, note)', () => { + const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; + const byCode = new Map(results.map((r) => [r.ruleId, r.level])); + expect(byCode.get('catalog.system_unresolved')).toBe('error'); + expect(byCode.get('lint.vague_response')).toBe('warning'); + expect(byCode.get('lint.suspicious_text_shape')).toBe('note'); + }); + + it('produces one result per diagnostic in input order', () => { + const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; + expect(results.map((r) => r.ruleId)).toEqual([ + 'lint.vague_response', + 'catalog.system_unresolved', + 'ears.no_match', + 'lint.suspicious_text_shape', + ]); + }); + + it('uses a line-based region when the source line is known', () => { + const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; + const loc = results[0]?.locations[0]?.physicalLocation; + expect(loc.artifactLocation.uri).toBe('specs/subscribe.ears'); + expect(loc.region).toEqual({ startLine: 7 }); + }); + + it('falls back to a char-offset region when the line is unknown', () => { + const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; + const noMatch = results.find((r) => r.ruleId === 'ears.no_match'); + expect(noMatch?.locations[0]?.physicalLocation.region).toEqual({ + charOffset: 0, + charLength: 29, + }); + }); + + it('omits the region when neither line nor span is known', () => { + const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; + const suspicious = results.find((r) => r.ruleId === 'lint.suspicious_text_shape'); + expect(suspicious?.locations[0]?.physicalLocation.region).toBeUndefined(); + }); + + it('serializes to valid JSON', () => { + const log = buildSarifLog(sampleRun); + const roundTrip = JSON.parse(JSON.stringify(log)); + expect(roundTrip).toEqual(log); + }); +}); diff --git a/packages/cli-contract/src/sarif.ts b/packages/cli-contract/src/sarif.ts new file mode 100644 index 0000000..4f97991 --- /dev/null +++ b/packages/cli-contract/src/sarif.ts @@ -0,0 +1,167 @@ +/** + * A minimal, valid subset of the SARIF 2.1.0 static analysis report format. + * + * SARIF (Static Analysis Results Interchange Format) is the OASIS standard + * that code-scanning tools (GitHub code scanning, editors, CI dashboards) + * consume. This module emits only the fields those consumers require: one run, + * a tool driver named `earsyntax`, a rule per diagnostic code, and one result + * per diagnostic. It does not attempt full-schema coverage. + * + * Schema reference: + * https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json + */ + +import type { Severity } from '@earsyntax/core'; +import type { ReportInput } from './input.js'; +import { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; + +/** The canonical SARIF 2.1.0 JSON schema URL. */ +export const SARIF_SCHEMA = + 'https://github.com/oasis-tcs/sarif-spec/blob/main/sarif-2.1/schema/sarif-external-property-file-schema-2.1.0.json'; + +/** The SARIF version string this builder 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 builder emits. */ +export type SarifLevel = 'error' | 'warning' | 'note'; + +/** A SARIF rule descriptor (`reportingDescriptor`). */ +export interface SarifRule { + /** The rule id, equal to the diagnostic code. */ + id: string; + /** A short, one-line description of the rule. */ + shortDescription: { text: string }; +} + +/** A SARIF physical location region. Either line-based or offset-based. */ +export interface SarifRegion { + /** 1-based start line, when the source line is known. */ + startLine?: number; + /** 0-based character offset into the requirement text, when the line is not. */ + charOffset?: number; + /** Character length from {@link SarifRegion.charOffset}. */ + charLength?: number; +} + +/** A SARIF physical location. */ +export interface SarifPhysicalLocation { + /** The artifact (source file) the result points at. */ + artifactLocation: { uri: string }; + /** The region within the artifact, when known. */ + region?: SarifRegion; +} + +/** A SARIF result location. */ +export interface SarifLocation { + physicalLocation: SarifPhysicalLocation; +} + +/** A single SARIF result (one diagnostic). */ +export interface SarifResult { + /** The id of the rule this result violates. */ + ruleId: string; + /** The mapped severity level. */ + level: SarifLevel; + /** The human-readable message. */ + message: { text: string }; + /** Where the result was found. */ + locations: SarifLocation[]; +} + +/** A SARIF tool driver. */ +export interface SarifDriver { + /** The tool name. Always {@link SARIF_TOOL_NAME}. */ + name: string; + /** The rules this tool can report. */ + rules: SarifRule[]; +} + +/** A single SARIF run. */ +export interface SarifRun { + /** The tool that produced the run. */ + tool: { driver: SarifDriver }; + /** The results the run produced. */ + results: SarifResult[]; +} + +/** A complete SARIF log. */ +export interface SarifLog { + /** The SARIF schema URL. */ + $schema: string; + /** The SARIF version. Always {@link SARIF_VERSION}. */ + version: typeof SARIF_VERSION; + /** The runs in this log. This builder always emits exactly one. */ + runs: SarifRun[]; +} + +/** + * Map an EARS {@link Severity} to its SARIF level. + * + * `error` maps to `error`, `warning` to `warning`, and `info` to `note` + * (SARIF has no `info` level). + */ +function toSarifLevel(severity: Severity): SarifLevel { + switch (severity) { + case 'error': + return 'error'; + case 'warning': + return 'warning'; + case 'info': + return 'note'; + } +} + +/** + * Build a minimal SARIF 2.1.0 log from a linting run. + * + * The single run declares one rule per registered diagnostic code (sorted) and + * one result per diagnostic, in file then requirement then diagnostic order. + * Each result points at its source file; the region is line-based when the + * source line is known, otherwise character-offset-based from the diagnostic + * span, and omitted when neither is available. + * + * @param input The linted requirements grouped by source file. + * @returns A SARIF log ready to serialize with `JSON.stringify`. + */ +export function buildSarifLog(input: ReportInput): SarifLog { + const rules: SarifRule[] = DIAGNOSTIC_CODES.map((code) => ({ + id: code, + shortDescription: { text: DIAGNOSTIC_DESCRIPTIONS[code] }, + })); + + const results: SarifResult[] = []; + for (const file of input) { + for (const item of file.items) { + const line = item.input.source?.line; + for (const diagnostic of item.result.diagnostics) { + const physicalLocation: SarifPhysicalLocation = { + artifactLocation: { uri: file.path }, + }; + if (line !== undefined) { + physicalLocation.region = { startLine: line }; + } else if (diagnostic.span !== undefined) { + physicalLocation.region = { + charOffset: diagnostic.span.start, + charLength: diagnostic.span.end - diagnostic.span.start, + }; + } + + results.push({ + ruleId: diagnostic.code, + level: toSarifLevel(diagnostic.severity), + message: { text: diagnostic.message }, + locations: [{ physicalLocation }], + }); + } + } + } + + return { + $schema: SARIF_SCHEMA, + version: SARIF_VERSION, + runs: [{ tool: { driver: { name: SARIF_TOOL_NAME, rules } }, results }], + }; +} diff --git a/packages/cli-contract/tsconfig.build.json b/packages/cli-contract/tsconfig.build.json new file mode 100644 index 0000000..86dfb00 --- /dev/null +++ b/packages/cli-contract/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", "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..652dc9a --- /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))); \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..502379a --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,48 @@ +{ + "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", + "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" + }, + "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.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..63f81eb --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,114 @@ +/** + * A lean, hand-rolled argument parser. + * + * The facade JSON contract is exact and the command set is small, 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 `--no-flag`, 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. + */ + +import { usageError } from './errors.js'; + +/** Flags that take a value (`--flag value` or `--flag=value`). */ +const VALUE_FLAGS = new Set([ + 'cwd', + 'config', + 'tools', + 'mode', + 'source', + 'prompt', + 'out', + 'by', + 'work', + 'catalog', + 'format', + 'artifact', + 'status', +]); + +/** 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. + const eq = body.indexOf('='); + if (eq !== -1) { + const name = body.slice(0, eq); + values.set(name, body.slice(eq + 1)); + continue; + } + + // `--no-flag` boolean negation. + if (body.startsWith('no-')) { + booleans.add(body); + 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; + color: boolean; + interactive: boolean; + cwd?: string; + config?: string; +} + +/** Extract global options from parsed args. */ +export function resolveGlobals(args: ParsedArgs): GlobalOptions { + return { + json: args.booleans.has('json'), + color: !args.booleans.has('no-color'), + interactive: !args.booleans.has('no-interactive'), + cwd: args.values.get('cwd'), + config: args.values.get('config'), + }; +} \ No newline at end of file diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000..2f4d964 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,367 @@ +/** + * End-to-end facade tests. + * + * Each test drives `run(argv, { cwd })` against a temp project directory and + * captures stdout, exercising the full loop: init -> new -> instructions -> + * agent-simulated write -> validate -> status -> accept, plus staleness, + * refusals, and exit codes. JSON responses are compared structurally to the + * golden fixtures in `fixtures/facade/` (values that vary by run are ignored). + */ + +import { mkdtempSync, readFileSync, 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 { run } from './cli.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..'); +const DEMO = resolve(REPO_ROOT, 'fixtures', 'demo'); +const DEMO_REQUIREMENTS = resolve(DEMO, 'requirements.ears'); +const DEMO_VALID_ONLY = resolve(DEMO, 'valid-only.ears'); +const DEMO_CATALOG = resolve(DEMO, 'catalog.json'); + +interface RunResult { + code: number; + out: string; + json: () => Record; +} + +function makeRunner(cwd: string) { + return (...argv: string[]): RunResult => { + let out = ''; + const code = run(argv, { cwd, stdout: (s) => (out += s) }); + return { code, out, json: () => JSON.parse(out) as Record }; + }; +} + +function tempProject(): string { + // Directories are left in the OS temp dir; the OS reclaims them. + return mkdtempSync(join(tmpdir(), 'earsyntax-cli-')); +} + +const SOURCE_MD = [ + '# Checkout webhooks', + '', + 'When a payment webhook is received, the billing service must verify the HMAC signature.', + 'If the HMAC signature is invalid, the webhook must be rejected.', + '', +].join('\n'); + +describe('version', () => { + it('reports features as JSON', () => { + const r = makeRunner(tempProject()); + const res = r('version', '--features', '--json'); + expect(res.code).toBe(0); + const body = res.json(); + expect(body.command).toBe('version'); + expect(body.ok).toBe(true); + expect(body.next).toEqual([]); + const features = body.features as Record; + expect(features.facade).toBe(1); + expect(features.instructions).toEqual(['author', 'convert', 'repair', 'review']); + expect(features.sarif).toBe(false); + }); +}); + +describe('init', () => { + it('writes the workspace and refuses to overwrite without --force', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + + const first = r('init', '--json'); + expect(first.code).toBe(0); + const body = first.json(); + expect(body.ok).toBe(true); + expect(body.written).toEqual(['.earsyntax/config.json', '.earsyntax/work/.gitkeep']); + expect(typeof body.root).toBe('string'); + + const again = r('init', '--json'); + expect(again.code).toBe(3); + const failure = again.json(); + expect(failure.ok).toBe(false); + const diags = failure.diagnostics as { code: string }[]; + expect(diags[0]?.code).toBe('init.exists'); + + const forced = r('init', '--force', '--json'); + expect(forced.code).toBe(0); + }); + + it('writes tool wrappers for --tools claude', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + const res = r('init', '--tools', 'claude', '--json'); + expect(res.code).toBe(0); + const body = res.json(); + expect(body.tools).toEqual(['claude']); + const written = body.written as string[]; + expect(written).toContain('.claude/commands/earsyntax-author.md'); + expect(written).toContain('.claude/commands/earsyntax-convert.md'); + expect(written).toContain('.claude/commands/earsyntax-repair.md'); + }); + + it('rejects an unknown tool with exit 2', () => { + const r = makeRunner(tempProject()); + const res = r('init', '--tools', 'bogus', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('init.bad_tools'); + }); +}); + +describe('convert loop (happy path)', () => { + it('runs new -> instructions -> write -> validate -> status -> accept', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + writeFileSync(join(cwd, 'source.md'), SOURCE_MD); + + r('init', '--json'); + + const createdRes = r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); + expect(createdRes.code).toBe(0); + const newBody = createdRes.json(); + const work = newBody.work as Record; + expect(work.id).toBe('checkout-webhooks'); + expect(work.mode).toBe('convert'); + expect(work.status).toBe('scaffolded'); + expect(String(work.sourceHash)).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(newBody.written).toContain('.earsyntax/work/checkout-webhooks/manifest.json'); + + const instr = r('instructions', 'convert', '--work', 'checkout-webhooks', '--json'); + expect(instr.code).toBe(0); + const instrBody = instr.json(); + expect(instrBody.command).toBe('instructions convert'); + expect(instrBody.mode).toBe('convert'); + expect((instrBody.rules as string[]).length).toBeGreaterThan(0); + const format = instrBody.format as Record; + expect((format.allowedPatterns as string[]).length).toBe(5); + expect((format.metadataPrefixes as string[]).length).toBe(3); + const source = instrBody.source as Record; + expect(source.path).toBe('source.md'); + expect(Array.isArray(source.excerpts)).toBe(true); + const next = instrBody.next as { command: string }[]; + expect(next[0]?.command).toContain('earsyntax validate'); + + // Agent writes valid .ears content into the reserved output path. + const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); + writeFileSync(outPath, readFileSync(DEMO_VALID_ONLY, 'utf8')); + + const validated = r( + 'validate', + '.earsyntax/work/checkout-webhooks/requirements.ears', + '--source', + 'source.md', + '--work', + 'checkout-webhooks', + '--json', + ); + expect(validated.code).toBe(0); + const vBody = validated.json(); + expect(vBody.ok).toBe(true); + const summary = vBody.summary as Record; + expect(summary.errors).toBe(0); + expect((vBody.work as Record).status).toBe('valid'); + expect(vBody.stale).toBe(false); + + const status = r('status', 'checkout-webhooks', '--json'); + expect(status.code).toBe(0); + expect((status.json().work as Record).status).toBe('valid'); + + const accepted = r('accept', 'checkout-webhooks', '--by', 'omer', '--json'); + expect(accepted.code).toBe(0); + const aWork = accepted.json().work as Record; + expect(aWork.status).toBe('accepted'); + const acceptedBlock = aWork.accepted as Record; + expect(acceptedBlock.by).toBe('omer'); + expect(String(acceptedBlock.outputHash)).toMatch(/^sha256:/); + }); +}); + +describe('invalid content and repair', () => { + it('validate exits 1 and repair surfaces diagnostics; accept refuses invalid', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + writeFileSync(join(cwd, 'source.md'), SOURCE_MD); + r('init', '--json'); + r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); + + const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); + writeFileSync(outPath, readFileSync(DEMO_REQUIREMENTS, 'utf8')); + + const validated = r( + 'validate', + '.earsyntax/work/checkout-webhooks/requirements.ears', + '--work', + 'checkout-webhooks', + '--json', + ); + expect(validated.code).toBe(1); + const vBody = validated.json(); + expect(vBody.ok).toBe(false); + expect((vBody.summary as Record).errors).toBeGreaterThan(0); + expect((vBody.work as Record).status).toBe('invalid'); + + const repair = r('instructions', 'repair', '--work', 'checkout-webhooks', '--json'); + expect(repair.code).toBe(0); + const diagnostics = repair.json().diagnostics as { code: string }[]; + expect(diagnostics.length).toBeGreaterThan(0); + + const accept = r('accept', 'checkout-webhooks', '--json'); + expect(accept.code).toBe(3); + expect((accept.json().diagnostics as { code: string }[])[0]?.code).toBe('accept.not_valid'); + }); +}); + +describe('validate standalone files', () => { + it('exits 1 on the demo requirements with invalid lines', () => { + const r = makeRunner(tempProject()); + const res = r('validate', DEMO_REQUIREMENTS, '--json'); + expect(res.code).toBe(1); + expect((res.json().summary as Record).errors).toBeGreaterThan(0); + }); + + it('exits 0 on the valid-only demo', () => { + const r = makeRunner(tempProject()); + const res = r('validate', DEMO_VALID_ONLY, '--json'); + expect(res.code).toBe(0); + expect((res.json().summary as Record).errors).toBe(0); + }); + + it('exits 2 on a missing file', () => { + const r = makeRunner(tempProject()); + const res = r('validate', 'does-not-exist.ears', '--json'); + expect(res.code).toBe(2); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('validate.missing_file'); + }); + + it('accepts a catalog', () => { + const r = makeRunner(tempProject()); + const res = r('validate', DEMO_VALID_ONLY, '--catalog', DEMO_CATALOG, '--json'); + expect(res.code).toBe(0); + const body = res.json(); + expect((body.summary as Record).errors).toBe(0); + expect(Array.isArray(body.results)).toBe(true); + }); + + it('flags duplicate IDs as a facade error', () => { + const cwd = tempProject(); + const dupPath = join(cwd, 'dup.ears'); + writeFileSync( + dupPath, + [ + 'REQ-001: The billing service shall verify the HMAC signature of every incoming webhook.', + 'REQ-001: The billing service shall retry failed webhook deliveries up to five times.', + ].join('\n'), + ); + const r = makeRunner(cwd); + const res = r('validate', 'dup.ears', '--json'); + expect(res.code).toBe(1); + const diags = res.json().diagnostics as { code: string }[]; + expect(diags.some((d) => d.code === 'facade.duplicate_id')).toBe(true); + }); +}); + +describe('staleness', () => { + it('reports stale after the source changes and accept refuses it', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + const sourcePath = join(cwd, 'source.md'); + writeFileSync(sourcePath, SOURCE_MD); + r('init', '--json'); + r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); + + const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); + writeFileSync(outPath, readFileSync(DEMO_VALID_ONLY, 'utf8')); + + const first = r( + 'validate', + '.earsyntax/work/checkout-webhooks/requirements.ears', + '--source', + 'source.md', + '--work', + 'checkout-webhooks', + '--json', + ); + expect(first.code).toBe(0); + expect(first.json().stale).toBe(false); + + // The source drifts. + writeFileSync(sourcePath, `${SOURCE_MD}\nWhile the provider is unavailable, queue events.\n`); + + const second = r( + 'validate', + '.earsyntax/work/checkout-webhooks/requirements.ears', + '--source', + 'source.md', + '--work', + 'checkout-webhooks', + '--json', + ); + expect(second.code).toBe(0); + expect(second.json().stale).toBe(true); + + const status = r('status', 'checkout-webhooks', '--json'); + expect((status.json().work as Record).status).toBe('stale'); + + const accept = r('accept', 'checkout-webhooks', '--json'); + expect(accept.code).toBe(3); + expect((accept.json().diagnostics as { code: string }[])[0]?.code).toBe('accept.stale'); + }); +}); + +describe('metadata-prefix requirements', () => { + it('validates a work item whose .ears uses the [source: path:line] form', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + writeFileSync(join(cwd, 'source.md'), SOURCE_MD); + r('init', '--json'); + r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); + + // The agent writes requirements using the documented metadata prefix, + // including the range form. The declared ref, not the physical line, + // becomes the reported source line. + const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); + writeFileSync( + outPath, + [ + 'REQ-001 [source: source.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.', + 'REQ-002 [source: source.md:10-11]: If the HMAC signature is invalid, then the billing service shall reject the webhook.', + '', + ].join('\n'), + ); + + const res = r( + 'validate', + '.earsyntax/work/checkout-webhooks/requirements.ears', + '--source', + 'source.md', + '--work', + 'checkout-webhooks', + '--json', + ); + expect(res.code).toBe(0); + const body = res.json(); + expect((body.summary as Record).errors).toBe(0); + expect((body.work as Record).status).toBe('valid'); + const results = body.results as { id?: string; line?: number; valid: boolean }[]; + expect(results[0]?.id).toBe('REQ-001'); + expect(results[0]?.line).toBe(7); + expect(results[0]?.valid).toBe(true); + expect(results[1]?.id).toBe('REQ-002'); + expect(results[1]?.line).toBe(10); + expect(results[1]?.valid).toBe(true); + }); +}); + +describe('new refusals', () => { + it('refuses a duplicate slug without --force', () => { + const cwd = tempProject(); + const r = makeRunner(cwd); + writeFileSync(join(cwd, 'source.md'), SOURCE_MD); + r('init', '--json'); + expect(r('new', 'dup-item', '--source', 'source.md', '--json').code).toBe(0); + const again = r('new', 'dup-item', '--source', 'source.md', '--json'); + expect(again.code).toBe(3); + expect((again.json().diagnostics as { code: string }[])[0]?.code).toBe('new.exists'); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..c7ec852 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,131 @@ +/** + * The `earsyntax` command dispatcher. + * + * A lean hand-rolled entry point: it parses global options, resolves the + * working directory, builds the output emitter, and routes to one command + * handler. Handlers emit their own JSON or pretty output and return an exit + * code. {@link CliError}s are caught here and rendered as a base response. + * + * Kept framework-free on purpose: the facade JSON contract is exact, and owning + * the response shape directly is simpler than bending a CLI framework to it. + */ + +import { resolveGlobals, parseArgs } from './args.js'; +import { createPainter } from './color.js'; +import type { CommandContext, CommandHandler } from './context.js'; +import { CliError, usageError } from './errors.js'; +import { findRoot, resolveInput } from './project.js'; +import { emit, errorResponse } from './response.js'; +import { acceptCommand } from './commands/accept.js'; +import { doctorCommand } from './commands/doctor.js'; +import { initCommand } from './commands/init.js'; +import { instructionsCommand } from './commands/instructions.js'; +import { listCommand } from './commands/list.js'; +import { newCommand } from './commands/new.js'; +import { showCommand } from './commands/show.js'; +import { statusCommand } from './commands/status.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; + stderr?: (text: string) => void; +} + +const COMMANDS: Record = { + init: initCommand, + doctor: doctorCommand, + version: versionCommand, + new: newCommand, + list: listCommand, + status: statusCommand, + instructions: instructionsCommand, + validate: validateCommand, + accept: acceptCommand, + show: showCommand, +}; + +/** The command label used on responses (instructions carries its mode). */ +function commandLabel(command: string, args: ReturnType): string { + if (command === 'instructions') { + const mode = args.positionals[0]; + return mode ? `instructions ${mode}` : 'instructions'; + } + return command; +} + +/** + * 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(); + + const command = argv.at(0); + const rest = argv.slice(1); + + // Bare invocation and top-level flags. + if (command === undefined || command === '--help' || command === '-h') { + write(`${usageText()}\n`); + return command === undefined ? 2 : 0; + } + if (command === '--version' || command === '-v') { + return dispatch('version', [], baseCwd, write); + } + + return dispatch(command, rest, baseCwd, write); +} + +function dispatch(command: string, rest: string[], baseCwd: string, write: (text: string) => void): number { + const args = parseArgs(rest); + const global = resolveGlobals(args); + const cwd = global.cwd ? resolveInput(baseCwd, global.cwd) : baseCwd; + const emitter = { json: global.json, painter: createPainter(global.color), write }; + + const label = commandLabel(command, args); + + try { + if (!Object.hasOwn(COMMANDS, command)) { + throw usageError('cli.unknown_command', `Unknown command "${command}". Run \`earsyntax --help\`.`); + } + const handler = COMMANDS[command]; + const context: CommandContext = { args, global, cwd, emitter }; + return handler(context); + } 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 response = errorResponse(label, findRoot(cwd), cliError); + const pretty = `error ${cliError.diagnostic.code}: ${cliError.diagnostic.message}`; + emit(emitter, response, pretty); + return cliError.exitCode; + } +} + +function usageText(): string { + return [ + 'earsyntax [options]', + '', + 'Commands:', + ' init Initialize .earsyntax/ and optional agent wrappers', + ' doctor Read-only project health report', + ' version Version and feature discovery', + ' new Create a work item for a source spec or prompt', + ' list List work items', + ' status Show one work item state and next steps', + ' instructions Return the rules an agent follows for a step', + ' validate Validate .ears files and emit diagnostics', + ' accept Mark a valid .ears artifact as human-accepted', + ' show Show resolved artifact paths or content', + '', + 'Global options: --json --no-color --cwd --config --no-interactive', + ].join('\n'); +} \ No newline at end of file diff --git a/packages/cli/src/color.ts b/packages/cli/src/color.ts new file mode 100644 index 0000000..456977e --- /dev/null +++ b/packages/cli/src/color.ts @@ -0,0 +1,38 @@ +/** + * Minimal ANSI color helpers. + * + * Color is applied only when enabled (the `--no-color` global turns it off), 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`, + green: `${ESC}[32m`, + 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}`; + }, + }; +} \ No newline at end of file diff --git a/packages/cli/src/commands/.gitkeep b/packages/cli/src/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/cli/src/commands/accept.ts b/packages/cli/src/commands/accept.ts new file mode 100644 index 0000000..0a85267 --- /dev/null +++ b/packages/cli/src/commands/accept.ts @@ -0,0 +1,73 @@ +/** + * `earsyntax accept ` — record that a human accepted the `.ears` file. + * + * The human gate. Refuses (exit 3) when the status is not `valid`, when the + * source is stale, or when the `.ears` output changed since it was validated. + * On success it writes the `accepted` block and never touches source or output. + */ + +import type { CommandContext } from '../context.js'; +import type { WorkManifest } from '../facade-types.js'; +import { refusalError, usageError } from '../errors.js'; +import { loadConfig, requireRoot } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { currentOutputHash, isStale, requireManifest, writeManifest } from '../workspace.js'; + +export function acceptCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + + const slug = context.args.positionals.at(0); + if (slug === undefined) { + throw usageError('accept.missing_slug', 'Provide a work-item slug to accept.'); + } + const { paths, manifest } = requireManifest(root, config, slug); + + if (manifest.status !== 'valid') { + throw refusalError( + 'accept.not_valid', + `Cannot accept "${slug}": status is ${manifest.status}, not valid. Validate it first.`, + ); + } + if (isStale(root, manifest)) { + throw refusalError( + 'accept.stale', + `Cannot accept "${slug}": the source changed since it was validated. Re-run the loop.`, + ); + } + const outputHash = currentOutputHash(root, manifest); + if (outputHash === undefined) { + throw refusalError('accept.output_missing', `Cannot accept "${slug}": the .ears output is unreadable.`); + } + if (manifest.output.hash !== undefined && outputHash !== manifest.output.hash) { + throw refusalError( + 'accept.output_changed', + `Cannot accept "${slug}": the .ears file was edited after validation. Re-validate first.`, + ); + } + + const by = context.args.values.get('by'); + const accepted: WorkManifest['accepted'] = { + at: new Date().toISOString(), + ...(by !== undefined ? { by } : {}), + ...(manifest.source?.hash !== undefined ? { sourceHash: manifest.source.hash } : {}), + outputHash, + }; + + const updated: WorkManifest = { ...manifest, status: 'accepted', accepted }; + writeManifest(paths, updated); + + const work: Record = { + id: manifest.id, + status: 'accepted', + mode: manifest.mode, + source: manifest.source?.path ?? null, + output: manifest.output.path, + accepted, + }; + + const response = buildResponse({ command: 'accept', ok: true, root, next: [] }, { work }); + const pretty = `Accepted "${slug}"${by ? ` by ${by}` : ''}.`; + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..607c55d --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,117 @@ +/** + * `earsyntax doctor` — read-only project health report. + * + * Runs a fixed checklist and returns it as `checks`, mirroring every + * non-passing check into the base `diagnostics` array so agents read one array + * for problems while humans get the full ordered checklist. `ok` is true only + * when no check produced an error. + */ + +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { CommandContext } from '../context.js'; +import type { FacadeDiagnostic, FacadeSeverity } from '../facade-types.js'; +import { loadConfig, requireRoot, resolveInput } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { CLI_VERSION } from '../version.js'; +import { isStale, listSlugs, readManifest, workPaths } from '../workspace.js'; +import { wrapperFilesFor } from '../tool-wrappers.js'; + +interface DoctorCheck { + name: string; + ok: boolean; + severity: FacadeSeverity; + message: string; +} + +export function doctorCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + // Throws exit 2 when the config is unreadable/invalid. + const config = loadConfig(root, context.global.config); + + const checks: DoctorCheck[] = []; + + checks.push({ + name: 'config', + ok: true, + severity: 'info', + message: '.earsyntax/config.json exists and parses.', + }); + + const workDirExists = existsSync(resolve(root, config.workDir)); + checks.push({ + name: 'workDir', + ok: workDirExists, + severity: workDirExists ? 'info' : 'error', + message: workDirExists + ? `Work directory ${config.workDir} exists.` + : `Work directory ${config.workDir} is missing.`, + }); + + checks.push({ + name: 'version', + ok: true, + severity: 'info', + message: `earsyntax ${CLI_VERSION} supports facade contract 1.`, + }); + + // Per-work-item checks: referenced sources exist, accepted items are not stale. + for (const slug of listSlugs(root, config)) { + const manifest = readManifest(workPaths(root, config, slug)); + if (manifest === undefined) { + continue; + } + if (manifest.source?.path !== undefined) { + const sourceExists = existsSync(resolveInput(root, manifest.source.path)); + checks.push({ + name: `source:${slug}`, + ok: sourceExists, + severity: sourceExists ? 'info' : 'warning', + message: sourceExists + ? `Source for ${slug} exists.` + : `Source ${manifest.source.path} for ${slug} is missing.`, + }); + } + if (manifest.status === 'accepted') { + const stale = isStale(root, manifest); + checks.push({ + name: `stale:${slug}`, + ok: !stale, + severity: stale ? 'warning' : 'info', + message: stale + ? `Accepted item ${slug} is stale: its source changed after acceptance.` + : `Accepted item ${slug} is up to date.`, + }); + } + } + + // Configured tool wrappers are present. + for (const tool of config.tools) { + for (const wrapper of wrapperFilesFor(tool)) { + const present = existsSync(resolve(root, wrapper.path)); + checks.push({ + name: `tool:${wrapper.path}`, + ok: present, + severity: present ? 'info' : 'info', + message: present ? `${wrapper.path} is present.` : `${wrapper.path} is missing.`, + }); + } + } + + const diagnostics: FacadeDiagnostic[] = checks + .filter((check) => !check.ok) + .map((check) => ({ code: `doctor.${check.name}`, severity: check.severity, message: check.message })); + + const hasError = checks.some((check) => !check.ok && check.severity === 'error'); + + const response = buildResponse( + { command: 'doctor', ok: !hasError, root, diagnostics, next: [] }, + { checks }, + ); + + const pretty = checks + .map((check) => ` [${check.ok ? 'ok' : check.severity}] ${check.name}: ${check.message}`) + .join('\n'); + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..bce3390 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,139 @@ +/** + * `earsyntax init` — create `.earsyntax/` and optional agent wrapper files. + * + * Refuses to overwrite an existing `.earsyntax/` (or an existing tool wrapper + * file) without `--force`, returning exit 3. The workflow works with + * `--tools none`; wrappers are a convenience layer. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import type { CommandContext } from '../context.js'; +import { refusalError, usageError } from '../errors.js'; +import { defaultConfig, earsyntaxDir, toRelative } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { + AGENTS_END, + AGENTS_START, + KNOWN_TOOLS, + type WrapperFile, + wrapperFilesFor, +} from '../tool-wrappers.js'; + +function resolveTools(raw: string | undefined): string[] { + if (raw === undefined || raw.trim() === '') { + return []; + } + if (raw.trim() === 'none') { + return []; + } + const tools = raw + .split(',') + .map((t) => t.trim()) + .filter((t) => t !== ''); + for (const tool of tools) { + if (!KNOWN_TOOLS.has(tool)) { + throw usageError( + 'init.bad_tools', + `Unknown tool "${tool}" in --tools. Expected: claude, codex, cursor, or none.`, + ); + } + } + return [...new Set(tools)]; +} + +/** Merge the earsyntax section into an existing or new AGENTS.md, returning its content. */ +function mergeAgentsFile(existing: string | undefined, section: string): string { + if (existing === undefined) { + return section; + } + const startIdx = existing.indexOf(AGENTS_START); + const endIdx = existing.indexOf(AGENTS_END); + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + const before = existing.slice(0, startIdx); + const after = existing.slice(endIdx + AGENTS_END.length); + return `${before}${section.trim()}${after}`; + } + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + return `${existing}${separator}${section}`; +} + +export function initCommand(context: CommandContext): number { + const root = context.cwd; + const force = context.args.booleans.has('force'); + const tools = resolveTools(context.args.values.get('tools')); + + const earsDir = earsyntaxDir(root); + if (existsSync(earsDir) && !force) { + throw refusalError( + 'init.exists', + 'An .earsyntax/ directory already exists. Pass --force to reinitialize.', + ); + } + + // Collect every wrapper file up front so overwrite protection can run before + // any write. AGENTS.md is merged, not clobbered, so it is exempt. + const wrappers: WrapperFile[] = tools.flatMap((tool) => wrapperFilesFor(tool)); + for (const wrapper of wrappers) { + if (wrapper.path === 'AGENTS.md') { + continue; + } + if (existsSync(resolve(root, wrapper.path)) && !force) { + throw refusalError( + 'init.wrapper_exists', + `Refusing to overwrite ${wrapper.path} without --force.`, + { path: wrapper.path }, + ); + } + } + + const written: string[] = []; + const writeFile = (relPath: string, content: string): void => { + const abs = resolve(root, relPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + written.push(relPath); + }; + + // Core workspace. + mkdirSync(earsDir, { recursive: true }); + writeFile('.earsyntax/config.json', `${JSON.stringify(defaultConfig(tools), null, 2)}\n`); + writeFile('.earsyntax/work/.gitkeep', ''); + + // Tool wrappers. + for (const wrapper of wrappers) { + if (wrapper.path === 'AGENTS.md') { + const abs = resolve(root, 'AGENTS.md'); + const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : undefined; + writeFileSync(abs, mergeAgentsFile(existing, wrapper.content)); + written.push('AGENTS.md'); + } else { + writeFile(wrapper.path, wrapper.content); + } + } + + const response = buildResponse( + { + command: 'init', + ok: true, + root, + next: [ + { + command: 'earsyntax new --source specs/source.md --json', + reason: 'Create a work item for a source spec.', + forAgent: true, + }, + ], + }, + { written, tools }, + ); + + const pretty = [ + `Initialized earsyntax in ${toRelative(root, earsDir)}`, + ...written.map((p) => ` wrote ${p}`), + tools.length > 0 ? ` tools: ${tools.join(', ')}` : ' tools: none', + ].join('\n'); + + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts new file mode 100644 index 0000000..16bf861 --- /dev/null +++ b/packages/cli/src/commands/instructions.ts @@ -0,0 +1,156 @@ +/** + * `earsyntax instructions --work ` — return the rules an agent + * follows for one loop step. Read-only. + * + * The four modes are author, convert, repair, and review. Convert (and repair + * and review when the item has a source) include a `source` block with + * excerpts; repair also includes the diagnostics the agent must address, read + * from the work item's last `validation.json`. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import type { CommandContext } from '../context.js'; +import type { + FacadeDiagnostic, + NextAction, + SourceExcerpt, + ValidationResult, +} from '../facade-types.js'; +import { usageError } from '../errors.js'; +import { requireInstructionMode } from '../mode.js'; +import { loadConfig, requireRoot, resolveInput } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { ALLOWED_PATTERNS, exampleLine, METADATA_PREFIXES, rulesFor } from '../rules.js'; +import { requireManifest, toWorkSummary } from '../workspace.js'; + +const MAX_EXCERPTS = 3; + +/** Best-effort source excerpts: the first few content lines with their numbers. */ +function buildExcerpts(root: string, sourcePath: string): SourceExcerpt[] { + const abs = resolveInput(root, sourcePath); + let content: string; + try { + content = readFileSync(abs, 'utf8'); + } catch { + return []; + } + const excerpts: SourceExcerpt[] = []; + const lines = content.split('\n'); + for (let i = 0; i < lines.length && excerpts.length < MAX_EXCERPTS; i++) { + const text = lines[i]?.trim() ?? ''; + if (text === '' || text.startsWith('#')) { + continue; + } + const lineNo = i + 1; + excerpts.push({ path: sourcePath, startLine: lineNo, endLine: lineNo, text }); + } + return excerpts; +} + +/** Read the last validation.json results and flatten them to facade diagnostics. */ +function readRepairDiagnostics(validationJsonAbs: string): FacadeDiagnostic[] { + if (!existsSync(validationJsonAbs)) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(validationJsonAbs, 'utf8')); + } catch { + return []; + } + const results = + parsed && typeof parsed === 'object' && 'results' in parsed + ? ((parsed as { results?: ValidationResult[] }).results ?? []) + : []; + return results.flatMap((result) => + result.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + path: result.file, + ...(result.line !== undefined ? { line: result.line } : {}), + })), + ); +} + +export function instructionsCommand(context: CommandContext): number { + const mode = requireInstructionMode(context.args.positionals[0]); + const workId = context.args.values.get('work'); + if (workId === undefined) { + throw usageError('instructions.missing_work', 'Provide the work item with --work .'); + } + + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + const { paths, manifest } = requireManifest(root, config, workId); + + const sourcePath = manifest.source?.path; + const output = manifest.output.path; + + // Convert mode carries the source block with excerpts; repair mode carries + // the diagnostics to fix. This mirrors the golden instruction fixtures. + const sourceBlock = + mode === 'convert' && sourcePath !== undefined + ? { + source: { + path: sourcePath, + hash: manifest.source?.hash, + excerpts: buildExcerpts(root, sourcePath), + }, + } + : {}; + const diagnosticsBlock = + mode === 'repair' ? { diagnostics: readRepairDiagnostics(paths.validationJson) } : {}; + + const extra: Record = { + mode, + work: toWorkSummary(root, manifest), + rules: rulesFor(mode), + format: { + line: exampleLine(mode, sourcePath), + allowedPatterns: ALLOWED_PATTERNS, + metadataPrefixes: METADATA_PREFIXES, + }, + ...sourceBlock, + ...diagnosticsBlock, + }; + + const sourceFlag = sourcePath ? ` --source ${sourcePath}` : ''; + const validateNext: NextAction = { + command: `earsyntax validate ${output}${sourceFlag} --json`, + reason: + mode === 'repair' + ? 'Re-validate after applying the repairs and repeat until validation is clean.' + : 'Validate the generated .ears file once it is written.', + forAgent: true, + }; + const reviewNext: NextAction[] = [ + { + command: `earsyntax show ${manifest.id} --artifact questions --json`, + reason: 'Read the open questions the human must resolve before acceptance.', + forAgent: true, + }, + { + command: `earsyntax accept ${manifest.id}`, + reason: 'Record human acceptance once the reviewer approves.', + blocking: true, + }, + ]; + + const response = buildResponse( + { + command: `instructions ${mode}`, + ok: true, + root, + next: mode === 'review' ? reviewNext : [validateNext], + }, + extra, + ); + + const pretty = [`instructions ${mode} for "${manifest.id}"`, ...rulesFor(mode).map((r) => ` - ${r}`)].join( + '\n', + ); + + emit(context.emitter, response, pretty); + return 0; +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..8c04605 --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,34 @@ +/** + * `earsyntax list` — list work items, sorted by id. + * + * Read-only. `--status ` filters by the reported (computed) status, so + * `--status stale` surfaces items whose source drifted after validation. + */ + +import type { CommandContext } from '../context.js'; +import type { WorkStatus } from '../facade-types.js'; +import { loadConfig, requireRoot } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { listSlugs, readManifest, toWorkSummary, workPaths } from '../workspace.js'; + +export function listCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + const filter = context.args.values.get('status') as WorkStatus | undefined; + + const items = listSlugs(root, config) + .map((slug) => readManifest(workPaths(root, config, slug))) + .filter((manifest): manifest is NonNullable => manifest !== undefined) + .map((manifest) => toWorkSummary(root, manifest)) + .filter((summary) => filter === undefined || summary.status === filter); + + const response = buildResponse({ command: 'list', ok: true, root, next: [] }, { items }); + + const pretty = + items.length === 0 + ? 'No work items.' + : items.map((item) => `${item.id} ${item.status}`).join('\n'); + + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts new file mode 100644 index 0000000..9810d09 --- /dev/null +++ b/packages/cli/src/commands/new.ts @@ -0,0 +1,194 @@ +/** + * `earsyntax new ` — scaffold a work item. + * + * Prepares the directory an agent writes into; it never generates EARS content. + * Convert mode requires `--source `, author mode requires `--prompt`. + * Refuses to clobber an existing work item without `--force` (exit 3). + */ + +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { extname, resolve } from 'node:path'; +import type { CommandContext } from '../context.js'; +import type { WorkManifest, WorkMode } from '../facade-types.js'; +import { refusalError, usageError } from '../errors.js'; +import { hashContent } from '../hash.js'; +import { loadConfig, requireRoot, resolveInput, toRelative } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { nextForStatus } from '../next-actions.js'; +import { sourceKind, workPaths } from '../workspace.js'; + +const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i; + +const QUESTIONS_TEMPLATE = [ + '# Open questions', + '', + '', + '', +].join('\n'); + +const TRACEABILITY_TEMPLATE = `${JSON.stringify({ requirements: [], questions: [] }, null, 2)}\n`; + +interface ModeResolution { + mode: WorkMode; + source?: string; + prompt?: string; +} + +function resolveMode(context: CommandContext): ModeResolution { + const modeFlag = context.args.values.get('mode'); + const source = context.args.values.get('source'); + const prompt = context.args.values.get('prompt'); + + if (modeFlag !== undefined && modeFlag !== 'convert' && modeFlag !== 'author') { + throw usageError('new.bad_mode', `Unknown --mode "${modeFlag}". Expected convert or author.`); + } + + const mode: WorkMode | undefined = + modeFlag === 'convert' || modeFlag === 'author' + ? modeFlag + : source !== undefined + ? 'convert' + : prompt !== undefined + ? 'author' + : undefined; + + if (mode === undefined) { + throw usageError( + 'new.missing_mode', + 'Specify a mode: pass --source (convert) or --prompt (author).', + ); + } + if (mode === 'convert' && source === undefined) { + throw usageError('new.missing_source', 'Convert mode requires --source .'); + } + if (mode === 'author' && prompt === undefined) { + throw usageError('new.missing_prompt', 'Author mode requires --prompt .'); + } + + return { + mode, + ...(source !== undefined ? { source } : {}), + ...(prompt !== undefined ? { prompt } : {}), + }; +} + +export function newCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + + const slug = context.args.positionals.at(0); + if (slug === undefined || !SLUG_RE.test(slug)) { + throw usageError( + 'new.bad_slug', + `Provide a valid work-item slug (letters, digits, ., -, _). Got: "${slug ?? ''}".`, + ); + } + + const { mode, source, prompt } = resolveMode(context); + const force = context.args.booleans.has('force'); + + const paths = workPaths(root, config, slug); + if (existsSync(paths.manifest) && !force) { + throw refusalError('new.exists', `Work item "${slug}" already exists. Pass --force to recreate.`); + } + + // Read and hash the source for convert mode. + let sourceRel: string | undefined; + let sourceHash: string | undefined; + let snapshotRel: string | undefined; + if (mode === 'convert' && source !== undefined) { + const sourceAbs = resolveInput(context.cwd, source); + let content: string; + try { + content = readFileSync(sourceAbs, 'utf8'); + } catch { + throw usageError('new.source_unreadable', `Could not read source file: ${source}.`); + } + sourceRel = toRelative(root, sourceAbs); + sourceHash = hashContent(content); + if (context.args.booleans.has('snapshot-source')) { + const snapshotAbs = resolve(paths.dir, `source${extname(sourceAbs) || '.txt'}`); + mkdirSync(paths.dir, { recursive: true }); + copyFileSync(sourceAbs, snapshotAbs); + snapshotRel = toRelative(root, snapshotAbs); + } + } + + const outFlag = context.args.values.get('out'); + const outputRel = outFlag + ? toRelative(root, resolveInput(context.cwd, outFlag)) + : toRelative(root, paths.requirements); + + const now = new Date().toISOString(); + const sourceBlock = + mode === 'convert' && sourceRel !== undefined + ? { + source: { + path: sourceRel, + hash: sourceHash, + kind: sourceKind(sourceRel), + ...(snapshotRel !== undefined ? { snapshotPath: snapshotRel } : {}), + }, + } + : {}; + const promptBlock = mode === 'author' && prompt !== undefined ? { prompt } : {}; + const manifest: WorkManifest = { + schemaVersion: 1, + id: slug, + mode, + status: 'scaffolded', + ...sourceBlock, + ...promptBlock, + output: { path: outputRel }, + artifacts: { + questions: toRelative(root, paths.questions), + traceability: toRelative(root, paths.traceability), + validationJson: toRelative(root, paths.validationJson), + validationMarkdown: toRelative(root, paths.validationMarkdown), + }, + createdAt: now, + updatedAt: now, + }; + + // Write the manifest and empty artifacts. + mkdirSync(paths.dir, { recursive: true }); + const written: string[] = []; + const outputAbs = resolveInput(root, outputRel); + mkdirSync(resolve(outputAbs, '..'), { recursive: true }); + writeFileSync(outputAbs, ''); + written.push(outputRel); + writeFileSync(paths.questions, QUESTIONS_TEMPLATE); + written.push(toRelative(root, paths.questions)); + writeFileSync(paths.traceability, TRACEABILITY_TEMPLATE); + written.push(toRelative(root, paths.traceability)); + writeFileSync(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`); + written.push(toRelative(root, paths.manifest)); + + const work: Record = { + id: slug, + mode, + status: 'scaffolded', + ...(mode === 'convert' ? { source: sourceRel, sourceHash } : { prompt }), + output: outputRel, + }; + + const response = buildResponse( + { + command: 'new', + ok: true, + root, + next: nextForStatus(slug, 'scaffolded', sourceRel), + }, + { work, written }, + ); + + const pretty = [ + `Created work item "${slug}" (${mode}, scaffolded).`, + ...written.map((p) => ` wrote ${p}`), + ].join('\n'); + + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/show.ts b/packages/cli/src/commands/show.ts new file mode 100644 index 0000000..bac9b4e --- /dev/null +++ b/packages/cli/src/commands/show.ts @@ -0,0 +1,79 @@ +/** + * `earsyntax show [--artifact ]` — resolve artifact paths or print + * artifact content. Read-only. + * + * Without `--artifact`, returns resolved paths for every artifact. With one, + * returns `{ path, exists, content }`, where content is the UTF-8 text when the + * file exists and null otherwise, so an agent needs no second read. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import type { CommandContext } from '../context.js'; +import { usageError } from '../errors.js'; +import { loadConfig, requireRoot, toRelative } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { reportedStatus, requireManifest } from '../workspace.js'; + +const ARTIFACTS = ['requirements', 'questions', 'traceability', 'validation', 'manifest'] as const; +type ArtifactName = (typeof ARTIFACTS)[number]; + +export function showCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + + const slug = context.args.positionals.at(0); + if (slug === undefined) { + throw usageError('show.missing_slug', 'Provide a work-item slug.'); + } + const { paths, manifest } = requireManifest(root, config, slug); + + const absFor: Record = { + requirements: paths.requirements, + questions: paths.questions, + traceability: paths.traceability, + validation: paths.validationJson, + manifest: paths.manifest, + }; + + const artifactFlag = context.args.values.get('artifact'); + if (artifactFlag !== undefined) { + if (!(ARTIFACTS as readonly string[]).includes(artifactFlag)) { + throw usageError( + 'show.unknown_artifact', + `Unknown artifact "${artifactFlag}". Expected one of: ${ARTIFACTS.join(', ')}.`, + ); + } + const abs = absFor[artifactFlag as ArtifactName]; + const exists = existsSync(abs); + let content: string | null = null; + if (exists) { + try { + content = readFileSync(abs, 'utf8'); + } catch { + content = null; + } + } + const artifact = { path: toRelative(root, abs), exists, content }; + const response = buildResponse({ command: 'show', ok: true, root, next: [] }, { artifact }); + emit(context.emitter, response, content ?? `${toRelative(root, abs)} (missing)`); + return 0; + } + + const work = { + id: manifest.id, + status: reportedStatus(root, manifest), + source: manifest.source?.path ?? null, + output: manifest.output.path, + requirements: toRelative(root, paths.requirements), + questions: toRelative(root, paths.questions), + traceability: toRelative(root, paths.traceability), + validation: toRelative(root, paths.validationJson), + manifest: toRelative(root, paths.manifest), + }; + const response = buildResponse({ command: 'show', ok: true, root, next: [] }, { work }); + const pretty = Object.entries(work) + .map(([key, value]) => ` ${key}: ${String(value)}`) + .join('\n'); + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 0000000..c3e35e7 --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,63 @@ +/** + * `earsyntax status []` — report one work item's state and next steps. + * + * Read-only. With no slug it resolves the single work item when there is + * exactly one. `status` reports computed staleness on top of the stored status. + */ + +import type { CommandContext } from '../context.js'; +import { usageError } from '../errors.js'; +import { loadConfig, requireRoot } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { nextForStatus } from '../next-actions.js'; +import { isStale, listSlugs, reportedStatus, requireManifest } from '../workspace.js'; + +function resolveSlug(context: CommandContext, root: string, config: ReturnType): string { + const explicit = context.args.positionals.at(0); + if (explicit !== undefined) { + return explicit; + } + const slugs = listSlugs(root, config); + const only = slugs.at(0); + if (slugs.length === 1 && only !== undefined) { + return only; + } + if (slugs.length === 0) { + throw usageError('status.no_work', 'No work items found. Run `earsyntax new` first.'); + } + throw usageError('status.ambiguous', `Specify a work item: ${slugs.join(', ')}.`); +} + +export function statusCommand(context: CommandContext): number { + const root = requireRoot(context.cwd); + const config = loadConfig(root, context.global.config); + const slug = resolveSlug(context, root, config); + const { manifest } = requireManifest(root, config, slug); + + const status = reportedStatus(root, manifest); + const work: Record = { + id: manifest.id, + status, + mode: manifest.mode, + source: manifest.source?.path ?? null, + output: manifest.output.path, + sourceHash: manifest.source?.hash ?? null, + outputHash: manifest.output.hash ?? null, + acceptedHash: manifest.accepted?.outputHash ?? null, + stale: isStale(root, manifest), + }; + + const response = buildResponse( + { command: 'status', ok: true, root, next: nextForStatus(manifest.id, status, manifest.source?.path) }, + { work }, + ); + + const pretty = [ + `${manifest.id}: ${status} (${manifest.mode})`, + manifest.source?.path ? ` source: ${manifest.source.path}` : ' source: (none)', + ` output: ${manifest.output.path}`, + ].join('\n'); + + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..5bb21df --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,289 @@ +/** + * `earsyntax validate ` — validate `.ears` files deterministically. + * + * Uses `@earsyntax/extract` to read every supported format and + * `@earsyntax/core` `lintEarsBatch` to lint. Never writes source or asks + * questions. This is the only command that returns exit 1 (error diagnostics). + * When run against a work item it updates the manifest status and writes the + * `validation.json` / `validation.md` artifacts, and reports source staleness. + */ + +import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { type Catalog, lintEarsBatch, type Options } from '@earsyntax/core'; +import { extractFromFile } from '@earsyntax/extract'; +import type { CommandContext } from '../context.js'; +import type { FacadeDiagnostic, ValidationResult, WorkManifest, WorkStatus } from '../facade-types.js'; +import { usageError } from '../errors.js'; +import { hashContent } from '../hash.js'; +import { findRoot, loadConfig, requireRoot, resolveInput, toRelative } from '../project.js'; +import { buildResponse, emit } from '../response.js'; +import { currentSourceHash, requireManifest, toWorkSummary, type WorkPaths } from '../workspace.js'; + +const GLOB_CHARS = /[*?[\]{}]/; + +/** Expand file arguments (literals and globs) into absolute paths, in order. */ +function expandFiles(cwd: string, patterns: string[]): string[] { + const files: string[] = []; + for (const pattern of patterns) { + if (GLOB_CHARS.test(pattern)) { + const matches = globSync(pattern, { cwd }); + for (const match of matches.sort((a, b) => a.localeCompare(b))) { + files.push(resolveInput(cwd, match)); + } + } else { + const abs = resolveInput(cwd, pattern); + if (!existsSync(abs)) { + throw usageError('validate.missing_file', `File not found: ${pattern}.`); + } + files.push(abs); + } + } + return files; +} + +/** Load and parse a catalog file, throwing a usage error on failure. */ +function loadCatalog(cwd: string, catalogArg: string | undefined): Catalog | undefined { + if (catalogArg === undefined) { + return undefined; + } + const abs = resolveInput(cwd, catalogArg); + let raw: string; + try { + raw = readFileSync(abs, 'utf8'); + } catch { + throw usageError('validate.catalog_unreadable', `Could not read catalog: ${catalogArg}.`); + } + try { + return JSON.parse(raw) as Catalog; + } catch { + throw usageError('validate.catalog_invalid', `Catalog is not valid JSON: ${catalogArg}.`); + } +} + +/** Build core lint options from the parsed flags. */ +function buildOptions(context: CommandContext): Options { + const mode = context.args.values.get('mode'); + if (mode !== undefined && mode !== 'strict' && mode !== 'guided') { + throw usageError('validate.bad_mode', `Unknown --mode "${mode}". Expected strict or guided.`); + } + return { + ...(mode === 'strict' || mode === 'guided' ? { mode } : {}), + ...(context.args.booleans.has('comma-as-and') ? { commaAsAnd: true } : {}), + }; +} + +/** Find the work-item slug a validated path belongs to, if any. */ +function inferWorkSlug(root: string, workDir: string, absFiles: string[]): string | undefined { + const base = resolve(root, workDir); + for (const file of absFiles) { + const rel = relative(base, file); + if (!rel.startsWith('..') && rel !== '') { + const segment = rel.split(/[/\\]/).at(0); + if (segment !== undefined && segment !== '') { + return segment; + } + } + } + return undefined; +} + +/** Render a short human-readable validation.md artifact. */ +function renderValidationMarkdown(results: ValidationResult[], errors: number, warnings: number): string { + const lines = ['# Validation', '', `errors: ${errors}, warnings: ${warnings}`, '']; + for (const result of results) { + for (const diagnostic of result.diagnostics) { + const at = result.line === undefined ? '' : `:${result.line}`; + lines.push(`- ${diagnostic.severity} ${diagnostic.code} (${result.id ?? '?'}${at}) ${diagnostic.message}`); + } + } + lines.push(''); + return lines.join('\n'); +} + +export function validateCommand(context: CommandContext): number { + const patterns = context.args.positionals; + if (patterns.length === 0) { + throw usageError('validate.no_files', 'Provide one or more .ears files or globs to validate.'); + } + + const workId = context.args.values.get('work'); + const root = workId !== undefined ? requireRoot(context.cwd) : (findRoot(context.cwd) ?? context.cwd); + const config = existsSync(resolve(root, '.earsyntax')) ? loadConfig(root, context.global.config) : undefined; + + const absFiles = expandFiles(context.cwd, patterns); + const catalog = loadCatalog(context.cwd, context.args.values.get('catalog')); + const options = buildOptions(context); + + // Lint each file, grouping results in input order. + const results: ValidationResult[] = []; + let requirements = 0; + for (const abs of absFiles) { + const extracted = extractFromFile(abs); + if (extracted.items.length === 0 && extracted.errors.length > 0) { + throw usageError('validate.unreadable', extracted.errors.at(0)?.message ?? `Could not read ${abs}.`); + } + const fileRel = toRelative(root, abs); + const lintResults = lintEarsBatch(extracted.items, catalog, options); + for (let i = 0; i < extracted.items.length; i++) { + const item = extracted.items.at(i); + const lint = lintResults.at(i); + if (item === undefined || lint === undefined) { + continue; + } + requirements += 1; + results.push({ + ...(item.id !== undefined ? { id: item.id } : {}), + file: fileRel, + ...(item.source?.line !== undefined ? { line: item.source.line } : {}), + valid: lint.valid, + ...(lint.pattern !== undefined ? { pattern: lint.pattern } : {}), + ...(lint.ast !== undefined ? { ast: lint.ast } : {}), + references: lint.references, + diagnostics: lint.diagnostics, + }); + } + } + + // Facade-level stable-ID validation: duplicate IDs are error diagnostics. + const facadeDiagnostics: FacadeDiagnostic[] = []; + const seen = new Set(); + for (const result of results) { + if (result.id === undefined) { + continue; + } + if (seen.has(result.id)) { + facadeDiagnostics.push({ + code: 'facade.duplicate_id', + severity: 'error', + message: `Duplicate requirement ID "${result.id}". Requirement IDs must be unique.`, + path: result.file, + ...(result.line !== undefined ? { line: result.line } : {}), + }); + } else { + seen.add(result.id); + } + } + + let errors = facadeDiagnostics.length; + let warnings = 0; + let validCount = 0; + for (const result of results) { + if (result.valid) { + validCount += 1; + } + for (const diagnostic of result.diagnostics) { + if (diagnostic.severity === 'error') { + errors += 1; + } else if (diagnostic.severity === 'warning') { + warnings += 1; + } + } + } + + const summary = { files: absFiles.length, requirements, valid: validCount, errors, warnings }; + const ok = errors === 0; + + // Work-item integration: update the manifest and write artifacts, then expose + // the refreshed work summary and staleness. No target work item leaves it {}. + const slug = + config === undefined ? undefined : (workId ?? inferWorkSlug(root, config.workDir, absFiles)); + const workBlock = + config !== undefined && slug !== undefined + ? (() => { + const { paths, manifest } = requireManifest(root, config, slug); + const { manifest: updated, stale } = updateWorkItem( + root, + paths, + manifest, + errors, + results, + summary, + warnings, + ); + return { work: toWorkSummary(root, updated), stale }; + })() + : {}; + + const extra: Record = { summary, results, ...workBlock }; + + const next = ok + ? [] + : [ + { + command: `earsyntax instructions repair${workId ? ` --work ${workId}` : ''} --json`, + reason: 'Get targeted repair rules for the reported diagnostics.', + forAgent: true, + }, + ]; + + const response = buildResponse( + { command: 'validate', ok, root, diagnostics: facadeDiagnostics, next }, + extra, + ); + + const pretty = renderPretty(results, summary, facadeDiagnostics); + emit(context.emitter, response, pretty); + return ok ? 0 : 1; +} + +/** Update the work manifest after a validation and persist the artifacts. */ +function updateWorkItem( + root: string, + paths: WorkPaths, + manifest: WorkManifest, + errors: number, + results: ValidationResult[], + summary: { files: number; requirements: number; valid: number; errors: number; warnings: number }, + warnings: number, +): { manifest: WorkManifest; stale: boolean } { + const outputAbs = resolveInput(root, manifest.output.path); + let outputHash = manifest.output.hash; + try { + outputHash = hashContent(readFileSync(outputAbs, 'utf8')); + } catch { + // Leave the recorded hash if the output is unreadable. + } + + const nextStatus: WorkStatus = errors > 0 ? 'invalid' : 'valid'; + const updated: WorkManifest = { + ...manifest, + status: nextStatus, + output: { ...manifest.output, hash: outputHash }, + updatedAt: new Date().toISOString(), + }; + writeFileSync(paths.manifest, `${JSON.stringify(updated, null, 2)}\n`); + writeFileSync(paths.validationJson, `${JSON.stringify({ summary, results }, null, 2)}\n`); + writeFileSync(paths.validationMarkdown, renderValidationMarkdown(results, errors, warnings)); + + const current = currentSourceHash(root, updated.source?.path); + const stale = + nextStatus === 'valid' && + current !== undefined && + updated.source?.hash !== undefined && + current !== updated.source.hash; + return { manifest: updated, stale }; +} + +/** Pretty output: one line per diagnostic plus a summary. */ +function renderPretty( + results: ValidationResult[], + summary: { requirements: number; valid: number; errors: number; warnings: number }, + facadeDiagnostics: FacadeDiagnostic[], +): string { + const lines: string[] = []; + for (const result of results) { + for (const diagnostic of result.diagnostics) { + const at = result.line === undefined ? '' : `:${result.line}`; + lines.push(`${result.file}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`); + } + } + for (const diagnostic of facadeDiagnostics) { + const at = diagnostic.line === undefined ? '' : `:${diagnostic.line}`; + lines.push(`${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`); + } + lines.push( + `\n${summary.valid}/${summary.requirements} valid, ${summary.errors} errors, ${summary.warnings} warnings`, + ); + return lines.join('\n'); +} \ No newline at end of file diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts new file mode 100644 index 0000000..5746398 --- /dev/null +++ b/packages/cli/src/commands/version.ts @@ -0,0 +1,28 @@ +/** + * `earsyntax version` — version and feature discovery. + * + * Never resolves a project, so `root` is absent. With `--features` (or `--json`) + * the response carries the {@link FEATURES} capability map agents branch on. + */ + +import type { CommandContext } from '../context.js'; +import { buildResponse, emit } from '../response.js'; +import { CLI_VERSION, FEATURES } from '../version.js'; + +export function versionCommand(context: CommandContext): number { + const response = buildResponse({ command: 'version', ok: true, next: [] }, { features: FEATURES }); + + const pretty = context.args.booleans.has('features') + ? [ + `earsyntax ${CLI_VERSION}`, + ` facade contract: ${FEATURES.facade}`, + ` instructions: ${FEATURES.instructions.join(', ')}`, + ` input formats: ${FEATURES.inputFormats.join(', ')}`, + ` output formats: ${FEATURES.outputFormats.join(', ')}`, + ` sarif: ${FEATURES.sarif ? 'yes' : 'no'}`, + ].join('\n') + : `earsyntax ${CLI_VERSION}`; + + emit(context.emitter, response, pretty); + return 0; +} \ No newline at end of file diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts new file mode 100644 index 0000000..85ae4ea --- /dev/null +++ b/packages/cli/src/context.ts @@ -0,0 +1,21 @@ +/** + * The context object every command handler receives. + * + * It bundles the parsed arguments, resolved global options, the resolved + * working directory, and the output emitter. Command handlers read their inputs + * from here and return an exit code after emitting their response. + */ + +import type { GlobalOptions, ParsedArgs } from './args.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; + emitter: Emitter; +} + +/** A command handler: does its work, emits output, and returns an exit code. */ +export type CommandHandler = (context: CommandContext) => number; \ No newline at end of file diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..da80fbc --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,38 @@ +/** + * The single error type command handlers throw to abort with a facade + * diagnostic and a specific exit code. + * + * `run` catches {@link CliError}, renders a base {@link FacadeResponse} carrying + * the diagnostic (JSON or pretty), and returns the carried exit code. Exit + * codes follow the contract: 2 for usage/resolution errors, 3 for refusals. + */ + +import type { FacadeDiagnostic } from './facade-types.js'; + +export class CliError extends Error { + readonly exitCode: number; + readonly diagnostic: FacadeDiagnostic; + readonly next: { command: string; reason: string; forAgent?: boolean; blocking?: boolean }[]; + + constructor( + exitCode: number, + diagnostic: FacadeDiagnostic, + next: { command: string; reason: string; forAgent?: boolean; blocking?: boolean }[] = [], + ) { + super(diagnostic.message); + this.name = 'CliError'; + this.exitCode = exitCode; + this.diagnostic = diagnostic; + this.next = next; + } +} + +/** Build a usage/resolution error (exit 2). */ +export function usageError(code: string, message: string, extra?: Partial): CliError { + return new CliError(2, { code, severity: 'error', message, ...extra }); +} + +/** Build a refusal error (exit 3): overwrite protection, stale source, confirmation required. */ +export function refusalError(code: string, message: string, extra?: Partial): CliError { + return new CliError(3, { code, severity: 'error', message, ...extra }); +} \ No newline at end of file diff --git a/packages/cli/src/facade-types.ts b/packages/cli/src/facade-types.ts new file mode 100644 index 0000000..33ba0a1 --- /dev/null +++ b/packages/cli/src/facade-types.ts @@ -0,0 +1,125 @@ +/** + * Facade JSON contracts for the `earsyntax` CLI. + * + * These shapes are the normative wire format described in `docs/facade-api.md`. + * Every `--json` command returns a {@link FacadeResponse}; command-specific + * payloads are added as top-level convenience keys rather than nested under + * `data` (see the `[DECIDED] Convenience keys over data` note in the contract). + * + * Core linting types (`Pattern`, `Diagnostic`, `EarsAst`, `ReferenceMatch`) are + * imported from `@earsyntax/core`; this module only defines the facade layer. + */ + +import type { Diagnostic, EarsAst, Pattern, ReferenceMatch } from '@earsyntax/core'; + +/** Severity of a facade-level notice. `info` is facade-only; core emits only error/warning. */ +export type FacadeSeverity = 'error' | 'warning' | 'info'; + +/** A facade-level diagnostic about config, paths, or work items. */ +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; + blocking?: boolean; + forAgent?: boolean; +} + +/** The base shape every `--json` response carries. */ +export interface FacadeResponse { + version: string; + command: string; + ok: boolean; + root?: string; + data?: unknown; + diagnostics?: FacadeDiagnostic[]; + next: NextAction[]; + /** Command-specific convenience keys (work, written, summary, results, ...). */ + [key: string]: unknown; +} + +/** The two ways a work item can be created. */ +export type WorkMode = 'author' | 'convert'; + +/** The lifecycle state of a work item. */ +export type WorkStatus = + | 'missing' + | 'scaffolded' + | 'drafted' + | 'invalid' + | 'valid' + | 'accepted' + | 'stale'; + +/** How a source file was classified for extraction. */ +export type SourceKind = 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; + +/** The durable state of a work item, stored at `.earsyntax/work//manifest.json`. */ +export interface WorkManifest { + schemaVersion: 1; + id: string; + mode: WorkMode; + status: WorkStatus; + source?: { + path?: string; + hash?: string; + kind: SourceKind; + snapshotPath?: string; + }; + prompt?: string; + output: { + path: string; + hash?: string; + }; + artifacts: { + questions: string; + traceability: string; + validationJson: string; + validationMarkdown: string; + }; + createdAt: string; + updatedAt: string; + accepted?: { + at: string; + by?: string; + sourceHash?: string; + outputHash: string; + }; +} + +/** The compact work-item view returned by `instructions`, `list`, and `show`. */ +export interface WorkSummary { + id: string; + status: WorkStatus; + source?: string; + output: string; + questions: string; + traceability: string; +} + +/** One source excerpt returned in convert-mode instructions. */ +export interface SourceExcerpt { + path: string; + startLine: number; + endLine: number; + text: string; +} + +/** One validated requirement in a `validate` response. */ +export interface ValidationResult { + id?: string; + file: string; + line?: number; + valid: boolean; + pattern?: Pattern; + ast?: EarsAst; + references: ReferenceMatch[]; + diagnostics: Diagnostic[]; +} \ No newline at end of file diff --git a/packages/cli/src/hash.ts b/packages/cli/src/hash.ts new file mode 100644 index 0000000..ea74c59 --- /dev/null +++ b/packages/cli/src/hash.ts @@ -0,0 +1,15 @@ +/** + * Content hashing for source and output files. + * + * Hashes are `sha256:` followed by 64 lowercase hex characters, computed over + * the UTF-8 bytes of the content. Used for source-drift (stale) detection and + * to catch a `.ears` file edited after it was validated. + */ + +import { createHash } from 'node:crypto'; + +/** Hash a string as `sha256:<64 hex>`. */ +export function hashContent(content: string): string { + const digest = createHash('sha256').update(content, 'utf8').digest('hex'); + return `sha256:${digest}`; +} \ No newline at end of file diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..e94b321 --- /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'; \ No newline at end of file diff --git a/packages/cli/src/mode.ts b/packages/cli/src/mode.ts new file mode 100644 index 0000000..9b418e4 --- /dev/null +++ b/packages/cli/src/mode.ts @@ -0,0 +1,21 @@ +/** + * The instruction modes and small helpers for validating them. + */ + +import { usageError } from './errors.js'; + +/** The four instruction modes `earsyntax instructions ` accepts. */ +export type InstructionMode = 'author' | 'convert' | 'repair' | 'review'; + +const MODES: readonly InstructionMode[] = ['author', 'convert', 'repair', 'review']; + +/** Narrow an arbitrary string to an {@link InstructionMode} or throw a usage error. */ +export function requireInstructionMode(value: string | undefined): InstructionMode { + if (value !== undefined && (MODES as readonly string[]).includes(value)) { + return value as InstructionMode; + } + throw usageError( + 'instructions.unknown_mode', + `Unknown instructions mode "${value ?? ''}". Expected one of: ${MODES.join(', ')}.`, + ); +} \ No newline at end of file diff --git a/packages/cli/src/next-actions.ts b/packages/cli/src/next-actions.ts new file mode 100644 index 0000000..412a6d1 --- /dev/null +++ b/packages/cli/src/next-actions.ts @@ -0,0 +1,61 @@ +/** + * Builds the `next` action list for a work item from its reported status. + * + * The loop is walkable: an agent follows `next[].command` without hard-coding + * the sequence. `forAgent` marks actions safe to run automatically; `blocking` + * marks the human gate (acceptance). + */ + +import type { NextAction, WorkStatus } from './facade-types.js'; + +/** Next actions for a work item, keyed by its reported status. */ +export function nextForStatus(id: string, status: WorkStatus, source?: string): NextAction[] { + const sourceFlag = source ? ` --source ${source}` : ''; + const validateCmd = `earsyntax validate .earsyntax/work/${id}/requirements.ears${sourceFlag} --json`; + + switch (status) { + case 'missing': + return []; + case 'scaffolded': + return [ + { + command: `earsyntax instructions ${source ? 'convert' : 'author'} --work ${id} --json`, + reason: 'Give the coding agent the rules for writing the .ears file.', + forAgent: true, + }, + ]; + case 'drafted': + case 'invalid': + return [ + { + command: `earsyntax instructions repair --work ${id} --json`, + reason: 'Get targeted repair rules for the reported diagnostics.', + forAgent: true, + }, + { command: validateCmd, reason: 'Re-validate after applying the repairs.', forAgent: true }, + ]; + case 'valid': + return [ + { + command: `earsyntax instructions review --work ${id} --json`, + reason: 'Prepare the human review summary.', + forAgent: true, + }, + { + command: `earsyntax accept ${id}`, + reason: 'Record acceptance after human approval.', + blocking: true, + }, + ]; + case 'accepted': + return []; + case 'stale': + return [ + { + command: `earsyntax instructions convert --work ${id} --json`, + reason: 'The source changed; re-run the loop against the new source.', + forAgent: true, + }, + ]; + } +} \ No newline at end of file diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts new file mode 100644 index 0000000..f2a42c3 --- /dev/null +++ b/packages/cli/src/project.ts @@ -0,0 +1,97 @@ +/** + * Project resolution, config, and path helpers. + * + * The project root is the directory containing `.earsyntax/`, discovered by + * walking up from the resolved cwd. Paths in facade JSON are relative to this + * root (POSIX separators) unless the caller supplied an absolute path. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { usageError } from './errors.js'; + +/** The Earsyntax project config stored at `.earsyntax/config.json`. */ +export interface EarsyntaxConfig { + version: number; + /** Work directory relative to the root, for example `.earsyntax/work`. */ + workDir: string; + /** Tool wrappers written at init time. */ + tools: string[]; +} + +/** The default config `init` writes. */ +export function defaultConfig(tools: string[]): EarsyntaxConfig { + return { version: 1, workDir: '.earsyntax/work', tools }; +} + +/** The absolute path to the `.earsyntax` directory for a given root. */ +export function earsyntaxDir(root: string): string { + return resolve(root, '.earsyntax'); +} + +/** Find the project root by walking up from `startDir` looking for `.earsyntax/`. */ +export function findRoot(startDir: string): string | undefined { + let current = resolve(startDir); + for (;;) { + if (existsSync(earsyntaxDir(current))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +/** Resolve the project root or throw a usage error if the project is not initialized. */ +export function requireRoot(cwd: string): string { + const root = findRoot(cwd); + if (root === undefined) { + throw usageError( + 'project.not_initialized', + 'No .earsyntax project found. Run `earsyntax init` first.', + ); + } + return root; +} + +/** Load config from an explicit path or the project's `.earsyntax/config.json`. */ +export function loadConfig(root: string, explicitPath?: string): EarsyntaxConfig { + const path = explicitPath ? resolve(root, explicitPath) : resolve(earsyntaxDir(root), 'config.json'); + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + throw usageError('config.unreadable', `Could not read config at ${path}.`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw usageError('config.invalid', `Config at ${path} is not valid JSON.`); + } + if (!parsed || typeof parsed !== 'object') { + throw usageError('config.invalid', `Config at ${path} is not a JSON object.`); + } + const obj = parsed as Partial; + return { + version: typeof obj.version === 'number' ? obj.version : 1, + workDir: typeof obj.workDir === 'string' ? obj.workDir : '.earsyntax/work', + tools: Array.isArray(obj.tools) ? obj.tools.filter((t): t is string => typeof t === 'string') : [], + }; +} + +/** Convert an absolute path to a root-relative POSIX path, or keep it absolute if outside the root. */ +export function toRelative(root: string, absPath: string): string { + const rel = relative(root, absPath); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + return absPath; + } + return rel.split(sep).join('/'); +} + +/** Resolve a possibly-relative user path against the resolved cwd. */ +export function resolveInput(cwd: string, input: string): string { + return isAbsolute(input) ? input : resolve(cwd, input); +} \ No newline at end of file diff --git a/packages/cli/src/response.ts b/packages/cli/src/response.ts new file mode 100644 index 0000000..0576298 --- /dev/null +++ b/packages/cli/src/response.ts @@ -0,0 +1,71 @@ +/** + * 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. + */ +export function buildResponse(init: BaseInit, extra: Record = {}): FacadeResponse { + // One immutable literal in the stable facade key order: base identity, root, + // command convenience keys, diagnostics, then next last (matching fixtures). + 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 response: JSON when `--json`, otherwise the pretty string. */ +export function emit(emitter: Emitter, response: FacadeResponse, pretty: string): void { + emitter.write(`${emitter.json ? serialize(response) : pretty}\n`); +} + +/** Build the response for a {@link CliError} so `run` can emit it uniformly. */ +export function errorResponse(command: string, root: string | undefined, error: CliError): FacadeResponse { + return buildResponse( + { + command, + ok: false, + root, + diagnostics: [error.diagnostic], + next: error.next, + }, + {}, + ); +} \ No newline at end of file diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts new file mode 100644 index 0000000..86c8abe --- /dev/null +++ b/packages/cli/src/rules.ts @@ -0,0 +1,104 @@ +/** + * The instruction bodies the `instructions` command returns, embedded as data. + * + * These arrays are the canonical rule text from `docs/agent-rules.md`. They are + * embedded here, not read from `docs/` at runtime, so the published binary + * carries its own contract. The CLI JSON is the source of truth agents read; + * the doc is the human-readable mirror of the same content. + */ + +import type { InstructionMode } from './mode.js'; + +/** The five allowed EARS line templates, constant across all modes. */ +export const ALLOWED_PATTERNS = [ + 'The shall .', + 'When , the shall .', + 'While , the shall .', + 'Where , the shall .', + 'If , then the shall .', +]; + +/** The metadata prefixes an agent may put in front of a requirement line. */ +export const METADATA_PREFIXES = [ + 'REQ-001:', + 'REQ-001 [source: path:line]:', + 'REQ-001 [source: path:line-line]:', +]; + +const AUTHOR_RULES = [ + 'Treat the prompt as the only source of behavior; do not add requirements it does not state.', + 'Write only EARS requirements in the .ears output file.', + 'Use one requirement per non-empty line.', + 'Give every requirement a stable ID such as REQ-001.', + 'Use the narrowest EARS pattern that fits the described behavior.', + 'Do not invent behavior that seems reasonable, secure, or common but is absent from the prompt.', + 'Do not hide ambiguity inside vague wording.', + 'Split compound behavior into separate requirements when the response holds more than one observable obligation.', + 'Write unclear behavior to questions.md instead of guessing a precise requirement.', + 'Record each generated requirement in traceability.json; author-mode requirements have no source line, so leave the source object empty and set confidence.', +]; + +const CONVERT_RULES = [ + 'Read the full source before writing requirements.', + 'Write only EARS requirements in the .ears output file.', + 'Use one requirement per non-empty line.', + 'Give every requirement a stable ID such as REQ-001.', + 'Preserve source traceability with a [source: path:line] prefix when the source line is known.', + 'Use the narrowest EARS pattern that fits the source behavior.', + 'Do not invent behavior that is not stated in the source.', + 'Do not hide ambiguity inside vague wording.', + 'Split compound behavior into separate requirements when the response holds more than one observable obligation.', + 'Write unclear behavior to questions.md instead of guessing a precise requirement.', + 'Maintain traceability.json for every generated requirement and question.', + 'Do not edit the source spec unless the user explicitly asks.', +]; + +const REPAIR_RULES = [ + 'Change only what the diagnostics justify; leave passing requirements untouched.', + 'Preserve requirement IDs unless a duplicate-ID diagnostic forces a change.', + 'Preserve the [source: path:line] references on repaired lines.', + 'Preserve the intended behavior; do not weaken a requirement because it is harder to parse.', + 'Do not delete a failing requirement to make validation pass.', + 'For ears.invalid_if_then_form, add the missing then boundary: If , then the shall .', + 'For lint.vague_response, replace the vague term with an observable, bounded response, or raise a question in questions.md if the bound is unknown.', + 'If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing.', + 'Update traceability.json if an ID or source reference changes.', +]; + +const REVIEW_RULES = [ + "Present a review summary before recommending acceptance; do not accept on the human's behalf.", + 'Report how many requirements were generated and the distribution across the six EARS patterns.', + 'List every unresolved question in questions.md and the requirements each one blocks.', + 'State the validation status and the exact command that produced it.', + 'Call out source lines translated with medium or low confidence.', + 'Call out any source behavior that was intentionally not converted, with the reason.', + 'Recommend acceptance only when validation is clean and no blocking questions remain.', +]; + +/** The rule array for a given instruction mode. */ +export function rulesFor(mode: InstructionMode): string[] { + switch (mode) { + case 'author': + return [...AUTHOR_RULES]; + case 'convert': + return [...CONVERT_RULES]; + case 'repair': + return [...REPAIR_RULES]; + case 'review': + return [...REVIEW_RULES]; + } +} + +/** A representative example line for a mode, using the work item's source path. */ +export function exampleLine(mode: InstructionMode, sourcePath?: string): string { + const src = sourcePath ?? 'specs/source.md'; + switch (mode) { + case 'author': + return 'REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; + case 'convert': + case 'review': + return `REQ-001 [source: ${src}:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.`; + case 'repair': + return `REQ-002 [source: ${src}:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook.`; + } +} \ No newline at end of file diff --git a/packages/cli/src/tool-wrappers.ts b/packages/cli/src/tool-wrappers.ts new file mode 100644 index 0000000..1d51408 --- /dev/null +++ b/packages/cli/src/tool-wrappers.ts @@ -0,0 +1,98 @@ +/** + * Thin per-agent wrapper files written by `init --tools`. + * + * Every wrapper is a pointer to the same CLI loop: call + * `earsyntax instructions --json`, write the `.ears` file, then call + * `earsyntax validate --json` and repair until it is clean. Wrappers never fork + * or paraphrase the rules; the CLI is the single source of truth. + */ + +/** A file a tool wrapper writes: a root-relative path and its content. */ +export interface WrapperFile { + path: string; + content: string; +} + +/** Marker wrapping the injected AGENTS.md section so it can be detected and replaced. */ +export const AGENTS_START = ''; +export const AGENTS_END = ''; + +function claudeCommand(mode: 'author' | 'convert' | 'repair'): string { + const verb = + mode === 'author' + ? 'Author EARS requirements from a prompt' + : mode === 'convert' + ? 'Convert a source spec into EARS requirements' + : 'Repair EARS requirements after validation diagnostics'; + return [ + `# /earsyntax-${mode}`, + '', + `${verb}. This command is a thin wrapper around the \`earsyntax\` facade; the CLI returns the rules to follow.`, + '', + '1. Run `earsyntax instructions ' + + mode + + ' --work --json` and follow the returned rules exactly.', + '2. Write the `.ears` file, and maintain `questions.md` and `traceability.json`.', + '3. Run `earsyntax validate .ears --json`.', + '4. If it reports error diagnostics, run `earsyntax instructions repair --work --json` and fix only what the diagnostics justify. Repeat until validation is clean.', + '', + 'Do not invent behavior the source does not state. Put ambiguity in `questions.md`.', + '', + ].join('\n'); +} + +const AGENTS_SECTION = [ + AGENTS_START, + '## EARS requirements (earsyntax)', + '', + 'When asked to write or repair EARS `.ears` requirements, drive the `earsyntax` facade:', + '', + '1. `earsyntax instructions --work --json` returns the rules. Follow them exactly.', + '2. Write the `.ears` file (one requirement per line, stable REQ IDs), then `earsyntax validate .ears --json`.', + '3. Repair by diagnostic code until validation is clean. A human runs `earsyntax accept`.', + '', + 'Do not invent behavior the source does not state; record ambiguity in `questions.md`.', + AGENTS_END, + '', +].join('\n'); + +const CURSOR_RULES = [ + '---', + 'description: Drive EARS requirement authoring through the earsyntax CLI facade', + 'globs: ["**/*.ears"]', + '---', + '', + '# EARS requirements via earsyntax', + '', + 'Author and repair `.ears` files only through the `earsyntax` facade:', + '', + '1. `earsyntax instructions --work --json` returns the rules; follow them exactly.', + '2. Write the `.ears` file, then `earsyntax validate .ears --json`.', + '3. Fix by diagnostic code until validation is clean. Acceptance is a human step (`earsyntax accept`).', + '', + 'Do not invent behavior the source does not state; put ambiguity in `questions.md`.', + '', +].join('\n'); + +/** The wrapper files for one tool, as root-relative paths and content. */ +export function wrapperFilesFor(tool: string): WrapperFile[] { + switch (tool) { + case 'claude': + return [ + { path: '.claude/commands/earsyntax-author.md', content: claudeCommand('author') }, + { path: '.claude/commands/earsyntax-convert.md', content: claudeCommand('convert') }, + { path: '.claude/commands/earsyntax-repair.md', content: claudeCommand('repair') }, + ]; + case 'cursor': + return [{ path: '.cursor/rules/earsyntax.mdc', content: CURSOR_RULES }]; + case 'codex': + // codex uses an AGENTS.md section, handled specially by init (append or + // replace the marked section rather than clobbering a user-owned file). + return [{ path: 'AGENTS.md', content: AGENTS_SECTION }]; + default: + return []; + } +} + +/** The set of tool names `init --tools` understands. */ +export const KNOWN_TOOLS = new Set(['claude', 'codex', 'cursor']); \ No newline at end of file diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..fd62b7d --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,59 @@ +/** + * 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 without guessing. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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; + workItems: boolean; + instructions: string[]; + inputFormats: string[]; + outputFormats: string[]; + sarif: boolean; +} + +/** Capability map returned by `version --features`. */ +export const FEATURES: Features = { + facade: FACADE_CONTRACT, + workItems: true, + instructions: ['author', 'convert', 'repair', 'review'], + inputFormats: ['.ears', 'markdown', 'yaml', 'json'], + outputFormats: ['pretty', 'json'], + // SARIF output exists in @earsyntax/cli-contract but is not wired into the + // CLI in this milestone; the flag is false so agents do not select it. + sarif: false, +}; \ No newline at end of file diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts new file mode 100644 index 0000000..a7c466e --- /dev/null +++ b/packages/cli/src/workspace.ts @@ -0,0 +1,189 @@ +/** + * Work-item file operations: locating, reading, and writing the manifest and + * its sibling artifacts, plus computed status and staleness. + * + * Staleness is computed, never stored: a `valid` or `accepted` item is reported + * `stale` when the current source content hashes differently from the hash + * recorded when the item last reached `valid`/`accepted`. + */ + +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { usageError } from './errors.js'; +import { hashContent } from './hash.js'; +import { type EarsyntaxConfig, resolveInput, toRelative } from './project.js'; +import type { SourceKind, WorkManifest, WorkStatus, WorkSummary } from './facade-types.js'; + +/** Absolute paths for a work item's directory and artifacts. */ +export interface WorkPaths { + dir: string; + manifest: string; + requirements: string; + questions: string; + traceability: string; + validationJson: string; + validationMarkdown: string; +} + +/** Compute the absolute artifact paths for a work item slug. */ +export function workPaths(root: string, config: EarsyntaxConfig, slug: string): WorkPaths { + const dir = resolve(root, config.workDir, slug); + return { + dir, + manifest: resolve(dir, 'manifest.json'), + requirements: resolve(dir, 'requirements.ears'), + questions: resolve(dir, 'questions.md'), + traceability: resolve(dir, 'traceability.json'), + validationJson: resolve(dir, 'validation.json'), + validationMarkdown: resolve(dir, 'validation.md'), + }; +} + +/** Read and parse a manifest, or return undefined when the item does not exist. */ +export function readManifest(paths: WorkPaths): WorkManifest | undefined { + if (!existsSync(paths.manifest)) { + return undefined; + } + const raw = readFileSync(paths.manifest, 'utf8'); + return JSON.parse(raw) as WorkManifest; +} + +/** Resolve a work item's paths and manifest, throwing a usage error when unknown. */ +export function requireManifest( + root: string, + config: EarsyntaxConfig, + slug: string, +): { paths: WorkPaths; manifest: WorkManifest } { + const paths = workPaths(root, config, slug); + const manifest = readManifest(paths); + if (manifest === undefined) { + throw usageError('work.unknown', `Unknown work item "${slug}".`); + } + return { paths, manifest }; +} + +/** Persist a manifest, bumping `updatedAt`. */ +export function writeManifest(paths: WorkPaths, manifest: WorkManifest): void { + const next = { ...manifest, updatedAt: new Date().toISOString() }; + writeFileSync(paths.manifest, `${JSON.stringify(next, null, 2)}\n`); +} + +/** List every work-item slug under the configured work directory, sorted. */ +export function listSlugs(root: string, config: EarsyntaxConfig): string[] { + const base = resolve(root, config.workDir); + if (!existsSync(base)) { + return []; + } + return readdirSync(base) + .filter((name) => { + const entry = resolve(base, name); + return statSync(entry).isDirectory() && existsSync(resolve(entry, 'manifest.json')); + }) + .sort((a, b) => a.localeCompare(b)); +} + +/** Classify a source file by extension for the manifest `source.kind`. */ +export function sourceKind(path: string): SourceKind { + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) { + return 'markdown'; + } + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) { + return 'yaml'; + } + if (lower.endsWith('.json')) { + return 'json'; + } + return 'text'; +} + +/** + * The hash of the current source content on disk, or undefined when the source + * is absent or unreadable. `sourceInput` is the manifest-stored path resolved + * against the root. + */ +export function currentSourceHash(root: string, sourcePath: string | undefined): string | undefined { + if (sourcePath === undefined) { + return undefined; + } + const abs = resolveInput(root, sourcePath); + try { + return hashContent(readFileSync(abs, 'utf8')); + } catch { + return undefined; + } +} + +/** + * Whether a work item is stale: its recorded source hash differs from the + * source content on disk now. Only `valid` and `accepted` items can be stale. + */ +export function isStale(root: string, manifest: WorkManifest): boolean { + if (manifest.status !== 'valid' && manifest.status !== 'accepted') { + return false; + } + const recorded = + manifest.status === 'accepted' ? manifest.accepted?.sourceHash : manifest.source?.hash; + if (recorded === undefined) { + return false; + } + const current = currentSourceHash(root, manifest.source?.path); + if (current === undefined) { + return false; + } + return current !== recorded; +} + +/** The status to report to callers, applying computed staleness on top of the stored status. */ +export function reportedStatus(root: string, manifest: WorkManifest): WorkStatus { + return isStale(root, manifest) ? 'stale' : manifest.status; +} + +/** Build the compact {@link WorkSummary} used by instructions, list, and show. */ +export function toWorkSummary(root: string, manifest: WorkManifest): WorkSummary { + return { + id: manifest.id, + status: reportedStatus(root, manifest), + ...(manifest.source?.path !== undefined ? { source: manifest.source.path } : {}), + output: manifest.output.path, + questions: manifest.artifacts.questions, + traceability: manifest.artifacts.traceability, + }; +} + +/** The absolute output `.ears` path for a manifest, resolved against the root. */ +export function outputAbs(root: string, manifest: WorkManifest): string { + return resolveInput(root, manifest.output.path); +} + +/** The hash of the current `.ears` output on disk, or undefined when absent. */ +export function currentOutputHash(root: string, manifest: WorkManifest): string | undefined { + const abs = outputAbs(root, manifest); + try { + return hashContent(readFileSync(abs, 'utf8')); + } catch { + return undefined; + } +} + +/** Whether the `.ears` output has content (drives scaffolded -> drafted). */ +export function outputHasContent(root: string, manifest: WorkManifest): boolean { + const abs = outputAbs(root, manifest); + try { + return readFileSync(abs, 'utf8').trim() !== ''; + } catch { + return false; + } +} + +/** Render a manifest's path fields relative to the root for JSON output. */ +export function relPaths(root: string, paths: WorkPaths): Record { + return { + manifest: toRelative(root, paths.manifest), + requirements: toRelative(root, paths.requirements), + questions: toRelative(root, paths.questions), + traceability: toRelative(root, paths.traceability), + validationJson: toRelative(root, paths.validationJson), + validationMarkdown: toRelative(root, paths.validationMarkdown), + }; +} \ No newline at end of file diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 0000000..c155d61 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "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..8ae3f1c --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,41 @@ +{ + "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" + }, + "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..26d795d --- /dev/null +++ b/packages/core/src/catalog.test.ts @@ -0,0 +1,372 @@ +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: ambiguous', () => { + it('reports all candidates when more than one entry matches', () => { + 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).toBeUndefined(); + expect(term.ambiguous).toEqual([ + { group: 'events', id: 'EV-A', name: 'timeout' }, + { group: 'states', id: 'ST-B', name: 'session ended' }, + ]); + 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([]); + }); +}); + +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('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..01214bb --- /dev/null +++ b/packages/core/src/catalog.ts @@ -0,0 +1,652 @@ +/** + * 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 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)); + const matches: MatchCandidate[] = []; + for (const group of groups) { + for (const entry of group.entries) { + if (normalizeKey(entry.name) === key) { + matches.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) { + matches.push({ + ref: { group: group.group, id: entry.id, name: entry.name }, + role: group.role, + viaAlias: true, + }); + break; + } + } + } + } + return dedupeMatches(matches); +} + +/** 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]; + term.role = match.role; + 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; +} + +/** Stable sort of diagnostics by span start, then code, then message, then severity. */ +function sortDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { + const out = [...diagnostics]; + 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.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 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, + 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..8f36572 --- /dev/null +++ b/packages/core/src/diagnostics.test.ts @@ -0,0 +1,421 @@ +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', +] 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 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('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 and system catalog codes', () => { + const expected = [...STRUCTURAL_CODES, ...SYSTEM_CATALOG_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..a904cfc --- /dev/null +++ b/packages/core/src/diagnostics.ts @@ -0,0 +1,374 @@ +/** + * 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', +]); + +/** + * 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.', +} 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..7294d2d --- /dev/null +++ b/packages/core/src/expression-parser.test.ts @@ -0,0 +1,225 @@ +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); + }); +}); diff --git a/packages/core/src/expression-parser.ts b/packages/core/src/expression-parser.ts new file mode 100644 index 0000000..9b31ea7 --- /dev/null +++ b/packages/core/src/expression-parser.ts @@ -0,0 +1,417 @@ +/** + * 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) }; +} + +class ExprParser { + pos = 0; + readonly findings: ExprFinding[] = []; + hasAnd = false; + hasOr = false; + hasGroup = false; + /** 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 { + if (this.match(TokenKind.Not)) { + const inner = this.parseUnary(); + if (inner === null) { + this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); + const dangling: FreeTextExpr = { kind: 'free-text', text: 'not' }; + return dangling; + } + const notExpr: NotExpr = { kind: 'not', item: inner, span: inner.span }; + return notExpr; + } + return this.parsePrimary(); + } + + 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; + } + + let inner = this.parseExpr(); + if (!this.match(TokenKind.RParen)) { + this.addFinding('expr.unbalanced_parentheses', this.currentSpan()); + } + if (inner === null) { + const span = this.currentSpan(); + this.addFinding('expr.empty_subexpression', span); + const empty: FreeTextExpr = { kind: 'free-text', text: '', span }; + inner = empty; + } + const group: GroupExpr = { kind: 'group', item: inner, span: inner.span }; + 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 }; + } +} + +function mergeSpan(items: ClauseExpr[]): Span | undefined { + const starts: number[] = []; + const ends: number[] = []; + for (const item of items) { + if (item.span !== undefined) { + starts.push(item.span.start); + ends.push(item.span.end); + } + } + if (starts.length === 0) { + return undefined; + } + return { start: Math.min(...starts), end: Math.max(...ends) }; +} + +/** + * 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/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..9dd91e2 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,16 @@ +/** + * `@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 } from './lint.js'; diff --git a/packages/core/src/lint.test.ts b/packages/core/src/lint.test.ts new file mode 100644 index 0000000..c681be8 --- /dev/null +++ b/packages/core/src/lint.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; + +import { lintCatalogCoverage, lintEars, lintEarsBatch, parseEars } from './index.js'; +import { DEFAULT_VAGUE_TERMS, 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], + }); + }); + + 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']); + }); +}); diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts new file mode 100644 index 0000000..46cc985 --- /dev/null +++ b/packages/core/src/lint.ts @@ -0,0 +1,401 @@ +/** + * 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 }); + 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); + const ast: EarsAst = { ...resolvedAst, responses }; + + 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; +} + +/** 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..430543a --- /dev/null +++ b/packages/core/src/options.ts @@ -0,0 +1,64 @@ +/** + * 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 { 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 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[]; +} + +/** + * 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, + }; +} diff --git a/packages/core/src/shell-parser.ts b/packages/core/src/shell-parser.ts new file mode 100644 index 0000000..5982e50 --- /dev/null +++ b/packages/core/src/shell-parser.ts @@ -0,0 +1,519 @@ +/** + * 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 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; +} + +/** + * 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); + return parser.run(); +} + +class ShellParser { + private readonly text: string; + private readonly commaAsAnd: boolean; + private readonly findings: ShellFinding[] = []; + private readonly clauses: ClauseSlot[] = []; + private pos = 0; + + constructor(input: string, commaAsAnd: boolean) { + this.text = input; + this.commaAsAnd = commaAsAnd; + } + + run(): ShellParseResult { + const firstNonWs = skipWS(this.text, 0); + if (firstNonWs >= this.text.length) { + this.add('ears.no_match'); + return { findings: this.findings }; + } + + const shallCount = countShall(this.text); + if (shallCount === 0) { + this.add('ears.missing_shall'); + } else if (shallCount > 1) { + this.add('ears.multiple_shall'); + } + + 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.consumeKeyword('while'); + this.pushClause(this.scanCommaClause('while')); + } else if (this.peekKeyword('when')) { + this.consumeKeyword('when'); + this.pushClause(this.scanCommaClause('when')); + } else if (this.peekKeyword('where')) { + this.consumeKeyword('where'); + this.pushClause(this.scanCommaClause('where')); + } else if (this.peekKeyword('if')) { + this.consumeKeyword('if'); + const ifClause = this.scanIfClause(); + if (!ifClause) { + return undefined; + } + this.pushClause(ifClause); + } else { + break; + } + } + + // Mandatory tail: the shall . + if (!this.consumeKeyword('the')) { + this.add(this.clauses.length === 0 ? 'ears.no_match' : 'ears.missing_system'); + return undefined; + } + + const systemStart = this.pos; + const shallIdx = findWordFrom(this.text, this.pos, 'shall'); + if (shallIdx < 0) { + this.add('ears.missing_shall'); + return undefined; + } + + 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(); + if (responseRaw.endsWith('.')) { + responseRaw = responseRaw.slice(0, -1).trimEnd(); + } + 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(); + + return { + pattern: classifyPattern(this.clauses), + ...buildClauseFields(this.clauses), + system: { raw: systemRaw, role: 'system' } satisfies TermMatch, + responses: [responseRaw], + raw: this.text, + }; + } + + /** Extract a comma-delimited clause body, tracking parenthesis depth. */ + private scanCommaClause(kind: ClauseKind): ClauseSlot { + const start = this.pos; + const { end, next } = scanUntilClauseBoundary(this.text, this.pos, this.commaAsAnd); + 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); + } + + 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; + } +} + +/** 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)); +} + +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 } { + 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) }; + } + } + return { end: text.length, next: text.length }; +} + +/** 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..62b997a --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,436 @@ +/** + * 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'; + +/** + * 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; +} + +/** + * 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[]; +} + +/** + * 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..6ab5efd --- /dev/null +++ b/packages/core/test/fixtures.test.ts @@ -0,0 +1,107 @@ +/** + * 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, + 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 }; + }); +} + +function runFixture(loaded: LoadedFixture): void { + const { fixture } = loaded; + const { expected } = fixture; + const result: LintResult = lintEars(fixture.text, fixture.catalog, fixture.options); + + 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/shell-parser.test.ts b/packages/core/test/shell-parser.test.ts new file mode 100644 index 0000000..1d26104 --- /dev/null +++ b/packages/core/test/shell-parser.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { parseShell, type ShellFinding } from '../src/shell-parser.js'; +import type { AndExpr, DiagnosticCode, FreeTextExpr } from '../src/types.js'; + +function codes(findings: ShellFinding[]): DiagnosticCode[] { + return findings.map((f) => f.code); +} + +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', () => { + const { ast, findings } = parseShell('WHILE the door is open, THE system SHALL respond.'); + expect(findings).toEqual([]); + expect(ast!.pattern).toBe('state-driven'); + expect(ast!.system.raw).toBe('system'); + expect(ast!.responses).toEqual(['respond']); + }); +}); + +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..32f43af --- /dev/null +++ b/packages/extract/package.json @@ -0,0 +1,46 @@ +{ + "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" + }, + "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/dispatch.test.ts b/packages/extract/src/dispatch.test.ts new file mode 100644 index 0000000..267c959 --- /dev/null +++ b/packages/extract/src/dispatch.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { extractFromContent, extractFromFile } from './dispatch.js'; + +describe('extractFromContent', () => { + it('routes .ears content to the ears extractor', () => { + const { items } = extractFromContent('REQ-001: The billing service shall verify signatures.', 'requirements.ears'); + expect(items).toEqual([{ id: 'REQ-001', text: 'The billing service shall verify signatures.', source: { file: 'requirements.ears', line: 1 } }]); + }); + + it('routes .md and .markdown content to the markdown extractor', () => { + const bullet = '- REQ-001: The billing service shall verify signatures.'; + expect(extractFromContent(bullet, 'reqs.md').items[0].id).toBe('REQ-001'); + expect(extractFromContent(bullet, 'reqs.markdown').items[0].id).toBe('REQ-001'); + }); + + it('routes .yaml and .yml content to the yaml extractor', () => { + const yaml = 'requirements:\n - id: REQ-001\n text: The billing service shall verify signatures.\n'; + expect(extractFromContent(yaml, 'reqs.yaml').items[0].id).toBe('REQ-001'); + expect(extractFromContent(yaml, 'reqs.yml').items[0].id).toBe('REQ-001'); + }); + + it('routes .json content to the json extractor', () => { + const json = '{ "requirements": [ { "id": "REQ-001", "text": "The billing service shall verify signatures." } ] }'; + expect(extractFromContent(json, 'reqs.json').items[0].id).toBe('REQ-001'); + }); + + it('reports an error for an unsupported extension', () => { + const { items, errors } = extractFromContent('anything', 'notes.txt'); + expect(items).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Unsupported file extension'); + expect(errors[0].file).toBe('notes.txt'); + }); +}); + +describe('extractFromFile', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'earsyntax-extract-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads a file from disk and extracts by extension', () => { + const path = join(dir, 'requirements.ears'); + writeFileSync(path, 'REQ-001: The billing service shall verify signatures.\n', 'utf8'); + + const { items, errors } = extractFromFile(path); + + expect(errors).toEqual([]); + expect(items).toEqual([{ id: 'REQ-001', text: 'The billing service shall verify signatures.', source: { file: path, line: 1 } }]); + }); + + it('reports a read error for a missing file rather than throwing', () => { + const path = join(dir, 'does-not-exist.ears'); + + const { items, errors } = extractFromFile(path); + + expect(items).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Could not read file'); + expect(errors[0].file).toBe(path); + }); +}); diff --git a/packages/extract/src/dispatch.ts b/packages/extract/src/dispatch.ts new file mode 100644 index 0000000..bb41af6 --- /dev/null +++ b/packages/extract/src/dispatch.ts @@ -0,0 +1,71 @@ +/** + * Format dispatch and the single disk-reading wrapper. + * + * {@link extractFromContent} routes a string to the right parser by the file + * name's extension. {@link extractFromFile} is the only function in the package + * that touches the file system; every parser stays pure string-in. + */ + +import { readFileSync } from 'node:fs'; +import { extname } from 'node:path'; +import type { ExtractResult } from './types.js'; +import { extractEars } from './ears.js'; +import { extractMarkdown } from './markdown.js'; +import { extractYaml } from './yaml.js'; +import { extractJson } from './json.js'; + +/** + * Extract requirements from raw content, choosing the parser by the extension + * of `filename`. The name is also recorded as each item's source file. + * + * Supported extensions: `.ears`, `.md`, `.markdown`, `.yaml`, `.yml`, `.json`. + * An unsupported extension yields a single {@link ExtractError} and no items. + * + * @param content Raw file contents. + * @param filename File name or path used to pick the parser and label sources. + */ +export function extractFromContent(content: string, filename: string): ExtractResult { + const extension = extname(filename).toLowerCase(); + switch (extension) { + case '.ears': + return extractEars(content, filename); + case '.md': + case '.markdown': + return extractMarkdown(content, filename); + case '.yaml': + case '.yml': + return extractYaml(content, filename); + case '.json': + return extractJson(content, filename); + default: + return { + items: [], + errors: [{ message: `Unsupported file extension: "${extension || filename}".`, file: filename }], + }; + } +} + +/** + * Read a file from disk and extract its requirements. This is the only function + * that performs I/O. A read failure is reported as an {@link ExtractError} + * rather than thrown. + * + * @param filePath Path to the file to read and extract. + */ +export function extractFromFile(filePath: string): ExtractResult { + let content: string; + try { + content = readFileSync(filePath, 'utf8'); + } catch (error) { + return { + items: [], + errors: [ + { + message: `Could not read file: ${error instanceof Error ? error.message : String(error)}`, + file: filePath, + }, + ], + }; + } + return extractFromContent(content, filePath); +} diff --git a/packages/extract/src/ears.test.ts b/packages/extract/src/ears.test.ts new file mode 100644 index 0000000..2bd1017 --- /dev/null +++ b/packages/extract/src/ears.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { extractEars } from './ears.js'; + +describe('extractEars', () => { + it('extracts one requirement per non-empty line with ids and line numbers', () => { + const content = [ + '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.', + ].join('\n'); + + const { items, errors } = extractEars(content, 'requirements.ears'); + + 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.ears', line: 1 }, + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + source: { file: 'requirements.ears', line: 2 }, + }, + ]); + }); + + it('omits id when no ID prefix is present', () => { + const content = 'When a payment webhook is received, the billing service shall verify the HMAC signature.'; + + const { items } = extractEars(content); + + expect(items).toHaveLength(1); + expect(items[0].id).toBeUndefined(); + expect(items[0].text).toBe(content); + expect(items[0].source).toEqual({ line: 1 }); + }); + + it('does not treat a colon inside requirement prose as an id prefix', () => { + const content = 'When the report is ready, the billing service shall emit: a summary and a total.'; + + const { items } = extractEars(content); + + expect(items[0].id).toBeUndefined(); + expect(items[0].text).toBe(content); + }); + + it('ignores comment lines and blank lines while keeping line numbers accurate', () => { + const content = ['# billing requirements', '', 'REQ-001: The billing service shall retain receipts.', ' ', '# trailing note'].join( + '\n', + ); + + const { items } = extractEars(content); + + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'The billing service shall retain receipts.', + source: { line: 3 }, + }, + ]); + }); + + it('parses the metadata prefix form and uses the declared source reference', () => { + const content = + 'REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature.'; + + const { items, errors } = extractEars(content, 'requirements.ears'); + + 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: 'specs/checkout.md', line: 14 }, + }, + ]); + }); + + it('parses the metadata prefix source range, using the start line', () => { + const content = 'REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook.'; + + const { items } = extractEars(content, 'requirements.ears'); + + expect(items[0].id).toBe('REQ-002'); + expect(items[0].source).toEqual({ file: 'specs/checkout.md', line: 12 }); + expect(items[0].text).toBe('If the HMAC signature is invalid, then the billing service shall reject the webhook.'); + }); + + it('strips a malformed [source:] segment, keeps the id, and falls back to the physical location', () => { + const content = 'REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; + + const { items, errors } = extractEars(content, 'requirements.ears'); + + expect(errors).toEqual([]); + expect(items).toEqual([ + { + id: 'REQ-003', + text: 'The billing service shall retain receipts.', + source: { file: 'requirements.ears', line: 1 }, + }, + ]); + }); + + it('returns no items for an empty file', () => { + expect(extractEars('')).toEqual({ items: [], errors: [] }); + expect(extractEars('\n\n \n')).toEqual({ items: [], errors: [] }); + }); +}); diff --git a/packages/extract/src/ears.ts b/packages/extract/src/ears.ts new file mode 100644 index 0000000..5726adf --- /dev/null +++ b/packages/extract/src/ears.ts @@ -0,0 +1,46 @@ +/** + * `.ears` extractor. + * + * Format: one requirement per non-empty line. A line may carry an optional + * metadata prefix: a bare `REQ-001:` id, or `REQ-001 [source: path:line]:` / + * `REQ-001 [source: path:line-line]:` with a declared source reference. When a + * `[source: ...]` reference is present it becomes the item's source location + * (overriding the physical `.ears` file and line); otherwise the source is the + * physical file and 1-based line. Lines whose first non-whitespace character is + * `#` are comments and are ignored. Blank lines are ignored. Order is document + * order. + */ + +import type { RequirementInput } from '@earsyntax/core'; +import type { ExtractResult } from './types.js'; +import { splitId } from './internal.js'; + +/** + * Extract requirements from `.ears` content. + * + * @param content Raw file contents. + * @param file Optional source path, echoed onto each item's source location. + */ +export function extractEars(content: string, file?: string): ExtractResult { + const items: RequirementInput[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + continue; + } + const line = i + 1; + const { id, ref, text } = splitId(trimmed); + const source = + ref === undefined ? { ...(file === undefined ? {} : { file }), line } : { file: ref.file, line: ref.line }; + items.push({ + ...(id === undefined ? {} : { id }), + text, + source, + }); + } + + return { items, errors: [] }; +} diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts new file mode 100644 index 0000000..1857228 --- /dev/null +++ b/packages/extract/src/index.ts @@ -0,0 +1,18 @@ +/** + * `@earsyntax/extract` public API surface. + * + * Turns files people author (`.ears`, Markdown, YAML, JSON) into the frozen + * {@link RequirementInput} shape from `@earsyntax/core`. Every parser is a pure + * function of its input string; only {@link extractFromFile} 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'; + +export { extractEars } from './ears.js'; +export { extractMarkdown } from './markdown.js'; +export { extractYaml } from './yaml.js'; +export { extractJson } from './json.js'; +export { extractFromContent, extractFromFile } from './dispatch.js'; diff --git a/packages/extract/src/internal.ts b/packages/extract/src/internal.ts new file mode 100644 index 0000000..7b09a9f --- /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 (a letter followed by letters, digits, underscores, + * dots, or hyphens). 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-Za-z][A-Za-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 }; +} + +/** + * Find the 1-based line number of the first occurrence of `needle` in + * `content`. Returns `undefined` when the needle is absent or empty. + * + * Used to give structured formats (YAML, JSON) a best-effort source line by + * locating an item's id or text in the raw document. + */ +export function findLine(content: string, needle: string | undefined): number | undefined { + if (!needle) { + return undefined; + } + const index = content.indexOf(needle); + if (index === -1) { + return undefined; + } + let line = 1; + for (let i = 0; i < index; i++) { + if (content[i] === '\n') { + line++; + } + } + return line; +} diff --git a/packages/extract/src/json.test.ts b/packages/extract/src/json.test.ts new file mode 100644 index 0000000..4e5e1e7 --- /dev/null +++ b/packages/extract/src/json.test.ts @@ -0,0 +1,67 @@ +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..d0edc6c --- /dev/null +++ b/packages/extract/src/json.ts @@ -0,0 +1,89 @@ +/** + * 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 { findLine } from './internal.js'; + +/** + * Extract requirements from JSON content. + * + * @param content Raw file contents. + * @param file Optional source path, echoed onto each item and error. + */ +export function extractJson(content: string, file?: string): ExtractResult { + 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 }; + } + + 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 = findLine(content, id) ?? findLine(content, text); + items.push({ + ...(id === undefined ? {} : { id }), + text: text.trim(), + source: { + ...(file === undefined ? {} : { file }), + ...(line === undefined ? {} : { line }), + }, + }); + } + + return { items, errors }; +} + +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/markdown.test.ts b/packages/extract/src/markdown.test.ts new file mode 100644 index 0000000..75eb8c6 --- /dev/null +++ b/packages/extract/src/markdown.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { extractMarkdown } from './markdown.js'; + +describe('extractMarkdown', () => { + it('extracts bullet lists with and without ids', () => { + const content = [ + '# Billing requirements', + '', + 'Some prose describing the webhook flow that must be ignored.', + '', + '- REQ-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.', + '* REQ-003: The billing service shall retain receipts for seven years.', + ].join('\n'); + + const { items, errors } = extractMarkdown(content, 'requirements.md'); + + 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.md', line: 5 }, + }, + { + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + source: { file: 'requirements.md', line: 6 }, + }, + { + id: 'REQ-003', + text: 'The billing service shall retain receipts for seven years.', + source: { file: 'requirements.md', line: 7 }, + }, + ]); + }); + + it('extracts numbered lists', () => { + const content = ['1. REQ-001: The billing service shall verify the HMAC signature.', '2) The billing service shall reject invalid webhooks.'].join( + '\n', + ); + + const { items } = extractMarkdown(content); + + expect(items).toEqual([ + { id: 'REQ-001', text: 'The billing service shall verify the HMAC signature.', source: { line: 1 } }, + { text: 'The billing service shall reject invalid webhooks.', source: { line: 2 } }, + ]); + }); + + it('extracts an ID/Requirement table, taking ids from the id column', () => { + const content = [ + '| ID | Requirement |', + '| ------- | ---------------------------------------------------------------------------------------- |', + '| 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. |', + ].join('\n'); + + const { items } = extractMarkdown(content, 'reqs.md'); + + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + source: { file: 'reqs.md', line: 3 }, + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + source: { file: 'reqs.md', line: 4 }, + }, + ]); + }); + + it('extracts a single-column requirement table and lifts an inline id prefix', () => { + const content = [ + '| Requirement |', + '| --- |', + '| REQ-001: The billing service shall verify the HMAC signature. |', + '| The billing service shall reject invalid webhooks. |', + ].join('\n'); + + const { items } = extractMarkdown(content); + + expect(items).toEqual([ + { id: 'REQ-001', text: 'The billing service shall verify the HMAC signature.', source: { line: 3 } }, + { text: 'The billing service shall reject invalid webhooks.', source: { line: 4 } }, + ]); + }); + + it('ignores fenced code blocks entirely', () => { + const content = [ + '- REQ-001: The billing service shall verify the HMAC signature.', + '', + '```ears', + '- REQ-999: This bullet lives inside a code fence and must be ignored.', + '| ID | Requirement |', + '| -- | ----------- |', + '| REQ-998 | Also ignored. |', + '```', + '', + '~~~', + '- REQ-997: Ignored under a tilde fence.', + '~~~', + '', + '- REQ-002: The billing service shall reject invalid webhooks.', + ].join('\n'); + + const { items } = extractMarkdown(content); + + expect(items.map((item) => item.id)).toEqual(['REQ-001', 'REQ-002']); + expect(items[1].source).toEqual({ line: 14 }); + }); + + it('ignores prose, headings, and horizontal rules', () => { + const content = ['# Heading', '', 'Just a paragraph of prose.', '', '---', '', '- REQ-001: The billing service shall verify signatures.'].join( + '\n', + ); + + const { items } = extractMarkdown(content); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe('REQ-001'); + }); + + it('parses the metadata prefix form in bullets, using the declared source reference', () => { + const content = [ + '- 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.', + ].join('\n'); + + const { items } = extractMarkdown(content, 'requirements.md'); + + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + source: { file: 'specs/checkout.md', line: 14 }, + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + source: { file: 'specs/checkout.md', line: 12 }, + }, + ]); + }); + + it('parses the metadata prefix form inside a single-column table cell', () => { + const content = [ + '| Requirement |', + '| --- |', + '| REQ-001 [source: specs/checkout.md:14]: The billing service shall verify the HMAC signature. |', + ].join('\n'); + + const { items } = extractMarkdown(content, 'requirements.md'); + + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'The billing service shall verify the HMAC signature.', + source: { file: 'specs/checkout.md', line: 14 }, + }, + ]); + }); + + it('strips a malformed [source:] segment in a bullet and falls back to the physical location', () => { + const content = '- REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; + + const { items } = extractMarkdown(content, 'requirements.md'); + + expect(items).toEqual([ + { + id: 'REQ-003', + text: 'The billing service shall retain receipts.', + source: { file: 'requirements.md', line: 1 }, + }, + ]); + }); + + it('returns no items for an empty document', () => { + expect(extractMarkdown('')).toEqual({ items: [], errors: [] }); + }); +}); diff --git a/packages/extract/src/markdown.ts b/packages/extract/src/markdown.ts new file mode 100644 index 0000000..63ae9d3 --- /dev/null +++ b/packages/extract/src/markdown.ts @@ -0,0 +1,165 @@ +/** + * Markdown extractor. + * + * Extracts requirements from three Markdown structures: + * + * - bullet lists (`-`, `*`, `+`) + * - numbered lists (`1.`, `1)`) + * - GFM pipe tables (`| ID | Requirement |` and single-column requirement + * tables) + * + * Everything else is ignored: prose paragraphs, headings, and fenced code + * blocks (both ``` ``` ``` and `~~~`). A metadata prefix inside a bullet, list + * item, or single-column table cell (`REQ-001:` or + * `REQ-001 [source: path:line]:`) is lifted into the item's `id`, and a + * `[source: ...]` reference becomes the item's source location. Tables with an + * explicit `ID` column take the id from that column instead. + */ + +import type { RequirementInput } from '@earsyntax/core'; +import type { ExtractResult } from './types.js'; +import { splitId, type SourceRef } from './internal.js'; + +const BULLET = /^\s*[-*+]\s+(.+)$/; +const NUMBERED = /^\s*\d+[.)]\s+(.+)$/; +const SEPARATOR_CELL = /^:?-{1,}:?$/; +const REQUIREMENT_HEADERS = new Set(['requirement', 'requirements', 'text', 'statement']); + +interface TableRow { + line: number; + cells: string[]; +} + +/** + * Extract requirements from Markdown content. + * + * @param content Raw file contents. + * @param file Optional source path, echoed onto each item's source location. + */ +export function extractMarkdown(content: string, file?: string): ExtractResult { + const items: RequirementInput[] = []; + const lines = content.split('\n'); + let inFence = false; + + const push = (id: string | undefined, text: string, line: number, ref: SourceRef | undefined): void => { + const source = + ref === undefined ? { ...(file === undefined ? {} : { file }), line } : { file: ref.file, line: ref.line }; + items.push({ + ...(id === undefined || id === '' ? {} : { id }), + text, + source, + }); + }; + + let i = 0; + while (i < lines.length) { + const raw = lines[i]; + const trimmed = raw.trim(); + + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { + inFence = !inFence; + i++; + continue; + } + if (inFence || trimmed === '' || trimmed.startsWith('#')) { + i++; + continue; + } + + const bullet = BULLET.exec(raw); + if (bullet) { + const { id, ref, text } = splitId(bullet[1]); + push(id, text, i + 1, ref); + i++; + continue; + } + + const numbered = NUMBERED.exec(raw); + if (numbered) { + const { id, ref, text } = splitId(numbered[1]); + push(id, text, i + 1, ref); + i++; + continue; + } + + if (trimmed.startsWith('|')) { + const block: TableRow[] = []; + let j = i; + while (j < lines.length && lines[j].trim().startsWith('|')) { + block.push({ line: j + 1, cells: splitRow(lines[j].trim()) }); + j++; + } + extractTable(block, push); + i = j; + continue; + } + + // Prose paragraph: ignored. + i++; + } + + return { items, errors: [] }; +} + +/** + * Split a pipe-delimited table row into trimmed cells, dropping the empty + * segments produced by leading and trailing pipes. + */ +function splitRow(trimmed: string): string[] { + let body = trimmed; + if (body.startsWith('|')) { + body = body.slice(1); + } + if (body.endsWith('|')) { + body = body.slice(0, -1); + } + return body.split('|').map((cell) => cell.trim()); +} + +/** + * Turn a contiguous block of pipe rows into requirements. A valid table needs a + * separator row (`| --- |`) with a header row directly above it; anything else + * is ignored. + */ +function extractTable( + block: TableRow[], + push: (id: string | undefined, text: string, line: number, ref: SourceRef | undefined) => void, +): void { + if (block.length < 2) { + return; + } + const separatorIndex = block.findIndex( + (row) => row.cells.length > 0 && row.cells.every((cell) => SEPARATOR_CELL.test(cell)), + ); + if (separatorIndex < 1) { + return; + } + + const header = block[separatorIndex - 1].cells.map((cell) => cell.toLowerCase()); + const idColumn = header.indexOf('id'); + let requirementColumn = header.findIndex((cell) => REQUIREMENT_HEADERS.has(cell)); + if (requirementColumn === -1) { + if (header.length === 1) { + requirementColumn = 0; + } else { + requirementColumn = idColumn === header.length - 1 ? header.length - 2 : header.length - 1; + } + } + + for (const row of block.slice(separatorIndex + 1)) { + const rawText = (row.cells[requirementColumn] ?? '').trim(); + if (idColumn >= 0) { + if (rawText === '') { + continue; + } + const id = (row.cells[idColumn] ?? '').trim(); + push(id, rawText, row.line, undefined); + } else { + const { id, ref, text } = splitId(rawText); + if (text === '') { + continue; + } + push(id, text, row.line, ref); + } + } +} 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..8cd270d --- /dev/null +++ b/packages/extract/src/yaml.test.ts @@ -0,0 +1,79 @@ +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..7c9f34d --- /dev/null +++ b/packages/extract/src/yaml.ts @@ -0,0 +1,110 @@ +/** + * 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 { findLine } from './internal.js'; + +/** + * Extract requirements from YAML content. + * + * @param content Raw file contents. + * @param file Optional source path, echoed onto each item and error. + */ +export function extractYaml(content: string, file?: string): ExtractResult { + const items: RequirementInput[] = []; + const errors: ExtractError[] = []; + + let parsed: unknown; + try { + parsed = jsYaml.load(content); + } catch (error) { + if (error instanceof jsYaml.YAMLException) { + errors.push({ + message: `Malformed YAML: ${error.reason}`, + ...(file === undefined ? {} : { file }), + // `mark.line` is 0-based; present it as a 1-based line number. + line: error.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 }; + } + + 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 = findLine(content, id) ?? findLine(content, text); + items.push(buildInput(id, text.trim(), file, line)); + } + + return { items, errors }; +} + +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; +} + +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/specs/checkout.md b/specs/checkout.md new file mode 100644 index 0000000..ce1fb90 --- /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. \ No newline at end of file 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"] +} From 835efa031087aca61bd1851451a69439491c3626 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 29 Jul 2026 09:56:15 +0300 Subject: [PATCH 02/20] style: apply prettier formatting across the repo --- README.md | 38 +++- docs/agent-rules.md | 68 +++---- docs/agentic-loop.md | 50 ++--- docs/api.md | 59 ++++-- docs/authoring-ears.md | 26 ++- docs/cli.md | 65 ++++-- docs/compatibility.md | 126 ++++++------ docs/diagnostics.md | 66 +++--- docs/facade-api.md | 115 +++++------ docs/grammar.md | 128 ++++++------ docs/input-formats.md | 24 ++- docs/public-api.md | 62 +++--- docs/quickstart.md | 2 +- fixtures/demo/catalog.json | 48 ++++- fixtures/ears-lint-go-parity/A301.json | 191 +++++------------- fixtures/ears-lint-go-parity/A303.json | 191 +++++------------- fixtures/ears-lint-go-parity/I402.json | 191 +++++------------- fixtures/ears-lint-go-parity/I404.json | 191 +++++------------- fixtures/ears-lint-go-parity/I405.json | 187 +++++------------ fixtures/ears-lint-go-parity/I406.json | 187 +++++------------ fixtures/ears-lint-go-parity/I410.json | 191 +++++------------- fixtures/ears-lint-go-parity/I501.json | 191 +++++------------- fixtures/ears-lint-go-parity/I502.json | 187 +++++------------ fixtures/ears-lint-go-parity/I506.json | 191 +++++------------- fixtures/ears-lint-go-parity/K901.json | 191 +++++------------- .../ears-lint-go-parity/LIB-alias-used.json | 8 +- .../LIB-ambiguous-event.json | 8 +- .../ears-lint-go-parity/LIB-comma-as-and.json | 8 +- .../ears-lint-go-parity/LIB-complex-expr.json | 8 +- .../ears-lint-go-parity/LIB-determinism.json | 8 +- .../ears-lint-go-parity/LIB-event-driven.json | 8 +- .../LIB-guided-suspicious.json | 4 +- .../LIB-ifthen-missing-then.json | 8 +- .../ears-lint-go-parity/LIB-ifthen-valid.json | 8 +- .../LIB-multiple-trigger.json | 8 +- .../LIB-optional-feature.json | 8 +- .../ears-lint-go-parity/LIB-state-driven.json | 8 +- .../LIB-system-unresolved.json | 8 +- .../ears-lint-go-parity/LIB-ubiquitous.json | 8 +- fixtures/ears-lint-go-parity/N701.json | 187 +++++------------ fixtures/ears-lint-go-parity/S801.json | 191 +++++------------- fixtures/ears-lint-go-parity/S803.json | 191 +++++------------- fixtures/ears-lint-go-parity/V001.json | 191 +++++------------- fixtures/ears-lint-go-parity/V002.json | 191 +++++------------- fixtures/ears-lint-go-parity/V003.json | 191 +++++------------- fixtures/ears-lint-go-parity/V004.json | 191 +++++------------- fixtures/ears-lint-go-parity/V005.json | 191 +++++------------- fixtures/ears-lint-go-parity/V006.json | 191 +++++------------- fixtures/ears-lint-go-parity/V102.json | 191 +++++------------- fixtures/ears-lint-go-parity/V105.json | 191 +++++------------- fixtures/ears-lint-go-parity/V108.json | 191 +++++------------- fixtures/ears-lint-go-parity/V109.json | 191 +++++------------- fixtures/ears-lint-go-parity/V110.json | 191 +++++------------- fixtures/ears-lint-go-parity/V201.json | 191 +++++------------- fixtures/ears-lint-go-parity/V202.json | 191 +++++------------- fixtures/ears-lint-go-parity/V204.json | 191 +++++------------- fixtures/facade/README.md | 18 +- fixtures/facade/instructions-author.json | 2 +- fixtures/facade/instructions-convert.json | 2 +- fixtures/facade/instructions-review.json | 2 +- fixtures/facade/scaffolded.manifest.json | 2 +- fixtures/facade/stale.manifest.json | 2 +- fixtures/facade/status.response.json | 2 +- fixtures/facade/traceability.json | 2 +- fixtures/facade/valid.manifest.json | 2 +- fixtures/facade/validate.response.json | 2 +- .../invalid/catalog-system-ambiguous.json | 8 +- .../invalid/catalog-system-unresolved.json | 4 +- fixtures/schema.md | 48 ++--- .../valid/case-insensitive-if-then-upper.json | 4 +- .../valid/case-insensitive-when-lower.json | 4 +- .../valid/case-insensitive-when-upper.json | 4 +- .../valid/case-insensitive-while-mixed.json | 4 +- .../valid/catalog-clause-term-unresolved.json | 8 +- fixtures/valid/complex-boolean-nested.json | 4 +- fixtures/valid/complex-when-if.json | 4 +- fixtures/valid/complex-while-when.json | 4 +- fixtures/valid/complex-while-where-when.json | 4 +- fixtures/valid/event-driven-basic.json | 4 +- fixtures/valid/event-driven-refund.json | 4 +- fixtures/valid/event-driven-subscription.json | 4 +- .../valid/expr-and-keyword-comma-off.json | 4 +- fixtures/valid/expr-and.json | 4 +- fixtures/valid/expr-comma-as-and.json | 4 +- fixtures/valid/expr-group.json | 4 +- fixtures/valid/expr-not.json | 4 +- fixtures/valid/expr-or.json | 4 +- fixtures/valid/expr-precedence-warning.json | 4 +- .../guided-catalog-system-unresolved.json | 8 +- .../valid/guided-empty-subexpression.json | 4 +- fixtures/valid/guided-if-without-then.json | 4 +- .../valid/guided-invalid-clause-order.json | 4 +- fixtures/valid/guided-missing-shall.json | 4 +- .../valid/guided-unbalanced-parentheses.json | 4 +- fixtures/valid/lint-alias-used.json | 8 +- fixtures/valid/lint-multiple-responses.json | 5 +- fixtures/valid/lint-unparsed-tail.json | 4 +- fixtures/valid/lint-vague-response.json | 4 +- fixtures/valid/optional-feature-basic.json | 4 +- .../valid/optional-feature-multicurrency.json | 4 +- fixtures/valid/optional-feature-tax.json | 4 +- fixtures/valid/state-driven-basic.json | 4 +- fixtures/valid/state-driven-maintenance.json | 4 +- .../valid/state-driven-provider-degraded.json | 4 +- fixtures/valid/ubiquitous-audit-log.json | 4 +- fixtures/valid/ubiquitous-basic.json | 4 +- fixtures/valid/ubiquitous-retry-limit.json | 4 +- fixtures/valid/unwanted-behaviour-basic.json | 4 +- .../valid/unwanted-behaviour-declined.json | 4 +- fixtures/valid/unwanted-behaviour-stale.json | 4 +- lerna.json | 14 +- package.json | 2 +- packages/cli-contract/src/json-report.test.ts | 5 +- packages/cli-contract/tsconfig.build.json | 8 +- packages/cli/bin/run.js | 2 +- packages/cli/src/args.ts | 2 +- packages/cli/src/cli.test.ts | 12 +- packages/cli/src/cli.ts | 14 +- packages/cli/src/color.ts | 2 +- packages/cli/src/commands/accept.ts | 7 +- packages/cli/src/commands/doctor.ts | 8 +- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/commands/instructions.ts | 7 +- packages/cli/src/commands/list.ts | 2 +- packages/cli/src/commands/new.ts | 7 +- packages/cli/src/commands/show.ts | 2 +- packages/cli/src/commands/status.ts | 15 +- packages/cli/src/commands/validate.ts | 39 +++- packages/cli/src/commands/version.ts | 7 +- packages/cli/src/context.ts | 2 +- packages/cli/src/errors.ts | 14 +- packages/cli/src/facade-types.ts | 10 +- packages/cli/src/hash.ts | 2 +- packages/cli/src/index.ts | 2 +- packages/cli/src/mode.ts | 2 +- packages/cli/src/next-actions.ts | 2 +- packages/cli/src/project.ts | 10 +- packages/cli/src/response.ts | 8 +- packages/cli/src/rules.ts | 2 +- packages/cli/src/tool-wrappers.ts | 2 +- packages/cli/src/version.ts | 2 +- packages/cli/src/workspace.ts | 7 +- packages/cli/tsconfig.build.json | 8 +- packages/core/src/catalog.test.ts | 38 +++- packages/core/src/catalog.ts | 13 +- packages/core/src/diagnostics.test.ts | 6 +- packages/core/src/expression-parser.test.ts | 5 +- packages/core/src/expression-parser.ts | 31 ++- packages/core/src/lint.test.ts | 4 +- packages/core/src/lint.ts | 14 +- packages/core/src/shell-parser.ts | 16 +- packages/core/src/types.ts | 9 +- packages/core/test/shell-parser.test.ts | 20 +- packages/extract/src/dispatch.test.ts | 27 ++- packages/extract/src/dispatch.ts | 4 +- packages/extract/src/ears.test.ts | 23 ++- packages/extract/src/ears.ts | 4 +- packages/extract/src/json.test.ts | 20 +- packages/extract/src/markdown.test.ts | 34 +++- packages/extract/src/markdown.ts | 11 +- packages/extract/src/yaml.test.ts | 19 +- specs/checkout.md | 2 +- 162 files changed, 2263 insertions(+), 4857 deletions(-) diff --git a/README.md b/README.md index 0fd1f95..78ad78c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ const result = lintEars( { "valid": true, "references": [ - { "clause": "trigger", "text": "a payment webhook is received", "role": "event", "span": { "start": 5, "end": 34 } }, + { + "clause": "trigger", + "text": "a payment webhook is received", + "role": "event", + "span": { "start": 5, "end": 34 } + }, { "clause": "system", "text": "billing service", "role": "system" } ], "diagnostics": [], @@ -34,7 +39,12 @@ const result = lintEars( "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 } } + "trigger": { + "kind": "term", + "text": "a payment webhook is received", + "term": { "raw": "a payment webhook is received", "role": "event" }, + "span": { "start": 5, "end": 34 } + } } } ``` @@ -58,12 +68,12 @@ This toolkit parses those templates and reports where a requirement departs from ## Packages -| Package | Purpose | -| ------------------------- | ------------------------------------------------------------------------------------------------ | -| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. No runtime dependencies. | -| `@earsyntax/extract` | Extract EARS requirements from `.ears`, Markdown, YAML, and JSON files. Depends on core types. | -| `@earsyntax/cli-contract` | Shared report output contracts: the JSON report, SARIF 2.1.0, the pretty model, and exit codes. | -| `@earsyntax/cli` | The `earsyntax` command-line facade for humans, scripts, and coding agents. | +| Package | Purpose | +| ------------------------- | ----------------------------------------------------------------------------------------------- | +| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. No runtime dependencies. | +| `@earsyntax/extract` | Extract EARS requirements from `.ears`, Markdown, YAML, and JSON files. Depends on core types. | +| `@earsyntax/cli-contract` | Shared report output contracts: the JSON report, SARIF 2.1.0, the pretty model, and exit codes. | +| `@earsyntax/cli` | The `earsyntax` command-line facade for humans, scripts, and coding agents. | ## Install @@ -86,8 +96,14 @@ import { lintEars, lintEarsBatch } from '@earsyntax/core'; const one = lintEars('The billing service shall verify the HMAC signature.'); const many = 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.' }, + { + 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.', + }, ]); ``` @@ -123,4 +139,4 @@ v0.1 is a deterministic parser, linter, and facade. It is intentionally narrow. - SARIF output is not exposed by the CLI yet. The SARIF 2.1.0 builder ships in `@earsyntax/cli-contract`, but `validate` emits pretty and JSON only. `version --features` reports `"sarif": false`. - The `export` command is planned, not implemented in v0.1. -The full set of non-goals and the approved differences from the `ears-lint-go` reference are recorded in [docs/compatibility.md](docs/compatibility.md). \ No newline at end of file +The full set of non-goals and the approved differences from the `ears-lint-go` reference are recorded in [docs/compatibility.md](docs/compatibility.md). diff --git a/docs/agent-rules.md b/docs/agent-rules.md index 242d3ca..ab9d776 100644 --- a/docs/agent-rules.md +++ b/docs/agent-rules.md @@ -174,52 +174,52 @@ The table below maps every diagnostic code in the registry (see `docs/diagnostic ### `ears.*` shell structure -| Code | What to change | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Code | What to change | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `ears.no_match` | The line is not an EARS requirement. Rewrite it into one of the allowed templates, or move the text out of the `.ears` file. | -| `ears.invalid_clause_order` | Reorder shell clauses to `While -> Where -> When -> If -> the shall `. | -| `ears.missing_system` | Insert the system name before `shall`: `the shall ...`. | -| `ears.missing_shall` | Add a single `shall` response boundary. A requirement must state one obligation with `shall`. | -| `ears.multiple_shall` | Split into separate requirements, one `shall` each, following the compound-splitting rule. | -| `ears.invalid_if_then_form` | Add the missing `then`: `If , then the shall .` | -| `ears.empty_clause` | Fill the empty `While`/`Where`/`When`/`If` clause body, or remove the clause if it was accidental. | -| `ears.empty_response` | Add the response after `shall`, or raise a question if the source does not state one. | +| `ears.invalid_clause_order` | Reorder shell clauses to `While -> Where -> When -> If -> the shall `. | +| `ears.missing_system` | Insert the system name before `shall`: `the shall ...`. | +| `ears.missing_shall` | Add a single `shall` response boundary. A requirement must state one obligation with `shall`. | +| `ears.multiple_shall` | Split into separate requirements, one `shall` each, following the compound-splitting rule. | +| `ears.invalid_if_then_form` | Add the missing `then`: `If , then the shall .` | +| `ears.empty_clause` | Fill the empty `While`/`Where`/`When`/`If` clause body, or remove the clause if it was accidental. | +| `ears.empty_response` | Add the response after `shall`, or raise a question if the source does not state one. | ### `expr.*` clause expressions -| Code | What to change | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `expr.unbalanced_parentheses` | Balance the parentheses in the clause expression. | -| `expr.invalid_operator_sequence` | Fix the malformed operator run (for example `A or or B`, a trailing `and`, a leading `or`). | -| `expr.empty_subexpression` | Remove the empty group or supply the missing operand (for example `A and ()`). | +| Code | What to change | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `expr.unbalanced_parentheses` | Balance the parentheses in the clause expression. | +| `expr.invalid_operator_sequence` | Fix the malformed operator run (for example `A or or B`, a trailing `and`, a leading `or`). | +| `expr.empty_subexpression` | Remove the empty group or supply the missing operand (for example `A and ()`). | | `expr.operator_precedence_warning` | Warning. Add parentheses to a mixed `and`/`or` expression to make grouping explicit if intent is unclear. | -| `expr.unknown_term` | Warning. Align the clause term with a catalog entry, or add the term to the catalog if it is correct. | -| `expr.ambiguous_term` | Warning. Disambiguate the term so it matches one catalog entry, or use the canonical name. | -| `expr.mixed_unresolved_terms` | Warning. One clause mixes resolved and unresolved terms; align the unresolved term with the catalog. | +| `expr.unknown_term` | Warning. Align the clause term with a catalog entry, or add the term to the catalog if it is correct. | +| `expr.ambiguous_term` | Warning. Disambiguate the term so it matches one catalog entry, or use the canonical name. | +| `expr.mixed_unresolved_terms` | Warning. One clause mixes resolved and unresolved terms; align the unresolved term with the catalog. | ### `catalog.*` term matching -| Code | What to change | -| ---------------------------- | ------------------------------------------------------------------------------------------------------- | -| `catalog.system_unresolved` | Error in strict mode. Use the catalog's canonical system name, or confirm the system with the catalog owner. | -| `catalog.system_ambiguous` | Error in strict mode. Use the specific canonical system name so it matches exactly one entry. | -| `catalog.state_unresolved` | Warning. Use the canonical state name, or add the state to the catalog if it is correct. | -| `catalog.state_ambiguous` | Warning. Use the specific canonical state name. | -| `catalog.event_unresolved` | Warning. Use the canonical event name, or add the event to the catalog if it is correct. | -| `catalog.event_ambiguous` | Warning. Use the specific canonical event name. | -| `catalog.feature_unresolved` | Warning. Use the canonical feature name, or add the feature to the catalog if it is correct. | -| `catalog.feature_ambiguous` | Warning. Use the specific canonical feature name. | +| Code | What to change | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `catalog.system_unresolved` | Error in strict mode. Use the catalog's canonical system name, or confirm the system with the catalog owner. | +| `catalog.system_ambiguous` | Error in strict mode. Use the specific canonical system name so it matches exactly one entry. | +| `catalog.state_unresolved` | Warning. Use the canonical state name, or add the state to the catalog if it is correct. | +| `catalog.state_ambiguous` | Warning. Use the specific canonical state name. | +| `catalog.event_unresolved` | Warning. Use the canonical event name, or add the event to the catalog if it is correct. | +| `catalog.event_ambiguous` | Warning. Use the specific canonical event name. | +| `catalog.feature_unresolved` | Warning. Use the canonical feature name, or add the feature to the catalog if it is correct. | +| `catalog.feature_ambiguous` | Warning. Use the specific canonical feature name. | | `catalog.term_unreferenced` | Warning (coverage). A cataloged term is never referenced. Add a requirement that uses it if one is missing, or note the gap; do not invent behavior to satisfy coverage. | ### `lint.*` style advice -| Code | What to change | -| ---------------------------- | ------------------------------------------------------------------------------------------------------ | -| `lint.multiple_responses` | Warning. The response holds several semicolon-joined responses. Split into separate requirements. | +| Code | What to change | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `lint.multiple_responses` | Warning. The response holds several semicolon-joined responses. Split into separate requirements. | | `lint.vague_response` | Warning. Replace the vague term with an observable, bounded response, or raise a question if the bound is unknown. | -| `lint.unparsed_tail` | Warning. Text remains after the parsed requirement. Move it into the requirement or remove it. | -| `lint.alias_used` | Warning. A catalog alias matched. Prefer the canonical name. | -| `lint.suspicious_text_shape` | Warning. The sentence shape looks accidental. Rewrite it into a clean EARS template. | +| `lint.unparsed_tail` | Warning. Text remains after the parsed requirement. Move it into the requirement or remove it. | +| `lint.alias_used` | Warning. A catalog alias matched. Prefer the canonical name. | +| `lint.suspicious_text_shape` | Warning. The sentence shape looks accidental. Rewrite it into a clean EARS template. | ## Review before accept @@ -234,4 +234,4 @@ The summary states: - source lines translated with medium or low confidence - any source behavior intentionally not converted, with the reason -Recommend acceptance only when validation is clean (no error diagnostics) and no blocking questions remain. If either condition fails, say so and point at the next step (repair, or a human answer to a question) instead of recommending acceptance. \ No newline at end of file +Recommend acceptance only when validation is clean (no error diagnostics) and no blocking questions remain. If either condition fails, say so and point at the next step (repair, or a human answer to a question) instead of recommending acceptance. diff --git a/docs/agentic-loop.md b/docs/agentic-loop.md index da7293d..0c91465 100644 --- a/docs/agentic-loop.md +++ b/docs/agentic-loop.md @@ -4,11 +4,11 @@ This document describes how any coding agent derives `.ears` files from source s Three parties have separate responsibilities, and the loop keeps them separate. -| Party | Responsibility | -| ------------ | -------------------------------------------------------------------------------------------------------------------- | +| Party | Responsibility | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | CLI facade | Resolve paths, scaffold work directories, return instructions, validate `.ears`, report status, record acceptance, detect stale sources. | -| Coding agent | Read the source, decide which behaviors are requirements, write and repair `.ears`, and raise questions when the source is unclear. | -| Human | Approve behavior, answer questions, and accept or reject the generated `.ears`. | +| Coding agent | Read the source, decide which behaviors are requirements, write and repair `.ears`, and raise questions when the source is unclear. | +| Human | Approve behavior, answer questions, and accept or reject the generated `.ears`. | The CLI never interprets the source semantically. The agent never accepts its own work. Validation success and human acceptance are two different events. @@ -43,33 +43,33 @@ missing -> scaffolded -> drafted -> invalid -> valid -> accepted ### States -| State | Meaning | -| ------------ | ------------------------------------------------------------------------------------------- | -| `missing` | No manifest exists for the queried id. Reported by `status`; never stored. | -| `scaffolded` | `earsyntax new` created the directory and empty artifacts. No requirements written yet. | -| `drafted` | The `.ears` file has content but has not passed a clean validation. | -| `invalid` | The last `validate` produced at least one error-severity diagnostic. | -| `valid` | The last `validate` produced no error diagnostics. Eligible for review and acceptance. | -| `accepted` | A human accepted the `.ears` file. `accepted` metadata and hashes are recorded. | +| State | Meaning | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | +| `missing` | No manifest exists for the queried id. Reported by `status`; never stored. | +| `scaffolded` | `earsyntax new` created the directory and empty artifacts. No requirements written yet. | +| `drafted` | The `.ears` file has content but has not passed a clean validation. | +| `invalid` | The last `validate` produced at least one error-severity diagnostic. | +| `valid` | The last `validate` produced no error diagnostics. Eligible for review and acceptance. | +| `accepted` | A human accepted the `.ears` file. `accepted` metadata and hashes are recorded. | | `stale` | The source hash changed after the last `valid` or `accepted` state. The `.ears` may no longer reflect the source. | ### Transitions Each transition names the command or event that triggers it. -| From | To | Trigger | -| ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | -| `missing` | `scaffolded` | `earsyntax new ` creates the manifest and artifact files. | +| From | To | Trigger | +| ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `missing` | `scaffolded` | `earsyntax new ` creates the manifest and artifact files. | | `scaffolded` | `drafted` | The next `validate` observes a non-empty `.ears` output. (The agent writing the file is not a CLI event; the state advances when the CLI next reads it.) | -| `drafted` | `invalid` | `earsyntax validate` produces at least one error-severity diagnostic. | -| `drafted` | `valid` | `earsyntax validate` produces no error-severity diagnostic. | -| `invalid` | `invalid` | `earsyntax validate` still finds error diagnostics after a repair pass. | -| `invalid` | `valid` | `earsyntax validate` finds no error diagnostics after a repair pass. | -| `valid` | `invalid` | A later `validate` (for example after an edit) finds error diagnostics again. | -| `valid` | `accepted` | `earsyntax accept ` after a human approves. Refused unless status is `valid` and the source is not stale. | -| `valid` | `stale` | The source content hash no longer matches the hash recorded at the `valid` transition. | -| `accepted` | `stale` | The source content hash no longer matches `accepted.sourceHash`. | -| `stale` | `drafted` | Re-running the loop (`instructions convert`, rewrite, `validate`) against the changed source. | +| `drafted` | `invalid` | `earsyntax validate` produces at least one error-severity diagnostic. | +| `drafted` | `valid` | `earsyntax validate` produces no error-severity diagnostic. | +| `invalid` | `invalid` | `earsyntax validate` still finds error diagnostics after a repair pass. | +| `invalid` | `valid` | `earsyntax validate` finds no error diagnostics after a repair pass. | +| `valid` | `invalid` | A later `validate` (for example after an edit) finds error diagnostics again. | +| `valid` | `accepted` | `earsyntax accept ` after a human approves. Refused unless status is `valid` and the source is not stale. | +| `valid` | `stale` | The source content hash no longer matches the hash recorded at the `valid` transition. | +| `accepted` | `stale` | The source content hash no longer matches `accepted.sourceHash`. | +| `stale` | `drafted` | Re-running the loop (`instructions convert`, rewrite, `validate`) against the changed source. | [DECIDED] `drafted` is CLI-observed, not agent-signaled. The CLI has no hook that fires when the agent writes the file, so `scaffolded` advances to `drafted`, `valid`, or `invalid` at the next `validate`. `status` reports `scaffolded` until then. Rationale: the CLI only changes state on its own commands; it does not watch the filesystem. @@ -149,4 +149,4 @@ The CLI never accepts on the agent's behalf and never edits the source spec. ## Convergence -The repair sub-loop (`instructions repair` then `validate`) repeats until `validate` reports no error-severity diagnostics. The agent must not force convergence by deleting failing requirements or weakening wording; if the intended behavior is unclear, it writes a question and leaves a clear placeholder. The rules for this are in `docs/agent-rules.md`. Warnings (for example `lint.vague_response`) do not block a `valid` state, but the review summary reports them so the human can decide whether they matter. \ No newline at end of file +The repair sub-loop (`instructions repair` then `validate`) repeats until `validate` reports no error-severity diagnostics. The agent must not force convergence by deleting failing requirements or weakening wording; if the intended behavior is unclear, it writes a question and leaves a clear placeholder. The rules for this are in `docs/agent-rules.md`. Warnings (for example `lint.vague_response`) do not block a `valid` state, but the review summary reports them so the human can decide whether they matter. diff --git a/docs/api.md b/docs/api.md index 6f3c57d..82bd104 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,12 +5,7 @@ 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'; +import { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from '@earsyntax/core'; ``` ## Determinism guarantees @@ -34,14 +29,21 @@ Lints a single requirement and returns a complete `LintResult`: `valid`, the cla ### Without a catalog ```ts -lintEars('When a payment webhook is received, the billing service shall verify the HMAC signature.'); +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": "trigger", + "text": "a payment webhook is received", + "role": "event", + "span": { "start": 5, "end": 34 } + }, { "clause": "system", "text": "billing service", "role": "system" } ], "diagnostics": [], @@ -105,8 +107,14 @@ Lints many requirements at once. Returns one `LintResult` per input item, in the ```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.' }, + { + 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.', + }, ]); ``` @@ -130,7 +138,9 @@ function parseEars(text: string, catalog?: Catalog, options?: Options): ParseRes 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.'); +parseEars( + 'While the payment provider is unavailable, the billing service shall queue retryable events.', +); ``` ```json @@ -168,7 +178,12 @@ Reports catalog entries that no requirement text references. Emits `catalog.term ```ts lintCatalogCoverage( - [{ id: 'REQ-001', text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.' }], + [ + { + 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' }, @@ -195,9 +210,9 @@ The ledger service is never mentioned, so the result contains one diagnostic: ```ts interface Options { - mode?: Mode; // 'strict' | 'guided'; default 'strict' - commaAsAnd?: boolean; // default false - vagueTerms?: string[]; // default ['appropriate', 'sufficient', 'as needed'] + mode?: Mode; // 'strict' | 'guided'; default 'strict' + commaAsAnd?: boolean; // default false + vagueTerms?: string[]; // default ['appropriate', 'sufficient', 'as needed'] } ``` @@ -277,8 +292,16 @@ import { lintEars } from '@earsyntax/core'; import { buildJsonReport, exitCodeForReport } from '@earsyntax/cli-contract'; const inputs = [ - { id: 'REQ-001', text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', source: { file: 'requirements.ears', line: 1 } }, - { id: 'REQ-002', text: 'If the HMAC signature is invalid, the billing service shall reject the webhook.', source: { file: 'requirements.ears', line: 2 } }, + { + id: 'REQ-001', + text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + source: { file: 'requirements.ears', line: 1 }, + }, + { + id: 'REQ-002', + text: 'If the HMAC signature is invalid, the billing service shall reject the webhook.', + source: { file: 'requirements.ears', line: 2 }, + }, ]; const report = buildJsonReport([ @@ -293,4 +316,4 @@ exitCodeForReport(report); // 1, because REQ-002 has an error diagnostic `report.summary` is `{ files: 1, requirements: 2, errors: 1, warnings: 0, infos: 0, valid: false }`, and `report.files[0].requirements` holds one entry per requirement with its `id`, `line`, `text`, `valid`, `pattern`, and `diagnostics`. The package also exports `buildSarifLog` for SARIF 2.1.0 and `buildPrettyModel` for terminal rendering. `serializeJsonReport` produces a byte-stable string for the same input. -Note: the CLI does not expose SARIF output yet. The builder is available here, but `earsyntax validate` emits pretty and JSON only. \ No newline at end of file +Note: the CLI does not expose SARIF output yet. The builder is available here, but `earsyntax validate` emits pretty and JSON only. diff --git a/docs/authoring-ears.md b/docs/authoring-ears.md index f9a619a..05c784b 100644 --- a/docs/authoring-ears.md +++ b/docs/authoring-ears.md @@ -105,9 +105,7 @@ Given this catalog: ```json { - "systems": [ - { "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] } - ] + "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] }] } ``` @@ -147,16 +145,16 @@ Vague terms do not fail validation. They flag a requirement that a reader cannot These are the diagnostics you meet most often while authoring. In strict mode the structural codes are errors; the style codes are always warnings. The [diagnostics reference](diagnostics.md) documents all 29 codes and their severity in each mode. -| Code | What it means | Fix | -| --------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ears.missing_shall` | No single `shall` response boundary. | State one obligation with `shall`: `the billing service shall ...`. | -| `ears.missing_system` | No system name before `shall`. | Name the system: `... the billing service shall ...`. | -| `ears.invalid_if_then_form` | An `If` clause has no `then`. | Add `then`: `If , then the shall .` | -| `ears.multiple_shall` | More than one `shall` in the sentence. | Split into separate requirements, one obligation each. | -| `ears.invalid_clause_order` | Clauses are out of order. | Reorder to `While -> Where -> When -> If`. | -| `lint.multiple_responses` | Semicolon-joined responses in one requirement. | Split into separate requirements. | -| `lint.vague_response` | The response contains a configured vague term. | Replace it with an observable, bounded response. | -| `expr.operator_precedence_warning` | Mixed `and`/`or` without parentheses. | Add parentheses to make the grouping explicit. | +| Code | What it means | Fix | +| ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | +| `ears.missing_shall` | No single `shall` response boundary. | State one obligation with `shall`: `the billing service shall ...`. | +| `ears.missing_system` | No system name before `shall`. | Name the system: `... the billing service shall ...`. | +| `ears.invalid_if_then_form` | An `If` clause has no `then`. | Add `then`: `If , then the shall .` | +| `ears.multiple_shall` | More than one `shall` in the sentence. | Split into separate requirements, one obligation each. | +| `ears.invalid_clause_order` | Clauses are out of order. | Reorder to `While -> Where -> When -> If`. | +| `lint.multiple_responses` | Semicolon-joined responses in one requirement. | Split into separate requirements. | +| `lint.vague_response` | The response contains a configured vague term. | Replace it with an observable, bounded response. | +| `expr.operator_precedence_warning` | Mixed `and`/`or` without parentheses. | Add parentheses to make the grouping explicit. | For example, these four lines each fail with a different structural error: @@ -182,4 +180,4 @@ Line 1 states the behavior in the present tense with no `shall`; add the `shall` - [Diagnostics reference](diagnostics.md): every code, its severity by mode, 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. \ No newline at end of file +- [API reference](api.md): lint the same requirements from TypeScript. diff --git a/docs/cli.md b/docs/cli.md index 4dd8f17..f363e03 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,23 +12,23 @@ npx @earsyntax/cli [options] These work on every command where they make sense: -| Option | Effect | -| ------------------- | ----------------------------------------------------------------- | -| `--json` | Emit the machine-readable facade response instead of pretty text. | -| `--no-color` | Strip ANSI color from pretty output. | -| `--cwd ` | Resolve the project from this directory instead of the process working directory. | -| `--config ` | Use an explicit config file instead of `.earsyntax/config.json`. | -| `--no-interactive` | Never prompt; a step that would require confirmation is refused with exit 3. | +| Option | Effect | +| ------------------ | --------------------------------------------------------------------------------- | +| `--json` | Emit the machine-readable facade response instead of pretty text. | +| `--no-color` | Strip ANSI color from pretty output. | +| `--cwd ` | Resolve the project from this directory instead of the process working directory. | +| `--config ` | Use an explicit config file instead of `.earsyntax/config.json`. | +| `--no-interactive` | Never prompt; a step that would require confirmation is refused with exit 3. | Every `--json` response shares a base shape: `{ version, command, ok, root?, next: [] }` plus command-specific keys. Paths are relative to the resolved project root (the directory containing `.earsyntax/`) unless you passed an absolute path. ## Exit codes -| Code | Meaning | -| ---- | --------------------------------------------------------------------------------------------------- | -| `0` | Completed with no error-severity diagnostic. | -| `1` | Validation completed with at least one error diagnostic. Only `validate` returns `1`. | -| `2` | Usage, config, missing file, unparseable input, or work-item resolution error. | +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------------------------------- | +| `0` | Completed with no error-severity diagnostic. | +| `1` | Validation completed with at least one error diagnostic. Only `validate` returns `1`. | +| `2` | Usage, config, missing file, unparseable input, or work-item resolution error. | | `3` | Refused write, stale source, overwrite protection, or confirmation required in non-interactive mode. | ## `init` @@ -186,8 +186,16 @@ Expected output (`--json`, hashes vary): "stale": false }, "next": [ - { "command": "earsyntax instructions review --work checkout-webhooks --json", "reason": "Prepare the human review summary.", "forAgent": true }, - { "command": "earsyntax accept checkout-webhooks", "reason": "Record acceptance after human approval.", "blocking": true } + { + "command": "earsyntax instructions review --work checkout-webhooks --json", + "reason": "Prepare the human review summary.", + "forAgent": true + }, + { + "command": "earsyntax accept checkout-webhooks", + "reason": "Record acceptance after human approval.", + "blocking": true + } ] } ``` @@ -212,16 +220,35 @@ Expected output (convert, `--json`, abridged): "ok": true, "root": "/repo", "mode": "convert", - "work": { "id": "checkout-webhooks", "status": "scaffolded", "source": "specs/checkout.md", "output": "...", "questions": "...", "traceability": "..." }, + "work": { + "id": "checkout-webhooks", + "status": "scaffolded", + "source": "specs/checkout.md", + "output": "...", + "questions": "...", + "traceability": "..." + }, "rules": ["Read the full source before writing requirements.", "..."], "format": { "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", "allowedPatterns": ["The shall .", "..."], - "metadataPrefixes": ["REQ-001:", "REQ-001 [source: path:line]:", "REQ-001 [source: path:line-line]:"] + "metadataPrefixes": [ + "REQ-001:", + "REQ-001 [source: path:line]:", + "REQ-001 [source: path:line-line]:" + ] + }, + "source": { + "path": "specs/checkout.md", + "hash": "sha256:...", + "excerpts": [{ "path": "specs/checkout.md", "startLine": 3, "endLine": 3, "text": "..." }] }, - "source": { "path": "specs/checkout.md", "hash": "sha256:...", "excerpts": [{ "path": "specs/checkout.md", "startLine": 3, "endLine": 3, "text": "..." }] }, "next": [ - { "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", "reason": "Validate the generated .ears file once it is written.", "forAgent": true } + { + "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", + "reason": "Validate the generated .ears file once it is written.", + "forAgent": true + } ] } ``` @@ -292,4 +319,4 @@ earsyntax instructions repair --work checkout-webhooks --json earsyntax status checkout-webhooks --json earsyntax instructions review --work checkout-webhooks --json earsyntax accept checkout-webhooks --by "omer" -``` \ No newline at end of file +``` diff --git a/docs/compatibility.md b/docs/compatibility.md index f10b246..b4d7b3e 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -51,23 +51,23 @@ 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. | +| 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. | ## Behaviors where the fixtures follow the Go reference verbatim @@ -101,50 +101,50 @@ 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` | +| 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 @@ -189,8 +189,8 @@ than the corpus: 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. + corpus is the place to cover them; they are listed here so the gap is explicit + rather than silent. ## Known unknowns diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d08cb16..1798fa7 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -17,11 +17,11 @@ The parser and catalog matcher discover raw findings (a code and, when known, a 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` | +| 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. @@ -35,37 +35,37 @@ Codes are grouped by prefix. "Severity" columns show the value in each mode. These report defects in the outer EARS sentence shape. All are mode-dependent. -| Code | Meaning | strict | guided | Example trigger | -| --------------------------- | ---------------------------------------------------------------- | ------- | --------- | --------------------------------------------------------------------------- | -| `ears.no_match` | The text does not match any supported EARS shell pattern. | `error` | `warning` | `The quick brown fox.` | -| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | `error` | `warning` | `When the timer fires, while idle, the system shall reset.` | -| `ears.missing_system` | The system name before `shall` is absent or empty. | `error` | `warning` | `When the timer fires, shall reset.` | -| `ears.missing_shall` | The requirement has no single `shall` response boundary. | `error` | `warning` | `The system resets the timer.` | -| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | `error` | `warning` | `The system shall reset and shall log the event.` | -| `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.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | `error` | `warning` | `When , the system shall reset.` | -| `ears.empty_response` | The response after `shall` is empty. | `error` | `warning` | `The system shall .` | +| Code | Meaning | strict | guided | Example trigger | +| --------------------------- | ----------------------------------------------------------- | ------- | --------- | ------------------------------------------------------------------- | +| `ears.no_match` | The text does not match any supported EARS shell pattern. | `error` | `warning` | `The quick brown fox.` | +| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | `error` | `warning` | `When the timer fires, while idle, the system shall reset.` | +| `ears.missing_system` | The system name before `shall` is absent or empty. | `error` | `warning` | `When the timer fires, shall reset.` | +| `ears.missing_shall` | The requirement has no single `shall` response boundary. | `error` | `warning` | `The system resets the timer.` | +| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | `error` | `warning` | `The system shall reset and shall log the event.` | +| `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.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | `error` | `warning` | `When , the system shall reset.` | +| `ears.empty_response` | The response after `shall` is empty. | `error` | `warning` | `The system shall .` | ### `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. -| Code | Meaning | strict | guided | Example trigger | -| ---------------------------------- | ------------------------------------------------------------- | --------- | --------- | ------------------------------------------------------- | -| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | `error` | `warning` | `When (A and B, the system shall reset.` | -| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | `error` | `warning` | `While A or or B, the system shall reset.` | -| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | `error` | `warning` | `While A and (), the system shall reset.` | -| `expr.operator_precedence_warning` | A mixed `and`/`or` expression may need parentheses. | `warning` | `warning` | `While A and B or C, the system shall reset.` | -| `expr.unknown_term` | A clause term matches no catalog entry for its role. | `warning` | `warning` | A `When` term absent from the catalog. | -| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | `warning` | `warning` | A term whose name collides across catalog groups. | -| `expr.mixed_unresolved_terms` | One clause mixes resolved and unresolved terms. | `warning` | `warning` | `While A and B` where `A` resolves and `B` does not. | +| Code | Meaning | strict | guided | Example trigger | +| ---------------------------------- | ------------------------------------------------------ | --------- | --------- | ---------------------------------------------------- | +| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | `error` | `warning` | `When (A and B, the system shall reset.` | +| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | `error` | `warning` | `While A or or B, the system shall reset.` | +| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | `error` | `warning` | `While A and (), the system shall reset.` | +| `expr.operator_precedence_warning` | A mixed `and`/`or` expression may need parentheses. | `warning` | `warning` | `While A and B or C, the system shall reset.` | +| `expr.unknown_term` | A clause term matches no catalog entry for its role. | `warning` | `warning` | A `When` term absent from the catalog. | +| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | `warning` | `warning` | A term whose name collides across catalog groups. | +| `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. -| Code | Meaning | strict | guided | Example trigger | -| ---------------------------- | ------------------------------------------------------------- | --------- | --------- | ----------------------------------------------------------------- | +| Code | Meaning | strict | guided | Example trigger | +| ---------------------------- | ------------------------------------------------------------- | --------- | --------- | ---------------------------------------------------------------- | | `catalog.system_unresolved` | The system name matches no known system. | `error` | `warning` | `The invoicing engine shall ...` when only `BFF` is cataloged. | | `catalog.system_ambiguous` | The system name matches more than one known system. | `error` | `warning` | A system name that two catalog entries share. | | `catalog.state_unresolved` | A state term matches no known state. | `warning` | `warning` | `While the queue is draining, ...` with no such state. | @@ -80,13 +80,13 @@ The `system` role is mode-dependent (an unknown or ambiguous system is a hard er Stylistic findings that never affect validity. All are always warnings. -| Code | Meaning | strict | guided | Example trigger | -| --------------------------- | ---------------------------------------------------------- | --------- | --------- | ----------------------------------------------------------- | -| `lint.multiple_responses` | The response holds several semicolon-joined responses. | `warning` | `warning` | `The system shall log the event; notify the operator.` | -| `lint.vague_response` | The response contains a configured vague term. | `warning` | `warning` | `The system shall respond as needed.` | -| `lint.unparsed_tail` | Text remains after the parsed requirement. | `warning` | `warning` | Trailing tokens the expression parser could not consume. | -| `lint.alias_used` | A catalog alias matched; the canonical name is preferred. | `warning` | `warning` | `db` matching a `Postgres` entry via an alias. | -| `lint.suspicious_text_shape`| The sentence shape is likely accidental or malformed. | `warning` | `warning` | Guided-mode text that resembles no EARS shell. | +| Code | Meaning | strict | guided | Example trigger | +| ---------------------------- | --------------------------------------------------------- | --------- | --------- | -------------------------------------------------------- | +| `lint.multiple_responses` | The response holds several semicolon-joined responses. | `warning` | `warning` | `The system shall log the event; notify the operator.` | +| `lint.vague_response` | The response contains a configured vague term. | `warning` | `warning` | `The system shall respond as needed.` | +| `lint.unparsed_tail` | Text remains after the parsed requirement. | `warning` | `warning` | Trailing tokens the expression parser could not consume. | +| `lint.alias_used` | A catalog alias matched; the canonical name is preferred. | `warning` | `warning` | `db` matching a `Postgres` entry via an alias. | +| `lint.suspicious_text_shape` | The sentence shape is likely accidental or malformed. | `warning` | `warning` | Guided-mode text that resembles no EARS shell. | ## Ordering diff --git a/docs/facade-api.md b/docs/facade-api.md index 2b185d2..9a439a1 100644 --- a/docs/facade-api.md +++ b/docs/facade-api.md @@ -51,11 +51,11 @@ interface FacadeDiagnostic { Field descriptions: -| Field | Description | +| Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | The installed `@earsyntax/cli` package version, for example `0.1.0`. | +| `version` | The installed `@earsyntax/cli` package version, for example `0.1.0`. | | `command` | The command that produced the response. Subcommands use a space, for example `instructions convert`. | -| `ok` | `true` when the command completed with no error-severity diagnostic. See each command for what counts as an error. | +| `ok` | `true` when the command completed with no error-severity diagnostic. See each command for what counts as an error. | | `root` | The resolved project root as an absolute path. Present on commands that resolve a project. | | `data` | Reserved generic payload. In v0.1 commands expose their payload through top-level convenience keys instead (see the note below). | | `diagnostics` | Facade-level diagnostics (config, path, or work-item problems). Distinct from the core linting `Diagnostic` inside a validate result. | @@ -63,12 +63,12 @@ Field descriptions: `NextAction`: -| Field | Description | -| ---------- | ------------------------------------------------------------------------------------------------- | -| `command` | A runnable `earsyntax` command. Every value is a real command from this contract. | -| `reason` | One factual sentence explaining why this action follows. | +| Field | Description | +| ---------- | ----------------------------------------------------------------------------------------------------- | +| `command` | A runnable `earsyntax` command. Every value is a real command from this contract. | +| `reason` | One factual sentence explaining why this action follows. | | `blocking` | `true` when the action requires a human decision (for example acceptance). Optional; default `false`. | -| `forAgent` | `true` when a coding agent can run the action without human input. Optional; default `false`. | +| `forAgent` | `true` when a coding agent can run the action without human input. Optional; default `false`. | [DECIDED] Convenience keys over `data`. The orchestration brief shows command payloads as top-level keys (`work`, `written`, `summary`, `results`) rather than nested under `data`. v0.1 follows the examples: each command adds its own top-level keys and leaves `data` unset. Rationale: it matches every example in the brief and keeps the JSON one level flatter for agents. @@ -78,27 +78,27 @@ Field descriptions: Exit codes are uniform across commands. -| Code | Meaning | -| ---- | ----------------------------------------------------------------------------------------------------------- | -| `0` | The command completed and produced no error-severity diagnostic. | -| `1` | Validation completed and at least one error-severity diagnostic exists. Only `validate` returns `1`. | -| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work-item resolution error. | +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------ | +| `0` | The command completed and produced no error-severity diagnostic. | +| `1` | Validation completed and at least one error-severity diagnostic exists. Only `validate` returns `1`. | +| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work-item resolution error. | | `3` | Refused write, stale source, overwrite protection, or a human confirmation required in non-interactive mode. | Per-command notes: -| Command | `0` | `1` | `2` | `3` | -| -------------- | ---------------------------- | ----------------------- | ------------------------------------------------ | ------------------------------------------------------- | -| `init` | Initialized or already valid | not used | bad `--tools` value, unwritable root | `.earsyntax/` exists and `--force` not passed | -| `doctor` | all checks pass or warn | not used | config unreadable | not used | -| `version` | always | not used | not used | not used | -| `new` | work item created | not used | bad slug, missing `--source` file, missing mode | work item exists and `--force` not passed | -| `list` | always | not used | config unreadable | not used | -| `status` | work item resolved | not used | unknown work item | not used | -| `instructions` | instructions returned | not used | unknown work item, unknown mode | not used | -| `validate` | no error diagnostics | one or more error diags | missing file, bad glob, unreadable catalog | source stale relative to a work item (see `validate`) | -| `accept` | acceptance recorded | not used | unknown work item | status not `valid`, or source stale | -| `show` | artifact resolved | not used | unknown work item, unknown artifact | not used | +| Command | `0` | `1` | `2` | `3` | +| -------------- | ---------------------------- | ----------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `init` | Initialized or already valid | not used | bad `--tools` value, unwritable root | `.earsyntax/` exists and `--force` not passed | +| `doctor` | all checks pass or warn | not used | config unreadable | not used | +| `version` | always | not used | not used | not used | +| `new` | work item created | not used | bad slug, missing `--source` file, missing mode | work item exists and `--force` not passed | +| `list` | always | not used | config unreadable | not used | +| `status` | work item resolved | not used | unknown work item | not used | +| `instructions` | instructions returned | not used | unknown work item, unknown mode | not used | +| `validate` | no error diagnostics | one or more error diags | missing file, bad glob, unreadable catalog | source stale relative to a work item (see `validate`) | +| `accept` | acceptance recorded | not used | unknown work item | status not `valid`, or source stale | +| `show` | artifact resolved | not used | unknown work item, unknown artifact | not used | [DECIDED] Only `validate` returns exit `1`. Exit `1` means "error diagnostics exist", which is a linting outcome. Other commands surface problems as exit `2` (resolution) or `3` (refusal), never `1`. Rationale: keeps `1` a reliable signal that requirements failed validation, which is what scripts branch on. @@ -110,10 +110,10 @@ Initialize `.earsyntax/` and optional agent wrapper files. Top-level keys beyond the base: -| Key | Type | Description | -| --------- | ---------- | ------------------------------------------------------------------ | +| Key | Type | Description | +| --------- | ---------- | ----------------------------------------------------------------- | | `written` | `string[]` | Every path written, relative to root. Empty when nothing changed. | -| `tools` | `string[]` | Tool wrappers written, for example `["claude", "codex"]`. | +| `tools` | `string[]` | Tool wrappers written, for example `["claude", "codex"]`. | ```json { @@ -141,9 +141,9 @@ Read-only project health report. Never writes. Top-level keys beyond the base: -| Key | Type | Description | -| -------- | ------------------- | ----------------------------------------------------------------------- | -| `checks` | `DoctorCheck[]` | One entry per health check, each with `name`, `ok`, and a `message`. | +| Key | Type | Description | +| -------- | --------------- | -------------------------------------------------------------------- | +| `checks` | `DoctorCheck[]` | One entry per health check, each with `name`, `ok`, and a `message`. | [DECIDED] `checks` shape. The brief lists the checks doctor performs but not a JSON field. v0.1 returns `checks: { name: string; ok: boolean; severity: 'error'|'warning'|'info'; message: string }[]`, and mirrors any non-passing check into the base `diagnostics` array. Rationale: agents can read one array (`diagnostics`) for problems while humans get the full ordered checklist in `checks`. @@ -155,8 +155,8 @@ Expose installed capabilities. Never resolves a project, so `root` may be absent Top-level keys beyond the base: -| Key | Type | Description | -| ---------- | -------- | ---------------------------------------------------- | +| Key | Type | Description | +| ---------- | -------- | -------------------------------------------------------- | | `features` | `object` | Capability map for agents to branch on without guessing. | ```json @@ -184,10 +184,10 @@ Create a work item. Does not generate EARS content; it prepares the directory th Top-level keys beyond the base: -| Key | Type | Description | -| --------- | ------------- | ------------------------------------------------------ | -| `work` | `WorkSummary` | The created work item summary. | -| `written` | `string[]` | Every path written, relative to root. | +| Key | Type | Description | +| --------- | ------------- | ------------------------------------- | +| `work` | `WorkSummary` | The created work item summary. | +| `written` | `string[]` | Every path written, relative to root. | ```json { @@ -228,14 +228,7 @@ Mode selection: `--mode convert` requires `--source `; `--mode author` req ```ts type WorkMode = 'author' | 'convert'; -type WorkStatus = - | 'missing' - | 'scaffolded' - | 'drafted' - | 'invalid' - | 'valid' - | 'accepted' - | 'stale'; +type WorkStatus = 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; interface WorkManifest { schemaVersion: 1; @@ -283,9 +276,9 @@ List work items. Supports `--status ` to filter. Top-level keys beyond the base: -| Key | Type | Description | -| ------- | --------------- | ------------------------------- | -| `items` | `WorkSummary[]` | Work items, sorted by `id`. | +| Key | Type | Description | +| ------- | --------------- | --------------------------- | +| `items` | `WorkSummary[]` | Work items, sorted by `id`. | ## `status` @@ -293,9 +286,9 @@ Report one work item's state and next steps. Top-level keys beyond the base: -| Key | Type | Description | -| ------ | -------- | ------------------------------------ | -| `work` | `object` | Work item state (shape below). | +| Key | Type | Description | +| ------ | -------- | ------------------------------ | +| `work` | `object` | Work item state (shape below). | ```json { @@ -375,12 +368,12 @@ The response carries `InstructionResponse` fields as top-level keys alongside th Per-mode content: -| Mode | `source` present | `diagnostics` present | `rules` focus | -| --------- | --------------------------- | ------------------------------ | ---------------------------------------------------------------- | -| `author` | no (prompt-based work item) | no | Author from the prompt only; do not invent behavior. | -| `convert` | yes (path, hash, excerpts) | no | Convert the source; preserve traceability; split compounds. | -| `repair` | yes when the item has one | yes (the diagnostics to fix) | Change only what the diagnostics justify; keyed to codes. | -| `review` | yes when the item has one | no | Produce a human review summary; recommend accept only if clean. | +| Mode | `source` present | `diagnostics` present | `rules` focus | +| --------- | --------------------------- | ---------------------------- | --------------------------------------------------------------- | +| `author` | no (prompt-based work item) | no | Author from the prompt only; do not invent behavior. | +| `convert` | yes (path, hash, excerpts) | no | Convert the source; preserve traceability; split compounds. | +| `repair` | yes when the item has one | yes (the diagnostics to fix) | Change only what the diagnostics justify; keyed to codes. | +| `review` | yes when the item has one | no | Produce a human review summary; recommend accept only if clean. | The full rule text is specified in `docs/agent-rules.md`. The `command` field is `instructions ` (for example `instructions convert`). Fixtures: `fixtures/facade/instructions-*.json`. @@ -423,9 +416,9 @@ Record that a human accepted the generated `.ears` file. This is the human gate. Top-level keys beyond the base: -| Key | Type | Description | -| ------ | -------- | ------------------------------------------- | -| `work` | `object` | Updated work summary including `accepted`. | +| Key | Type | Description | +| ------ | -------- | ------------------------------------------ | +| `work` | `object` | Updated work summary including `accepted`. | Rules, each a refusal with exit `3` when violated: @@ -462,4 +455,4 @@ Planned, not implemented in v0.1. Documented in the orchestration brief for forw - `features.facade` is the contract version. Field removals or renames increment it. - New optional keys may be added within a contract version; consumers must ignore unknown keys. - `next[].command` values are always runnable `earsyntax` commands from this contract. -- Diagnostic codes inside validation results come from the frozen 29-code registry and are append-only. \ No newline at end of file +- Diagnostic codes inside validation results come from the frozen 29-code registry and are append-only. diff --git a/docs/grammar.md b/docs/grammar.md index fb8da69..4c99652 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -14,14 +14,14 @@ Sources: 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. | +| 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`. @@ -37,12 +37,12 @@ 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` | +| 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`). @@ -65,12 +65,12 @@ The `If ... then ...` form is special: the `then` boundary is required. An `If` 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 | +| 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`. @@ -97,13 +97,13 @@ So `a and b or c` parses as `(a and b) or c`, and `not a and b` parses as `(not 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 | +| 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`). @@ -111,10 +111,10 @@ The precedence warning is not an error: the expression still parses (`(a and b) 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` | +| 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`. @@ -122,12 +122,12 @@ The explicit `and` keyword works under both settings; it does not depend on `com 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` | +| 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. @@ -135,12 +135,12 @@ Parsing never requires an uppercase house style. A formatter may still choose on 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 | +| 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. @@ -148,27 +148,27 @@ Per the severity rules, an unresolved or ambiguous system is an error in `strict 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` | +| 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 diff --git a/docs/input-formats.md b/docs/input-formats.md index 3d7687c..0106855 100644 --- a/docs/input-formats.md +++ b/docs/input-formats.md @@ -9,7 +9,8 @@ line for each requirement. ```ts import { extractFromContent } from '@earsyntax/extract'; -const md = '- REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; +const md = + '- REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; const { items, errors } = extractFromContent(md, 'requirements.md'); // items[0] === { // id: 'REQ-001', @@ -116,6 +117,7 @@ reference that becomes the item's source location. ```md - REQ-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. + 1. REQ-003 [source: specs/checkout.md:20]: The billing service shall retain receipts for seven years. ``` @@ -131,17 +133,17 @@ columns takes the id from the `ID` column: | ID | Requirement | | ------- | ---------------------------------------------------------------------------------------- | | 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. | +| REQ-002 | If the HMAC signature is invalid, then the billing service shall reject the webhook. | ``` A single-column requirement table also works, and an `ID:` prefix inside a cell is lifted just like in a bullet: ```md -| Requirement | -| --- | +| Requirement | +| ------------------------------------------------------------- | | REQ-001: The billing service shall verify the HMAC signature. | -| The billing service shall reject invalid webhooks. | +| The billing service shall reject invalid webhooks. | ``` Column headers are matched case-insensitively. `ID` selects the id column; @@ -217,12 +219,12 @@ the raw document, and malformed JSON or the wrong top-level shape is reported in `extractFromContent` picks the right extractor from a file name's extension and records that name as each item's source file: -| Extension | Extractor | -| -------------------- | ----------------- | -| `.ears` | `extractEars` | -| `.md`, `.markdown` | `extractMarkdown` | -| `.yaml`, `.yml` | `extractYaml` | -| `.json` | `extractJson` | +| Extension | Extractor | +| ------------------ | ----------------- | +| `.ears` | `extractEars` | +| `.md`, `.markdown` | `extractMarkdown` | +| `.yaml`, `.yml` | `extractYaml` | +| `.json` | `extractJson` | An unsupported extension returns no items and a single `ExtractError`. diff --git a/docs/public-api.md b/docs/public-api.md index 63c1e59..d4cb6b7 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -3,12 +3,7 @@ `@earsyntax/core` exposes a small, frozen surface. Every type is defined in `packages/core/src/types.ts` and re-exported from the package root. The functions below are the entire public API for v0.1. ```ts -import { - lintEars, - lintEarsBatch, - parseEars, - lintCatalogCoverage, -} from '@earsyntax/core'; +import { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from '@earsyntax/core'; ``` ## Determinism guarantees @@ -87,8 +82,14 @@ Lints many requirements at once. Returns one `LintResult` per input item, in the ```ts const results = 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.' }, + { + 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.', + }, ], catalog, { mode: 'strict' }, @@ -132,7 +133,12 @@ Reports catalog entries that no requirement text references. Emits `catalog.term ```ts const diagnostics = lintCatalogCoverage( - [{ id: 'REQ-001', text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.' }], + [ + { + 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' }, @@ -159,22 +165,22 @@ Since the ledger service is never mentioned, the result contains one diagnostic: All shapes are defined and documented in `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 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. | +| 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 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 index 08c103a..d8412ec 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -183,4 +183,4 @@ The work item is now `accepted`, with the accepting user and the source and outp - [Author EARS by hand](authoring-ears.md): the six patterns, clause order, and common diagnostics. - [API reference](api.md): `lintEars`, `lintEarsBatch`, `parseEars`, and `lintCatalogCoverage`. -- [CLI reference](cli.md): every command, its flags, and its output. \ No newline at end of file +- [CLI reference](cli.md): every command, its flags, and its output. diff --git a/fixtures/demo/catalog.json b/fixtures/demo/catalog.json index eae0501..aa101a9 100644 --- a/fixtures/demo/catalog.json +++ b/fixtures/demo/catalog.json @@ -5,22 +5,54 @@ { "id": "SYS-WEBHOOK", "name": "webhook processor", "aliases": ["webhook handler"] } ], "events": [ - { "id": "EVT-WEBHOOK", "name": "a payment webhook is received", "aliases": ["payment webhook received"] }, + { + "id": "EVT-WEBHOOK", + "name": "a payment webhook is received", + "aliases": ["payment webhook received"] + }, { "id": "EVT-REFUND", "name": "a refund is requested", "aliases": ["refund requested"] }, { "id": "EVT-INVOICE", "name": "an invoice is finalized", "aliases": ["invoice finalized"] }, - { "id": "EVT-PAYMENT-DECLINED", "name": "a payment is declined", "aliases": ["payment declined"] } + { + "id": "EVT-PAYMENT-DECLINED", + "name": "a payment is declined", + "aliases": ["payment declined"] + } ], "states": [ - { "id": "STATE-PROVIDER-UNAVAIL", "name": "the payment provider is unavailable", "aliases": ["provider unavailable"] }, - { "id": "STATE-MAINTENANCE", "name": "the system is in maintenance mode", "aliases": ["maintenance mode"] } + { + "id": "STATE-PROVIDER-UNAVAIL", + "name": "the payment provider is unavailable", + "aliases": ["provider unavailable"] + }, + { + "id": "STATE-MAINTENANCE", + "name": "the system is in maintenance mode", + "aliases": ["maintenance mode"] + } ], "features": [ - { "id": "FEAT-DUNNING", "name": "dunning management is enabled", "aliases": ["dunning enabled", "dunning"] }, + { + "id": "FEAT-DUNNING", + "name": "dunning management is enabled", + "aliases": ["dunning enabled", "dunning"] + }, { "id": "FEAT-TAX", "name": "tax calculation is enabled", "aliases": ["tax calculation"] } ], "conditions": [ - { "id": "COND-HMAC-INVALID", "name": "the HMAC signature is invalid", "aliases": ["invalid HMAC signature"] }, - { "id": "COND-PAYMENT-DECLINED", "name": "the payment is declined", "aliases": ["payment declined"] }, - { "id": "COND-STALE-TIMESTAMP", "name": "the webhook timestamp is stale", "aliases": ["stale webhook timestamp"] } + { + "id": "COND-HMAC-INVALID", + "name": "the HMAC signature is invalid", + "aliases": ["invalid HMAC signature"] + }, + { + "id": "COND-PAYMENT-DECLINED", + "name": "the payment is declined", + "aliases": ["payment declined"] + }, + { + "id": "COND-STALE-TIMESTAMP", + "name": "the webhook timestamp is stale", + "aliases": ["stale webhook timestamp"] + } ] } diff --git a/fixtures/ears-lint-go-parity/A301.json b/fixtures/ears-lint-go-parity/A301.json index a0014cc..1a5a8d7 100644 --- a/fixtures/ears-lint-go-parity/A301.json +++ b/fixtures/ears-lint-go-parity/A301.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -381,8 +284,6 @@ "raw": "ECS" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/A303.json b/fixtures/ears-lint-go-parity/A303.json index b836ba5..739b8d8 100644 --- a/fixtures/ears-lint-go-parity/A303.json +++ b/fixtures/ears-lint-go-parity/A303.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -385,8 +288,6 @@ "raw": "diagnostics" } }, - "responses": [ - "raise an alert" - ] + "responses": ["raise an alert"] } } diff --git a/fixtures/ears-lint-go-parity/I402.json b/fixtures/ears-lint-go-parity/I402.json index 1d3d7c4..9a7615d 100644 --- a/fixtures/ears-lint-go-parity/I402.json +++ b/fixtures/ears-lint-go-parity/I402.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -373,8 +276,6 @@ "raw": "diagnostic system" } }, - "responses": [ - "store a fault record" - ] + "responses": ["store a fault record"] } } diff --git a/fixtures/ears-lint-go-parity/I404.json b/fixtures/ears-lint-go-parity/I404.json index 08c0495..b6afbe6 100644 --- a/fixtures/ears-lint-go-parity/I404.json +++ b/fixtures/ears-lint-go-parity/I404.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -373,8 +276,6 @@ "raw": "engine control system" } }, - "responses": [ - "shall enable reverse thrust" - ] + "responses": ["shall enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/I405.json b/fixtures/ears-lint-go-parity/I405.json index 3eba7c8..b7908cb 100644 --- a/fixtures/ears-lint-go-parity/I405.json +++ b/fixtures/ears-lint-go-parity/I405.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, diff --git a/fixtures/ears-lint-go-parity/I406.json b/fixtures/ears-lint-go-parity/I406.json index f7cc8a5..442b7fb 100644 --- a/fixtures/ears-lint-go-parity/I406.json +++ b/fixtures/ears-lint-go-parity/I406.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, diff --git a/fixtures/ears-lint-go-parity/I410.json b/fixtures/ears-lint-go-parity/I410.json index 42ea964..46a29e6 100644 --- a/fixtures/ears-lint-go-parity/I410.json +++ b/fixtures/ears-lint-go-parity/I410.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -382,8 +285,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/I501.json b/fixtures/ears-lint-go-parity/I501.json index e5c8211..f4f3b18 100644 --- a/fixtures/ears-lint-go-parity/I501.json +++ b/fixtures/ears-lint-go-parity/I501.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -377,8 +280,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/I502.json b/fixtures/ears-lint-go-parity/I502.json index 62f3b10..4beecee 100644 --- a/fixtures/ears-lint-go-parity/I502.json +++ b/fixtures/ears-lint-go-parity/I502.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, diff --git a/fixtures/ears-lint-go-parity/I506.json b/fixtures/ears-lint-go-parity/I506.json index 8fbfd3e..2b7bc26 100644 --- a/fixtures/ears-lint-go-parity/I506.json +++ b/fixtures/ears-lint-go-parity/I506.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -373,8 +276,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/K901.json b/fixtures/ears-lint-go-parity/K901.json index 321cf62..82f787a 100644 --- a/fixtures/ears-lint-go-parity/K901.json +++ b/fixtures/ears-lint-go-parity/K901.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -391,8 +294,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 959b845..b0e7c4c 100644 --- a/fixtures/ears-lint-go-parity/LIB-alias-used.json +++ b/fixtures/ears-lint-go-parity/LIB-alias-used.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -57,8 +55,6 @@ "raw": "engine ctrl system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index bb75abe..51ffc1a 100644 --- a/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json +++ b/fixtures/ears-lint-go-parity/LIB-ambiguous-event.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -67,8 +65,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 6db9d9c..30d74cd 100644 --- a/fixtures/ears-lint-go-parity/LIB-comma-as-and.json +++ b/fixtures/ears-lint-go-parity/LIB-comma-as-and.json @@ -10,9 +10,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -53,8 +51,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 9f172fe..0d4014a 100644 --- a/fixtures/ears-lint-go-parity/LIB-complex-expr.json +++ b/fixtures/ears-lint-go-parity/LIB-complex-expr.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -52,8 +50,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/LIB-determinism.json b/fixtures/ears-lint-go-parity/LIB-determinism.json index 620d335..63495e5 100644 --- a/fixtures/ears-lint-go-parity/LIB-determinism.json +++ b/fixtures/ears-lint-go-parity/LIB-determinism.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -65,8 +63,6 @@ "raw": "unknown system" } }, - "responses": [ - "do something appropriate" - ] + "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 index 5c96778..a6850c3 100644 --- a/fixtures/ears-lint-go-parity/LIB-event-driven.json +++ b/fixtures/ears-lint-go-parity/LIB-event-driven.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -59,8 +57,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 9eda9c8..279c509 100644 --- a/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json +++ b/fixtures/ears-lint-go-parity/LIB-guided-suspicious.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ diff --git a/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json b/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json index e2bb6e1..3b71685 100644 --- a/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-missing-then.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -57,8 +55,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index d8b2115..adc2def 100644 --- a/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json +++ b/fixtures/ears-lint-go-parity/LIB-ifthen-valid.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -52,8 +50,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 7067f64..5e0f6d8 100644 --- a/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json +++ b/fixtures/ears-lint-go-parity/LIB-multiple-trigger.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -57,8 +55,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 057b0bb..97f07f3 100644 --- a/fixtures/ears-lint-go-parity/LIB-optional-feature.json +++ b/fixtures/ears-lint-go-parity/LIB-optional-feature.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -52,8 +50,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 4aaec7d..af56088 100644 --- a/fixtures/ears-lint-go-parity/LIB-state-driven.json +++ b/fixtures/ears-lint-go-parity/LIB-state-driven.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -52,8 +50,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "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 index 287779f..4c88566 100644 --- a/fixtures/ears-lint-go-parity/LIB-system-unresolved.json +++ b/fixtures/ears-lint-go-parity/LIB-system-unresolved.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -57,8 +55,6 @@ "raw": "unknown system" } }, - "responses": [ - "do something" - ] + "responses": ["do something"] } } diff --git a/fixtures/ears-lint-go-parity/LIB-ubiquitous.json b/fixtures/ears-lint-go-parity/LIB-ubiquitous.json index 58777e6..05aed61 100644 --- a/fixtures/ears-lint-go-parity/LIB-ubiquitous.json +++ b/fixtures/ears-lint-go-parity/LIB-ubiquitous.json @@ -9,9 +9,7 @@ { "id": "SYS-ENGINE", "name": "engine control system", - "aliases": [ - "engine ctrl system" - ] + "aliases": ["engine ctrl system"] } ], "events": [ @@ -52,8 +50,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/N701.json b/fixtures/ears-lint-go-parity/N701.json index 3ee0e5f..682f217 100644 --- a/fixtures/ears-lint-go-parity/N701.json +++ b/fixtures/ears-lint-go-parity/N701.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, diff --git a/fixtures/ears-lint-go-parity/S801.json b/fixtures/ears-lint-go-parity/S801.json index 30d74bc..599c7b1 100644 --- a/fixtures/ears-lint-go-parity/S801.json +++ b/fixtures/ears-lint-go-parity/S801.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/S803.json b/fixtures/ears-lint-go-parity/S803.json index d80bc41..9474760 100644 --- a/fixtures/ears-lint-go-parity/S803.json +++ b/fixtures/ears-lint-go-parity/S803.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "diagnostic system" } }, - "responses": [ - "store a fault record" - ] + "responses": ["store a fault record"] } } diff --git a/fixtures/ears-lint-go-parity/V001.json b/fixtures/ears-lint-go-parity/V001.json index fbe605b..b5980eb 100644 --- a/fixtures/ears-lint-go-parity/V001.json +++ b/fixtures/ears-lint-go-parity/V001.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "brake control system" } }, - "responses": [ - "apply brake torque" - ] + "responses": ["apply brake torque"] } } diff --git a/fixtures/ears-lint-go-parity/V002.json b/fixtures/ears-lint-go-parity/V002.json index 0d9e75a..68c23fe 100644 --- a/fixtures/ears-lint-go-parity/V002.json +++ b/fixtures/ears-lint-go-parity/V002.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -375,8 +278,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V003.json b/fixtures/ears-lint-go-parity/V003.json index 4faf8e0..8cbb1f4 100644 --- a/fixtures/ears-lint-go-parity/V003.json +++ b/fixtures/ears-lint-go-parity/V003.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "brake control system" } }, - "responses": [ - "apply brake torque" - ] + "responses": ["apply brake torque"] } } diff --git a/fixtures/ears-lint-go-parity/V004.json b/fixtures/ears-lint-go-parity/V004.json index 32c944e..6143b2a 100644 --- a/fixtures/ears-lint-go-parity/V004.json +++ b/fixtures/ears-lint-go-parity/V004.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -375,8 +278,6 @@ "raw": "door control system" } }, - "responses": [ - "display the sunroof control" - ] + "responses": ["display the sunroof control"] } } diff --git a/fixtures/ears-lint-go-parity/V005.json b/fixtures/ears-lint-go-parity/V005.json index 22c040c..5b7c5d3 100644 --- a/fixtures/ears-lint-go-parity/V005.json +++ b/fixtures/ears-lint-go-parity/V005.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -375,8 +278,6 @@ "raw": "ATM" } }, - "responses": [ - "display please re-enter credit card details" - ] + "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 index 0ef620d..f84ba1d 100644 --- a/fixtures/ears-lint-go-parity/V006.json +++ b/fixtures/ears-lint-go-parity/V006.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V102.json b/fixtures/ears-lint-go-parity/V102.json index f3813b1..117c83e 100644 --- a/fixtures/ears-lint-go-parity/V102.json +++ b/fixtures/ears-lint-go-parity/V102.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -382,8 +285,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V105.json b/fixtures/ears-lint-go-parity/V105.json index 8b65834..73de9b0 100644 --- a/fixtures/ears-lint-go-parity/V105.json +++ b/fixtures/ears-lint-go-parity/V105.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "power management system" } }, - "responses": [ - "precondition the cabin" - ] + "responses": ["precondition the cabin"] } } diff --git a/fixtures/ears-lint-go-parity/V108.json b/fixtures/ears-lint-go-parity/V108.json index 5bf3cfd..4f0f699 100644 --- a/fixtures/ears-lint-go-parity/V108.json +++ b/fixtures/ears-lint-go-parity/V108.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V109.json b/fixtures/ears-lint-go-parity/V109.json index 042a541..f156997 100644 --- a/fixtures/ears-lint-go-parity/V109.json +++ b/fixtures/ears-lint-go-parity/V109.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -368,8 +271,6 @@ "raw": "engine control system" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V110.json b/fixtures/ears-lint-go-parity/V110.json index 277fea0..6e06e55 100644 --- a/fixtures/ears-lint-go-parity/V110.json +++ b/fixtures/ears-lint-go-parity/V110.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -373,8 +276,6 @@ "raw": "diagnostic system" } }, - "responses": [ - "record a degraded-power fault" - ] + "responses": ["record a degraded-power fault"] } } diff --git a/fixtures/ears-lint-go-parity/V201.json b/fixtures/ears-lint-go-parity/V201.json index 1fa6562..f1c7645 100644 --- a/fixtures/ears-lint-go-parity/V201.json +++ b/fixtures/ears-lint-go-parity/V201.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -377,8 +280,6 @@ "raw": "brake controller" } }, - "responses": [ - "apply brake torque" - ] + "responses": ["apply brake torque"] } } diff --git a/fixtures/ears-lint-go-parity/V202.json b/fixtures/ears-lint-go-parity/V202.json index 2f0c9de..e4d965b 100644 --- a/fixtures/ears-lint-go-parity/V202.json +++ b/fixtures/ears-lint-go-parity/V202.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -381,8 +284,6 @@ "raw": "FADEC" } }, - "responses": [ - "enable reverse thrust" - ] + "responses": ["enable reverse thrust"] } } diff --git a/fixtures/ears-lint-go-parity/V204.json b/fixtures/ears-lint-go-parity/V204.json index ecbb556..1dd0df8 100644 --- a/fixtures/ears-lint-go-parity/V204.json +++ b/fixtures/ears-lint-go-parity/V204.json @@ -10,76 +10,54 @@ { "id": "SYS-ENG-CTRL", "name": "engine control system", - "aliases": [ - "FADEC", - "ECS" - ] + "aliases": ["FADEC", "ECS"] }, { "id": "SYS-ENV-CTRL", "name": "environmental control system", - "aliases": [ - "ECS" - ] + "aliases": ["ECS"] }, { "id": "SYS-BRAKE-CTRL", "name": "brake control system", - "aliases": [ - "brake controller", - "BCS" - ] + "aliases": ["brake controller", "BCS"] }, { "id": "SYS-DIAG", "name": "diagnostic system", - "aliases": [ - "diagnostics" - ] + "aliases": ["diagnostics"] }, { "id": "SYS-POWER-MGMT", "name": "power management system", - "aliases": [ - "PMS" - ] + "aliases": ["PMS"] }, { "id": "SYS-NAV", "name": "navigation system", - "aliases": [ - "nav system" - ] + "aliases": ["nav system"] }, { "id": "SYS-DOOR-CTRL", "name": "door control system", - "aliases": [ - "door controller" - ] + "aliases": ["door controller"] }, { "id": "SYS-ATM", "name": "ATM", - "aliases": [ - "cash machine" - ] + "aliases": ["cash machine"] } ], "actors": [ { "id": "ACT-PILOT", "name": "pilot", - "aliases": [ - "flight crew member" - ] + "aliases": ["flight crew member"] }, { "id": "ACT-FLIGHT-CREW", "name": "flight crew", - "aliases": [ - "crew" - ] + "aliases": ["crew"] }, { "id": "ACT-DRIVER", @@ -88,255 +66,182 @@ { "id": "ACT-MAINT", "name": "maintenance technician", - "aliases": [ - "technician", - "maintainer" - ] + "aliases": ["technician", "maintainer"] }, { "id": "ACT-USER", "name": "user", - "aliases": [ - "operator" - ] + "aliases": ["operator"] } ], "events": [ { "id": "EVT-REV-CMD", "name": "reverse thrust is commanded", - "aliases": [ - "reverse thrust command", - "reverse thrust commanded" - ] + "aliases": ["reverse thrust command", "reverse thrust commanded"] }, { "id": "EVT-REV-CMD-PILOT", "name": "reverse thrust is commanded by pilot", - "aliases": [ - "reverse thrust command" - ] + "aliases": ["reverse thrust command"] }, { "id": "EVT-BRAKE-REQ", "name": "brake is requested", - "aliases": [ - "brake request", - "brake requested" - ] + "aliases": ["brake request", "brake requested"] }, { "id": "EVT-EMERG-BRAKE", "name": "emergency braking is active", - "aliases": [ - "emergency braking" - ] + "aliases": ["emergency braking"] }, { "id": "EVT-SENSOR-FAULT", "name": "sensor fault is detected", - "aliases": [ - "sensor fault" - ] + "aliases": ["sensor fault"] }, { "id": "EVT-LINK-LOSS", "name": "data link is lost", - "aliases": [ - "link loss" - ] + "aliases": ["link loss"] }, { "id": "EVT-DOOR-OPEN-CMD", "name": "door open is commanded", - "aliases": [ - "door open command" - ] + "aliases": ["door open command"] }, { "id": "EVT-DOOR-CLOSE-CMD", "name": "door close is commanded", - "aliases": [ - "door close command" - ] + "aliases": ["door close command"] }, { "id": "EVT-INVALID-CARD", "name": "an invalid credit card number is entered", - "aliases": [ - "invalid credit card number" - ] + "aliases": ["invalid credit card number"] }, { "id": "EVT-LOW-VOLTAGE", "name": "bus voltage falls below threshold", - "aliases": [ - "low voltage event" - ] + "aliases": ["low voltage event"] } ], "states": [ { "id": "STATE-ONGROUND", "name": "aircraft is on ground", - "aliases": [ - "on ground", - "aircraft on ground" - ] + "aliases": ["on ground", "aircraft on ground"] }, { "id": "STATE-INFLIGHT", "name": "aircraft is in flight", - "aliases": [ - "in flight" - ] + "aliases": ["in flight"] }, { "id": "STATE-HYD-PRESS", "name": "hydraulic pressure is available", - "aliases": [ - "hydraulic pressure available" - ] + "aliases": ["hydraulic pressure available"] }, { "id": "STATE-SERVICE-MODE", "name": "service mode is active", - "aliases": [ - "service mode" - ] + "aliases": ["service mode"] }, { "id": "STATE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "STATE-RUNWAY-MODE", "name": "runway mode is active", - "aliases": [ - "runway mode" - ] + "aliases": ["runway mode"] }, { "id": "STATE-AUTOPILOT", "name": "autopilot is engaged", - "aliases": [ - "autopilot engaged" - ] + "aliases": ["autopilot engaged"] }, { "id": "STATE-CARD-ABSENT", "name": "there is no card in the ATM", - "aliases": [ - "no card in the ATM" - ] + "aliases": ["no card in the ATM"] }, { "id": "STATE-DOOR-OPEN", "name": "the door is open", - "aliases": [ - "door is open" - ] + "aliases": ["door is open"] }, { "id": "STATE-DOOR-CLOSED", "name": "the door is closed", - "aliases": [ - "door is closed" - ] + "aliases": ["door is closed"] } ], "features": [ { "id": "FEAT-REV", "name": "reverse thrust is installed", - "aliases": [ - "reverse thrust installed" - ] + "aliases": ["reverse thrust installed"] }, { "id": "FEAT-REMOTE-START", "name": "remote start is enabled", - "aliases": [ - "remote start" - ] + "aliases": ["remote start"] }, { "id": "FEAT-PREMIUM-NAV", "name": "premium navigation is enabled", - "aliases": [ - "premium navigation", - "premium nav" - ] + "aliases": ["premium navigation", "premium nav"] }, { "id": "FEAT-SUNROOF", "name": "sunroof is installed", - "aliases": [ - "sunroof" - ] + "aliases": ["sunroof"] }, { "id": "FEAT-DATALINK", "name": "data link is installed", - "aliases": [ - "data link installed" - ] + "aliases": ["data link installed"] } ], "modes": [ { "id": "MODE-NORMAL", "name": "normal mode is active", - "aliases": [ - "normal mode" - ] + "aliases": ["normal mode"] }, { "id": "MODE-DEGRADED", "name": "degraded mode is active", - "aliases": [ - "degraded mode" - ] + "aliases": ["degraded mode"] }, { "id": "MODE-MAINT", "name": "maintenance mode is active", - "aliases": [ - "maintenance mode" - ] + "aliases": ["maintenance mode"] } ], "conditions": [ { "id": "COND-OVERTEMP", "name": "engine overtemperature exists", - "aliases": [ - "engine overtemperature" - ] + "aliases": ["engine overtemperature"] }, { "id": "COND-POWER-UNSTABLE", "name": "power is unstable", - "aliases": [ - "unstable power" - ] + "aliases": ["unstable power"] }, { "id": "COND-SAFE-TO-OPEN", "name": "it is safe to open the door", - "aliases": [ - "safe to open" - ] + "aliases": ["safe to open"] }, { "id": "COND-SAFE-TO-CLOSE", "name": "it is safe to close the door", - "aliases": [ - "safe to close" - ] + "aliases": ["safe to close"] } ], "dataTerms": [ @@ -351,9 +256,7 @@ { "id": "DATA-CREDIT-CARD-NUMBER", "name": "credit card number", - "aliases": [ - "card number" - ] + "aliases": ["card number"] } ] }, @@ -377,8 +280,6 @@ "raw": "nav system" } }, - "responses": [ - "display turn-by-turn guidance" - ] + "responses": ["display turn-by-turn guidance"] } } diff --git a/fixtures/facade/README.md b/fixtures/facade/README.md index 97ba7c3..a80e916 100644 --- a/fixtures/facade/README.md +++ b/fixtures/facade/README.md @@ -26,17 +26,17 @@ Each manifest pins the `WorkManifest` shape at one `WorkStatus` state. They shar Each pins the `instructions --json` response for one mode. Every `next[]` entry is a real `earsyntax` command from the facade contract. -| File | Pins | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `instructions-author.json` | Author mode: no `source` block, rules forbid inventing behavior beyond the prompt. | -| `instructions-convert.json` | Convert mode: a `source` block with hash and excerpts, full convert rule set. | -| `instructions-repair.json` | Repair mode: a `diagnostics` array the repair must address, rules keyed to the reported codes. | -| `instructions-review.json` | Review mode: review-summary rules, `next[]` points at reading questions and recording acceptance. | +| File | Pins | +| --------------------------- | ------------------------------------------------------------------------------------------------- | +| `instructions-author.json` | Author mode: no `source` block, rules forbid inventing behavior beyond the prompt. | +| `instructions-convert.json` | Convert mode: a `source` block with hash and excerpts, full convert rule set. | +| `instructions-repair.json` | Repair mode: a `diagnostics` array the repair must address, rules keyed to the reported codes. | +| `instructions-review.json` | Review mode: review-summary rules, `next[]` points at reading questions and recording acceptance. | ## Other responses -| File | Pins | -| ------------------------ | ----------------------------------------------------------------------------------------------------------- | +| File | Pins | +| ------------------------ | ------------------------------------------------------------------------------------------------------------ | | `traceability.json` | The `TraceabilityFile` shape: requirement traces with source lines and confidence, plus a blocking question. | | `validate.response.json` | A `validate --json` response with one error result, one warning result, and a `work` summary; `ok` is false. | -| `status.response.json` | A `status --json` response for a `valid` work item, with review and accept as next actions. | \ No newline at end of file +| `status.response.json` | A `status --json` response for a `valid` work item, with review and accept as next actions. | diff --git a/fixtures/facade/instructions-author.json b/fixtures/facade/instructions-author.json index c64af9c..5cac898 100644 --- a/fixtures/facade/instructions-author.json +++ b/fixtures/facade/instructions-author.json @@ -45,4 +45,4 @@ "forAgent": true } ] -} \ No newline at end of file +} diff --git a/fixtures/facade/instructions-convert.json b/fixtures/facade/instructions-convert.json index 3fe4984..6742411 100644 --- a/fixtures/facade/instructions-convert.json +++ b/fixtures/facade/instructions-convert.json @@ -72,4 +72,4 @@ "forAgent": true } ] -} \ No newline at end of file +} diff --git a/fixtures/facade/instructions-review.json b/fixtures/facade/instructions-review.json index dbb8903..0c8b706 100644 --- a/fixtures/facade/instructions-review.json +++ b/fixtures/facade/instructions-review.json @@ -48,4 +48,4 @@ "blocking": true } ] -} \ No newline at end of file +} diff --git a/fixtures/facade/scaffolded.manifest.json b/fixtures/facade/scaffolded.manifest.json index 9cd8bbf..d76af28 100644 --- a/fixtures/facade/scaffolded.manifest.json +++ b/fixtures/facade/scaffolded.manifest.json @@ -19,4 +19,4 @@ }, "createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-07-28T10:00:00.000Z" -} \ No newline at end of file +} diff --git a/fixtures/facade/stale.manifest.json b/fixtures/facade/stale.manifest.json index acf9e02..5e5f9aa 100644 --- a/fixtures/facade/stale.manifest.json +++ b/fixtures/facade/stale.manifest.json @@ -26,4 +26,4 @@ "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } -} \ No newline at end of file +} diff --git a/fixtures/facade/status.response.json b/fixtures/facade/status.response.json index 200cd0d..740c332 100644 --- a/fixtures/facade/status.response.json +++ b/fixtures/facade/status.response.json @@ -26,4 +26,4 @@ "blocking": true } ] -} \ No newline at end of file +} diff --git a/fixtures/facade/traceability.json b/fixtures/facade/traceability.json index 970ff6b..0ecb964 100644 --- a/fixtures/facade/traceability.json +++ b/fixtures/facade/traceability.json @@ -96,4 +96,4 @@ "blocksRequirements": ["REQ-008"] } ] -} \ No newline at end of file +} diff --git a/fixtures/facade/valid.manifest.json b/fixtures/facade/valid.manifest.json index 33fd380..978844d 100644 --- a/fixtures/facade/valid.manifest.json +++ b/fixtures/facade/valid.manifest.json @@ -20,4 +20,4 @@ }, "createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-07-28T10:15:00.000Z" -} \ No newline at end of file +} diff --git a/fixtures/facade/validate.response.json b/fixtures/facade/validate.response.json index bab5282..f45d5ae 100644 --- a/fixtures/facade/validate.response.json +++ b/fixtures/facade/validate.response.json @@ -82,4 +82,4 @@ "forAgent": true } ] -} \ No newline at end of file +} diff --git a/fixtures/invalid/catalog-system-ambiguous.json b/fixtures/invalid/catalog-system-ambiguous.json index 1424c97..a5aa6a3 100644 --- a/fixtures/invalid/catalog-system-ambiguous.json +++ b/fixtures/invalid/catalog-system-ambiguous.json @@ -6,16 +6,12 @@ { "id": "SYS-BILLING", "name": "billing service", - "aliases": [ - "BFF" - ] + "aliases": ["BFF"] }, { "id": "SYS-BACKEND", "name": "backend for frontend", - "aliases": [ - "BFF" - ] + "aliases": ["BFF"] } ] }, diff --git a/fixtures/invalid/catalog-system-unresolved.json b/fixtures/invalid/catalog-system-unresolved.json index 4d70d78..459f2c2 100644 --- a/fixtures/invalid/catalog-system-unresolved.json +++ b/fixtures/invalid/catalog-system-unresolved.json @@ -6,9 +6,7 @@ { "id": "SYS-BILLING", "name": "billing service", - "aliases": [ - "BFF" - ] + "aliases": ["BFF"] } ] }, diff --git a/fixtures/schema.md b/fixtures/schema.md index f7a0f59..5d717e6 100644 --- a/fixtures/schema.md +++ b/fixtures/schema.md @@ -39,23 +39,23 @@ Fixtures are the source-of-truth examples for the EARS toolkit. Each fixture fil ### 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. | +| 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. | +| 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` @@ -63,13 +63,13 @@ A full `Catalog` object as defined in `packages/core/src/types.ts`. Only the gro ### `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`. | +| 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`: @@ -77,11 +77,11 @@ Each entry in `expected.diagnostics` is a partial `Diagnostic`: { "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. | +| 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 diff --git a/fixtures/valid/case-insensitive-if-then-upper.json b/fixtures/valid/case-insensitive-if-then-upper.json index 0d8527c..1ce5ee5 100644 --- a/fixtures/valid/case-insensitive-if-then-upper.json +++ b/fixtures/valid/case-insensitive-if-then-upper.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "reject the webhook" - ] + "responses": ["reject the webhook"] } } diff --git a/fixtures/valid/case-insensitive-when-lower.json b/fixtures/valid/case-insensitive-when-lower.json index 813061b..30cd204 100644 --- a/fixtures/valid/case-insensitive-when-lower.json +++ b/fixtures/valid/case-insensitive-when-lower.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/case-insensitive-when-upper.json b/fixtures/valid/case-insensitive-when-upper.json index 9e66a1f..eb4b706 100644 --- a/fixtures/valid/case-insensitive-when-upper.json +++ b/fixtures/valid/case-insensitive-when-upper.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/case-insensitive-while-mixed.json b/fixtures/valid/case-insensitive-while-mixed.json index eb243a4..667407d 100644 --- a/fixtures/valid/case-insensitive-while-mixed.json +++ b/fixtures/valid/case-insensitive-while-mixed.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "queue retryable events" - ] + "responses": ["queue retryable events"] } } diff --git a/fixtures/valid/catalog-clause-term-unresolved.json b/fixtures/valid/catalog-clause-term-unresolved.json index 5187fc8..59ea87c 100644 --- a/fixtures/valid/catalog-clause-term-unresolved.json +++ b/fixtures/valid/catalog-clause-term-unresolved.json @@ -2,12 +2,8 @@ "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" } - ] + "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["BFF"] }], + "events": [{ "id": "EVT-WEBHOOK", "name": "a payment webhook is received" }] }, "expected": { "valid": true, diff --git a/fixtures/valid/complex-boolean-nested.json b/fixtures/valid/complex-boolean-nested.json index d722e9c..83185af 100644 --- a/fixtures/valid/complex-boolean-nested.json +++ b/fixtures/valid/complex-boolean-nested.json @@ -27,8 +27,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/complex-when-if.json b/fixtures/valid/complex-when-if.json index c05d297..e107260 100644 --- a/fixtures/valid/complex-when-if.json +++ b/fixtures/valid/complex-when-if.json @@ -20,8 +20,6 @@ "role": "system" } }, - "responses": [ - "reject the webhook" - ] + "responses": ["reject the webhook"] } } diff --git a/fixtures/valid/complex-while-when.json b/fixtures/valid/complex-while-when.json index 8213573..d2c9f2c 100644 --- a/fixtures/valid/complex-while-when.json +++ b/fixtures/valid/complex-while-when.json @@ -20,8 +20,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/complex-while-where-when.json b/fixtures/valid/complex-while-where-when.json index 632ee93..dc940aa 100644 --- a/fixtures/valid/complex-while-where-when.json +++ b/fixtures/valid/complex-while-where-when.json @@ -24,8 +24,6 @@ "role": "system" } }, - "responses": [ - "retry the charge" - ] + "responses": ["retry the charge"] } } diff --git a/fixtures/valid/event-driven-basic.json b/fixtures/valid/event-driven-basic.json index b5a2125..2103f3c 100644 --- a/fixtures/valid/event-driven-basic.json +++ b/fixtures/valid/event-driven-basic.json @@ -16,8 +16,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/event-driven-refund.json b/fixtures/valid/event-driven-refund.json index 6147d07..fd26c33 100644 --- a/fixtures/valid/event-driven-refund.json +++ b/fixtures/valid/event-driven-refund.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "issue a refund to the original payment method" - ] + "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 index 06ac57a..2a2b3b0 100644 --- a/fixtures/valid/event-driven-subscription.json +++ b/fixtures/valid/event-driven-subscription.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "stop future invoicing" - ] + "responses": ["stop future invoicing"] } } diff --git a/fixtures/valid/expr-and-keyword-comma-off.json b/fixtures/valid/expr-and-keyword-comma-off.json index 475f73e..4260d81 100644 --- a/fixtures/valid/expr-and-keyword-comma-off.json +++ b/fixtures/valid/expr-and-keyword-comma-off.json @@ -26,8 +26,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/expr-and.json b/fixtures/valid/expr-and.json index 62bfb8c..8459595 100644 --- a/fixtures/valid/expr-and.json +++ b/fixtures/valid/expr-and.json @@ -23,8 +23,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/expr-comma-as-and.json b/fixtures/valid/expr-comma-as-and.json index 86206b4..04c9f01 100644 --- a/fixtures/valid/expr-comma-as-and.json +++ b/fixtures/valid/expr-comma-as-and.json @@ -18,8 +18,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/expr-group.json b/fixtures/valid/expr-group.json index f478c9f..b3edd00 100644 --- a/fixtures/valid/expr-group.json +++ b/fixtures/valid/expr-group.json @@ -23,8 +23,6 @@ "role": "system" } }, - "responses": [ - "open a dispute case" - ] + "responses": ["open a dispute case"] } } diff --git a/fixtures/valid/expr-not.json b/fixtures/valid/expr-not.json index 98d3414..271db93 100644 --- a/fixtures/valid/expr-not.json +++ b/fixtures/valid/expr-not.json @@ -18,8 +18,6 @@ "role": "system" } }, - "responses": [ - "accept new events" - ] + "responses": ["accept new events"] } } diff --git a/fixtures/valid/expr-or.json b/fixtures/valid/expr-or.json index e3c9374..1af7aff 100644 --- a/fixtures/valid/expr-or.json +++ b/fixtures/valid/expr-or.json @@ -23,8 +23,6 @@ "role": "system" } }, - "responses": [ - "open a dispute case" - ] + "responses": ["open a dispute case"] } } diff --git a/fixtures/valid/expr-precedence-warning.json b/fixtures/valid/expr-precedence-warning.json index 21b0616..3f885e4 100644 --- a/fixtures/valid/expr-precedence-warning.json +++ b/fixtures/valid/expr-precedence-warning.json @@ -28,8 +28,6 @@ "role": "system" } }, - "responses": [ - "open a dispute case" - ] + "responses": ["open a dispute case"] } } diff --git a/fixtures/valid/guided-catalog-system-unresolved.json b/fixtures/valid/guided-catalog-system-unresolved.json index 0d5a83a..9904c2b 100644 --- a/fixtures/valid/guided-catalog-system-unresolved.json +++ b/fixtures/valid/guided-catalog-system-unresolved.json @@ -5,15 +5,11 @@ "mode": "guided" }, "catalog": { - "systems": [ - { "id": "SYS-BILLING", "name": "billing service", "aliases": ["BFF"] } - ] + "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["BFF"] }] }, "expected": { "valid": true, "pattern": "ubiquitous", - "diagnostics": [ - { "code": "catalog.system_unresolved", "severity": "warning" } - ] + "diagnostics": [{ "code": "catalog.system_unresolved", "severity": "warning" }] } } diff --git a/fixtures/valid/guided-empty-subexpression.json b/fixtures/valid/guided-empty-subexpression.json index e2b50cf..db97293 100644 --- a/fixtures/valid/guided-empty-subexpression.json +++ b/fixtures/valid/guided-empty-subexpression.json @@ -6,8 +6,6 @@ }, "expected": { "valid": true, - "diagnostics": [ - { "code": "expr.empty_subexpression", "severity": "warning" } - ] + "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 index 63c187b..ea1a606 100644 --- a/fixtures/valid/guided-if-without-then.json +++ b/fixtures/valid/guided-if-without-then.json @@ -7,8 +7,6 @@ "expected": { "valid": true, "pattern": "unwanted-behaviour", - "diagnostics": [ - { "code": "ears.invalid_if_then_form", "severity": "warning" } - ] + "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 index d0e2e89..5a29da5 100644 --- a/fixtures/valid/guided-invalid-clause-order.json +++ b/fixtures/valid/guided-invalid-clause-order.json @@ -7,8 +7,6 @@ "expected": { "valid": true, "pattern": "complex", - "diagnostics": [ - { "code": "ears.invalid_clause_order", "severity": "warning" } - ] + "diagnostics": [{ "code": "ears.invalid_clause_order", "severity": "warning" }] } } diff --git a/fixtures/valid/guided-missing-shall.json b/fixtures/valid/guided-missing-shall.json index d842193..6d22b95 100644 --- a/fixtures/valid/guided-missing-shall.json +++ b/fixtures/valid/guided-missing-shall.json @@ -6,8 +6,6 @@ }, "expected": { "valid": true, - "diagnostics": [ - { "code": "ears.missing_shall", "severity": "warning" } - ] + "diagnostics": [{ "code": "ears.missing_shall", "severity": "warning" }] } } diff --git a/fixtures/valid/guided-unbalanced-parentheses.json b/fixtures/valid/guided-unbalanced-parentheses.json index eb6096b..5060a8c 100644 --- a/fixtures/valid/guided-unbalanced-parentheses.json +++ b/fixtures/valid/guided-unbalanced-parentheses.json @@ -6,8 +6,6 @@ }, "expected": { "valid": true, - "diagnostics": [ - { "code": "expr.unbalanced_parentheses", "severity": "warning" } - ] + "diagnostics": [{ "code": "expr.unbalanced_parentheses", "severity": "warning" }] } } diff --git a/fixtures/valid/lint-alias-used.json b/fixtures/valid/lint-alias-used.json index fa039d3..92a3c23 100644 --- a/fixtures/valid/lint-alias-used.json +++ b/fixtures/valid/lint-alias-used.json @@ -6,9 +6,7 @@ { "id": "SYS-BILLING", "name": "billing service", - "aliases": [ - "BFF" - ] + "aliases": ["BFF"] } ] }, @@ -29,8 +27,6 @@ "viaAlias": true } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/lint-multiple-responses.json b/fixtures/valid/lint-multiple-responses.json index 1485370..0e41e2a 100644 --- a/fixtures/valid/lint-multiple-responses.json +++ b/fixtures/valid/lint-multiple-responses.json @@ -17,9 +17,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature", - "reject invalid webhooks" - ] + "responses": ["verify the HMAC signature", "reject invalid webhooks"] } } diff --git a/fixtures/valid/lint-unparsed-tail.json b/fixtures/valid/lint-unparsed-tail.json index b8b995a..23f6b84 100644 --- a/fixtures/valid/lint-unparsed-tail.json +++ b/fixtures/valid/lint-unparsed-tail.json @@ -17,8 +17,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/lint-vague-response.json b/fixtures/valid/lint-vague-response.json index bcecd2c..efe5d0f 100644 --- a/fixtures/valid/lint-vague-response.json +++ b/fixtures/valid/lint-vague-response.json @@ -17,8 +17,6 @@ "role": "system" } }, - "responses": [ - "retry failed charges as needed" - ] + "responses": ["retry failed charges as needed"] } } diff --git a/fixtures/valid/optional-feature-basic.json b/fixtures/valid/optional-feature-basic.json index 6915920..fb9d38d 100644 --- a/fixtures/valid/optional-feature-basic.json +++ b/fixtures/valid/optional-feature-basic.json @@ -16,8 +16,6 @@ "role": "system" } }, - "responses": [ - "retry declined charges" - ] + "responses": ["retry declined charges"] } } diff --git a/fixtures/valid/optional-feature-multicurrency.json b/fixtures/valid/optional-feature-multicurrency.json index be4a2fb..1ea52f9 100644 --- a/fixtures/valid/optional-feature-multicurrency.json +++ b/fixtures/valid/optional-feature-multicurrency.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "convert charges to the customer currency" - ] + "responses": ["convert charges to the customer currency"] } } diff --git a/fixtures/valid/optional-feature-tax.json b/fixtures/valid/optional-feature-tax.json index c568f61..dc2d059 100644 --- a/fixtures/valid/optional-feature-tax.json +++ b/fixtures/valid/optional-feature-tax.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "add sales tax to each invoice" - ] + "responses": ["add sales tax to each invoice"] } } diff --git a/fixtures/valid/state-driven-basic.json b/fixtures/valid/state-driven-basic.json index 86dfad0..e9b5cee 100644 --- a/fixtures/valid/state-driven-basic.json +++ b/fixtures/valid/state-driven-basic.json @@ -16,8 +16,6 @@ "role": "system" } }, - "responses": [ - "queue retryable events" - ] + "responses": ["queue retryable events"] } } diff --git a/fixtures/valid/state-driven-maintenance.json b/fixtures/valid/state-driven-maintenance.json index 2d06e42..cdf6eb8 100644 --- a/fixtures/valid/state-driven-maintenance.json +++ b/fixtures/valid/state-driven-maintenance.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "reject incoming webhooks" - ] + "responses": ["reject incoming webhooks"] } } diff --git a/fixtures/valid/state-driven-provider-degraded.json b/fixtures/valid/state-driven-provider-degraded.json index 1d49c71..a7f7cab 100644 --- a/fixtures/valid/state-driven-provider-degraded.json +++ b/fixtures/valid/state-driven-provider-degraded.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "reduce the webhook batch size" - ] + "responses": ["reduce the webhook batch size"] } } diff --git a/fixtures/valid/ubiquitous-audit-log.json b/fixtures/valid/ubiquitous-audit-log.json index bc204c3..3246da9 100644 --- a/fixtures/valid/ubiquitous-audit-log.json +++ b/fixtures/valid/ubiquitous-audit-log.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "record an audit log entry for every processed webhook" - ] + "responses": ["record an audit log entry for every processed webhook"] } } diff --git a/fixtures/valid/ubiquitous-basic.json b/fixtures/valid/ubiquitous-basic.json index bb5ca7c..2a3ab1f 100644 --- a/fixtures/valid/ubiquitous-basic.json +++ b/fixtures/valid/ubiquitous-basic.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "verify the HMAC signature" - ] + "responses": ["verify the HMAC signature"] } } diff --git a/fixtures/valid/ubiquitous-retry-limit.json b/fixtures/valid/ubiquitous-retry-limit.json index 282eb7c..00fa3cb 100644 --- a/fixtures/valid/ubiquitous-retry-limit.json +++ b/fixtures/valid/ubiquitous-retry-limit.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "retry failed webhook deliveries up to five times" - ] + "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 index 99e73e3..5dfc8aa 100644 --- a/fixtures/valid/unwanted-behaviour-basic.json +++ b/fixtures/valid/unwanted-behaviour-basic.json @@ -16,8 +16,6 @@ "role": "system" } }, - "responses": [ - "reject the webhook" - ] + "responses": ["reject the webhook"] } } diff --git a/fixtures/valid/unwanted-behaviour-declined.json b/fixtures/valid/unwanted-behaviour-declined.json index 5d1a470..b928bc4 100644 --- a/fixtures/valid/unwanted-behaviour-declined.json +++ b/fixtures/valid/unwanted-behaviour-declined.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "notify the customer" - ] + "responses": ["notify the customer"] } } diff --git a/fixtures/valid/unwanted-behaviour-stale.json b/fixtures/valid/unwanted-behaviour-stale.json index 7a7a687..f85c510 100644 --- a/fixtures/valid/unwanted-behaviour-stale.json +++ b/fixtures/valid/unwanted-behaviour-stale.json @@ -12,8 +12,6 @@ "role": "system" } }, - "responses": [ - "discard the event" - ] + "responses": ["discard the event"] } } diff --git a/lerna.json b/lerna.json index 45d6e58..3753595 100644 --- a/lerna.json +++ b/lerna.json @@ -2,23 +2,15 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "independent", "npmClient": "pnpm", - "packages": [ - "packages/*" - ], + "packages": ["packages/*"], "command": { "version": { - "allowBranch": [ - "master", - "next" - ], + "allowBranch": ["master", "next"], "message": "chore(release): version packages [skip ci]", "conventionalCommits": true }, "publish": { - "allowBranch": [ - "master", - "next" - ], + "allowBranch": ["master", "next"], "registry": "https://registry.npmjs.org/", "conventionalCommits": true } diff --git a/package.json b/package.json index fefcded..591807c 100644 --- a/package.json +++ b/package.json @@ -55,4 +55,4 @@ "eslint --fix --no-warn-ignored" ] } -} \ No newline at end of file +} diff --git a/packages/cli-contract/src/json-report.test.ts b/packages/cli-contract/src/json-report.test.ts index e4b20fb..fb1bba9 100644 --- a/packages/cli-contract/src/json-report.test.ts +++ b/packages/cli-contract/src/json-report.test.ts @@ -28,10 +28,7 @@ describe('buildJsonReport', () => { it('preserves file and requirement input order', () => { const report = buildJsonReport(sampleRun); - expect(report.files.map((f) => f.path)).toEqual([ - 'specs/subscribe.ears', - 'specs/payment.ears', - ]); + expect(report.files.map((f) => f.path)).toEqual(['specs/subscribe.ears', 'specs/payment.ears']); expect(report.files[0]?.requirements.map((r) => r.id)).toEqual(['REQ-1', 'REQ-2']); }); diff --git a/packages/cli-contract/tsconfig.build.json b/packages/cli-contract/tsconfig.build.json index 86dfb00..5483128 100644 --- a/packages/cli-contract/tsconfig.build.json +++ b/packages/cli-contract/tsconfig.build.json @@ -8,6 +8,12 @@ "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" }, "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/__fixtures__/**", "node_modules", "dist"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/__fixtures__/**", + "node_modules", + "dist" + ], "references": [{ "path": "../core/tsconfig.build.json" }] } diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 652dc9a..17f284b 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -2,4 +2,4 @@ import { run } from '../dist/cli.js'; import process from 'node:process'; -process.exit(run(process.argv.slice(2))); \ No newline at end of file +process.exit(run(process.argv.slice(2))); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 63f81eb..f7fbd22 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -111,4 +111,4 @@ export function resolveGlobals(args: ParsedArgs): GlobalOptions { cwd: args.values.get('cwd'), config: args.values.get('config'), }; -} \ No newline at end of file +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 2f4d964..b404375 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -117,7 +117,15 @@ describe('convert loop (happy path)', () => { r('init', '--json'); - const createdRes = r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); + const createdRes = r( + 'new', + 'checkout-webhooks', + '--source', + 'source.md', + '--mode', + 'convert', + '--json', + ); expect(createdRes.code).toBe(0); const newBody = createdRes.json(); const work = newBody.work as Record; @@ -364,4 +372,4 @@ describe('new refusals', () => { expect(again.code).toBe(3); expect((again.json().diagnostics as { code: string }[])[0]?.code).toBe('new.exists'); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c7ec852..72666c3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -79,7 +79,12 @@ export function run(argv: string[], options: RunOptions = {}): number { return dispatch(command, rest, baseCwd, write); } -function dispatch(command: string, rest: string[], baseCwd: string, write: (text: string) => void): number { +function dispatch( + command: string, + rest: string[], + baseCwd: string, + write: (text: string) => void, +): number { const args = parseArgs(rest); const global = resolveGlobals(args); const cwd = global.cwd ? resolveInput(baseCwd, global.cwd) : baseCwd; @@ -89,7 +94,10 @@ function dispatch(command: string, rest: string[], baseCwd: string, write: (text try { if (!Object.hasOwn(COMMANDS, command)) { - throw usageError('cli.unknown_command', `Unknown command "${command}". Run \`earsyntax --help\`.`); + throw usageError( + 'cli.unknown_command', + `Unknown command "${command}". Run \`earsyntax --help\`.`, + ); } const handler = COMMANDS[command]; const context: CommandContext = { args, global, cwd, emitter }; @@ -128,4 +136,4 @@ function usageText(): string { '', 'Global options: --json --no-color --cwd --config --no-interactive', ].join('\n'); -} \ No newline at end of file +} diff --git a/packages/cli/src/color.ts b/packages/cli/src/color.ts index 456977e..a0ebf38 100644 --- a/packages/cli/src/color.ts +++ b/packages/cli/src/color.ts @@ -35,4 +35,4 @@ export function createPainter(enabled: boolean): Painter { return `${CODES[name]}${text}${CODES.reset}`; }, }; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/accept.ts b/packages/cli/src/commands/accept.ts index 0a85267..2cb906d 100644 --- a/packages/cli/src/commands/accept.ts +++ b/packages/cli/src/commands/accept.ts @@ -37,7 +37,10 @@ export function acceptCommand(context: CommandContext): number { } const outputHash = currentOutputHash(root, manifest); if (outputHash === undefined) { - throw refusalError('accept.output_missing', `Cannot accept "${slug}": the .ears output is unreadable.`); + throw refusalError( + 'accept.output_missing', + `Cannot accept "${slug}": the .ears output is unreadable.`, + ); } if (manifest.output.hash !== undefined && outputHash !== manifest.output.hash) { throw refusalError( @@ -70,4 +73,4 @@ export function acceptCommand(context: CommandContext): number { const pretty = `Accepted "${slug}"${by ? ` by ${by}` : ''}.`; emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 607c55d..9976cd5 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -100,7 +100,11 @@ export function doctorCommand(context: CommandContext): number { const diagnostics: FacadeDiagnostic[] = checks .filter((check) => !check.ok) - .map((check) => ({ code: `doctor.${check.name}`, severity: check.severity, message: check.message })); + .map((check) => ({ + code: `doctor.${check.name}`, + severity: check.severity, + message: check.message, + })); const hasError = checks.some((check) => !check.ok && check.severity === 'error'); @@ -114,4 +118,4 @@ export function doctorCommand(context: CommandContext): number { .join('\n'); emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bce3390..60873b5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -136,4 +136,4 @@ export function initCommand(context: CommandContext): number { emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts index 16bf861..43671dd 100644 --- a/packages/cli/src/commands/instructions.ts +++ b/packages/cli/src/commands/instructions.ts @@ -147,9 +147,10 @@ export function instructionsCommand(context: CommandContext): number { extra, ); - const pretty = [`instructions ${mode} for "${manifest.id}"`, ...rulesFor(mode).map((r) => ` - ${r}`)].join( - '\n', - ); + const pretty = [ + `instructions ${mode} for "${manifest.id}"`, + ...rulesFor(mode).map((r) => ` - ${r}`), + ].join('\n'); emit(context.emitter, response, pretty); return 0; diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 8c04605..fd9584c 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -31,4 +31,4 @@ export function listCommand(context: CommandContext): number { emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts index 9810d09..691f843 100644 --- a/packages/cli/src/commands/new.ts +++ b/packages/cli/src/commands/new.ts @@ -91,7 +91,10 @@ export function newCommand(context: CommandContext): number { const paths = workPaths(root, config, slug); if (existsSync(paths.manifest) && !force) { - throw refusalError('new.exists', `Work item "${slug}" already exists. Pass --force to recreate.`); + throw refusalError( + 'new.exists', + `Work item "${slug}" already exists. Pass --force to recreate.`, + ); } // Read and hash the source for convert mode. @@ -191,4 +194,4 @@ export function newCommand(context: CommandContext): number { emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/show.ts b/packages/cli/src/commands/show.ts index bac9b4e..c749179 100644 --- a/packages/cli/src/commands/show.ts +++ b/packages/cli/src/commands/show.ts @@ -76,4 +76,4 @@ export function showCommand(context: CommandContext): number { .join('\n'); emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index c3e35e7..c800a2d 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -12,7 +12,11 @@ import { buildResponse, emit } from '../response.js'; import { nextForStatus } from '../next-actions.js'; import { isStale, listSlugs, reportedStatus, requireManifest } from '../workspace.js'; -function resolveSlug(context: CommandContext, root: string, config: ReturnType): string { +function resolveSlug( + context: CommandContext, + root: string, + config: ReturnType, +): string { const explicit = context.args.positionals.at(0); if (explicit !== undefined) { return explicit; @@ -48,7 +52,12 @@ export function statusCommand(context: CommandContext): number { }; const response = buildResponse( - { command: 'status', ok: true, root, next: nextForStatus(manifest.id, status, manifest.source?.path) }, + { + command: 'status', + ok: true, + root, + next: nextForStatus(manifest.id, status, manifest.source?.path), + }, { work }, ); @@ -60,4 +69,4 @@ export function statusCommand(context: CommandContext): number { emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5bb21df..5742377 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -13,7 +13,12 @@ import { relative, resolve } from 'node:path'; import { type Catalog, lintEarsBatch, type Options } from '@earsyntax/core'; import { extractFromFile } from '@earsyntax/extract'; import type { CommandContext } from '../context.js'; -import type { FacadeDiagnostic, ValidationResult, WorkManifest, WorkStatus } from '../facade-types.js'; +import type { + FacadeDiagnostic, + ValidationResult, + WorkManifest, + WorkStatus, +} from '../facade-types.js'; import { usageError } from '../errors.js'; import { hashContent } from '../hash.js'; import { findRoot, loadConfig, requireRoot, resolveInput, toRelative } from '../project.js'; @@ -89,12 +94,18 @@ function inferWorkSlug(root: string, workDir: string, absFiles: string[]): strin } /** Render a short human-readable validation.md artifact. */ -function renderValidationMarkdown(results: ValidationResult[], errors: number, warnings: number): string { +function renderValidationMarkdown( + results: ValidationResult[], + errors: number, + warnings: number, +): string { const lines = ['# Validation', '', `errors: ${errors}, warnings: ${warnings}`, '']; for (const result of results) { for (const diagnostic of result.diagnostics) { const at = result.line === undefined ? '' : `:${result.line}`; - lines.push(`- ${diagnostic.severity} ${diagnostic.code} (${result.id ?? '?'}${at}) ${diagnostic.message}`); + lines.push( + `- ${diagnostic.severity} ${diagnostic.code} (${result.id ?? '?'}${at}) ${diagnostic.message}`, + ); } } lines.push(''); @@ -108,8 +119,11 @@ export function validateCommand(context: CommandContext): number { } const workId = context.args.values.get('work'); - const root = workId !== undefined ? requireRoot(context.cwd) : (findRoot(context.cwd) ?? context.cwd); - const config = existsSync(resolve(root, '.earsyntax')) ? loadConfig(root, context.global.config) : undefined; + const root = + workId !== undefined ? requireRoot(context.cwd) : (findRoot(context.cwd) ?? context.cwd); + const config = existsSync(resolve(root, '.earsyntax')) + ? loadConfig(root, context.global.config) + : undefined; const absFiles = expandFiles(context.cwd, patterns); const catalog = loadCatalog(context.cwd, context.args.values.get('catalog')); @@ -121,7 +135,10 @@ export function validateCommand(context: CommandContext): number { for (const abs of absFiles) { const extracted = extractFromFile(abs); if (extracted.items.length === 0 && extracted.errors.length > 0) { - throw usageError('validate.unreadable', extracted.errors.at(0)?.message ?? `Could not read ${abs}.`); + throw usageError( + 'validate.unreadable', + extracted.errors.at(0)?.message ?? `Could not read ${abs}.`, + ); } const fileRel = toRelative(root, abs); const lintResults = lintEarsBatch(extracted.items, catalog, options); @@ -275,15 +292,19 @@ function renderPretty( for (const result of results) { for (const diagnostic of result.diagnostics) { const at = result.line === undefined ? '' : `:${result.line}`; - lines.push(`${result.file}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`); + lines.push( + `${result.file}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, + ); } } for (const diagnostic of facadeDiagnostics) { const at = diagnostic.line === undefined ? '' : `:${diagnostic.line}`; - lines.push(`${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`); + lines.push( + `${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, + ); } lines.push( `\n${summary.valid}/${summary.requirements} valid, ${summary.errors} errors, ${summary.warnings} warnings`, ); return lines.join('\n'); -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts index 5746398..9f9e94f 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -10,7 +10,10 @@ import { buildResponse, emit } from '../response.js'; import { CLI_VERSION, FEATURES } from '../version.js'; export function versionCommand(context: CommandContext): number { - const response = buildResponse({ command: 'version', ok: true, next: [] }, { features: FEATURES }); + const response = buildResponse( + { command: 'version', ok: true, next: [] }, + { features: FEATURES }, + ); const pretty = context.args.booleans.has('features') ? [ @@ -25,4 +28,4 @@ export function versionCommand(context: CommandContext): number { emit(context.emitter, response, pretty); return 0; -} \ No newline at end of file +} diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 85ae4ea..1464134 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -18,4 +18,4 @@ export interface CommandContext { } /** A command handler: does its work, emits output, and returns an exit code. */ -export type CommandHandler = (context: CommandContext) => number; \ No newline at end of file +export type CommandHandler = (context: CommandContext) => number; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index da80fbc..4f48a62 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -28,11 +28,19 @@ export class CliError extends Error { } /** Build a usage/resolution error (exit 2). */ -export function usageError(code: string, message: string, extra?: Partial): CliError { +export function usageError( + code: string, + message: string, + extra?: Partial, +): CliError { return new CliError(2, { code, severity: 'error', message, ...extra }); } /** Build a refusal error (exit 3): overwrite protection, stale source, confirmation required. */ -export function refusalError(code: string, message: string, extra?: Partial): CliError { +export function refusalError( + code: string, + message: string, + extra?: Partial, +): CliError { return new CliError(3, { code, severity: 'error', message, ...extra }); -} \ No newline at end of file +} diff --git a/packages/cli/src/facade-types.ts b/packages/cli/src/facade-types.ts index 33ba0a1..4b59ed4 100644 --- a/packages/cli/src/facade-types.ts +++ b/packages/cli/src/facade-types.ts @@ -50,13 +50,7 @@ export type WorkMode = 'author' | 'convert'; /** The lifecycle state of a work item. */ export type WorkStatus = - | 'missing' - | 'scaffolded' - | 'drafted' - | 'invalid' - | 'valid' - | 'accepted' - | 'stale'; + 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; /** How a source file was classified for extraction. */ export type SourceKind = 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; @@ -122,4 +116,4 @@ export interface ValidationResult { ast?: EarsAst; references: ReferenceMatch[]; diagnostics: Diagnostic[]; -} \ No newline at end of file +} diff --git a/packages/cli/src/hash.ts b/packages/cli/src/hash.ts index ea74c59..0863019 100644 --- a/packages/cli/src/hash.ts +++ b/packages/cli/src/hash.ts @@ -12,4 +12,4 @@ import { createHash } from 'node:crypto'; export function hashContent(content: string): string { const digest = createHash('sha256').update(content, 'utf8').digest('hex'); return `sha256:${digest}`; -} \ No newline at end of file +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e94b321..5d03fad 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,4 +8,4 @@ export { run, type RunOptions } from './cli.js'; export { CLI_VERSION, FACADE_CONTRACT, FEATURES } from './version.js'; -export type * from './facade-types.js'; \ No newline at end of file +export type * from './facade-types.js'; diff --git a/packages/cli/src/mode.ts b/packages/cli/src/mode.ts index 9b418e4..893c607 100644 --- a/packages/cli/src/mode.ts +++ b/packages/cli/src/mode.ts @@ -18,4 +18,4 @@ export function requireInstructionMode(value: string | undefined): InstructionMo 'instructions.unknown_mode', `Unknown instructions mode "${value ?? ''}". Expected one of: ${MODES.join(', ')}.`, ); -} \ No newline at end of file +} diff --git a/packages/cli/src/next-actions.ts b/packages/cli/src/next-actions.ts index 412a6d1..bc701c9 100644 --- a/packages/cli/src/next-actions.ts +++ b/packages/cli/src/next-actions.ts @@ -58,4 +58,4 @@ export function nextForStatus(id: string, status: WorkStatus, source?: string): }, ]; } -} \ No newline at end of file +} diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts index f2a42c3..22a7e34 100644 --- a/packages/cli/src/project.ts +++ b/packages/cli/src/project.ts @@ -58,7 +58,9 @@ export function requireRoot(cwd: string): string { /** Load config from an explicit path or the project's `.earsyntax/config.json`. */ export function loadConfig(root: string, explicitPath?: string): EarsyntaxConfig { - const path = explicitPath ? resolve(root, explicitPath) : resolve(earsyntaxDir(root), 'config.json'); + const path = explicitPath + ? resolve(root, explicitPath) + : resolve(earsyntaxDir(root), 'config.json'); let raw: string; try { raw = readFileSync(path, 'utf8'); @@ -78,7 +80,9 @@ export function loadConfig(root: string, explicitPath?: string): EarsyntaxConfig return { version: typeof obj.version === 'number' ? obj.version : 1, workDir: typeof obj.workDir === 'string' ? obj.workDir : '.earsyntax/work', - tools: Array.isArray(obj.tools) ? obj.tools.filter((t): t is string => typeof t === 'string') : [], + tools: Array.isArray(obj.tools) + ? obj.tools.filter((t): t is string => typeof t === 'string') + : [], }; } @@ -94,4 +98,4 @@ export function toRelative(root: string, absPath: string): string { /** Resolve a possibly-relative user path against the resolved cwd. */ export function resolveInput(cwd: string, input: string): string { return isAbsolute(input) ? input : resolve(cwd, input); -} \ No newline at end of file +} diff --git a/packages/cli/src/response.ts b/packages/cli/src/response.ts index 0576298..3add432 100644 --- a/packages/cli/src/response.ts +++ b/packages/cli/src/response.ts @@ -57,7 +57,11 @@ export function emit(emitter: Emitter, response: FacadeResponse, pretty: string) } /** Build the response for a {@link CliError} so `run` can emit it uniformly. */ -export function errorResponse(command: string, root: string | undefined, error: CliError): FacadeResponse { +export function errorResponse( + command: string, + root: string | undefined, + error: CliError, +): FacadeResponse { return buildResponse( { command, @@ -68,4 +72,4 @@ export function errorResponse(command: string, root: string | undefined, error: }, {}, ); -} \ No newline at end of file +} diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts index 86c8abe..c710a91 100644 --- a/packages/cli/src/rules.ts +++ b/packages/cli/src/rules.ts @@ -101,4 +101,4 @@ export function exampleLine(mode: InstructionMode, sourcePath?: string): string case 'repair': return `REQ-002 [source: ${src}:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook.`; } -} \ No newline at end of file +} diff --git a/packages/cli/src/tool-wrappers.ts b/packages/cli/src/tool-wrappers.ts index 1d51408..12594d4 100644 --- a/packages/cli/src/tool-wrappers.ts +++ b/packages/cli/src/tool-wrappers.ts @@ -95,4 +95,4 @@ export function wrapperFilesFor(tool: string): WrapperFile[] { } /** The set of tool names `init --tools` understands. */ -export const KNOWN_TOOLS = new Set(['claude', 'codex', 'cursor']); \ No newline at end of file +export const KNOWN_TOOLS = new Set(['claude', 'codex', 'cursor']); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index fd62b7d..061917d 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -56,4 +56,4 @@ export const FEATURES: Features = { // SARIF output exists in @earsyntax/cli-contract but is not wired into the // CLI in this milestone; the flag is false so agents do not select it. sarif: false, -}; \ No newline at end of file +}; diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts index a7c466e..c993c06 100644 --- a/packages/cli/src/workspace.ts +++ b/packages/cli/src/workspace.ts @@ -102,7 +102,10 @@ export function sourceKind(path: string): SourceKind { * is absent or unreadable. `sourceInput` is the manifest-stored path resolved * against the root. */ -export function currentSourceHash(root: string, sourcePath: string | undefined): string | undefined { +export function currentSourceHash( + root: string, + sourcePath: string | undefined, +): string | undefined { if (sourcePath === undefined) { return undefined; } @@ -186,4 +189,4 @@ export function relPaths(root: string, paths: WorkPaths): Record validationJson: toRelative(root, paths.validationJson), validationMarkdown: toRelative(root, paths.validationMarkdown), }; -} \ No newline at end of file +} diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index c155d61..3c52bf1 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -8,7 +8,13 @@ "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" }, "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/__fixtures__/**", "node_modules", "dist"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/__fixtures__/**", + "node_modules", + "dist" + ], "references": [ { "path": "../core/tsconfig.build.json" }, { "path": "../cli-contract/tsconfig.build.json" }, diff --git a/packages/core/src/catalog.test.ts b/packages/core/src/catalog.test.ts index 26d795d..c20c42a 100644 --- a/packages/core/src/catalog.test.ts +++ b/packages/core/src/catalog.test.ts @@ -68,7 +68,11 @@ describe('resolveTerm: single match', () => { 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' }, + { + code: 'lint.alias_used', + severity: 'warning', + message: 'alias used instead of canonical term', + }, ]); }); @@ -99,7 +103,10 @@ describe('resolveTerm: unresolved', () => { 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']); + expect(diagnostics.map((d) => d.code)).toEqual([ + 'expr.unknown_term', + 'catalog.event_unresolved', + ]); }); it('downgrades an unresolved system to a warning in guided mode', () => { @@ -134,8 +141,16 @@ describe('resolveTerm: ambiguous', () => { { group: 'states', id: 'ST-B', name: 'session ended' }, ]); 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' }, + { + code: 'expr.ambiguous_term', + severity: 'warning', + message: 'ambiguous catalog term in expression', + }, + { + code: 'catalog.event_ambiguous', + severity: 'warning', + message: 'ambiguous catalog term match', + }, ]); }); @@ -149,7 +164,11 @@ describe('resolveTerm: ambiguous', () => { 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' }, + { + code: 'catalog.system_ambiguous', + severity: 'error', + message: 'ambiguous catalog term match', + }, ]); }); @@ -290,9 +309,9 @@ describe('resolveAndCollect', () => { const ast = buildAst(); const { references, diagnostics } = resolveAndCollect(ast, undefined); expect(diagnostics).toEqual([]); - expect(references.every((ref) => ref.matched === undefined && ref.unresolved === undefined)).toBe( - true, - ); + expect( + references.every((ref) => ref.matched === undefined && ref.unresolved === undefined), + ).toBe(true); }); }); @@ -315,7 +334,8 @@ describe('catalogCoverageDiagnostics', () => { 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', + message: + 'catalog events term "reverse thrust" (EV-2) is not referenced by any requirement text', }); }); diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 01214bb..e8d46af 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -170,7 +170,11 @@ function allowedGroups(role: TermRole, all: GroupEntries[]): GroupEntries[] { * 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[] { +export function findMatches( + raw: string, + requestedRole: TermRole, + catalog: Catalog, +): MatchCandidate[] { const key = normalizeKey(raw); if (key === '') { return []; @@ -311,7 +315,12 @@ export function resolveTerm( // Go, which emits both for every role). if (requestedRole !== 'system') { diagnostics.push( - makeDiagnostic('expr.ambiguous_term', 'warning', 'ambiguous catalog term in expression', span), + makeDiagnostic( + 'expr.ambiguous_term', + 'warning', + 'ambiguous catalog term in expression', + span, + ), ); } diagnostics.push( diff --git a/packages/core/src/diagnostics.test.ts b/packages/core/src/diagnostics.test.ts index 8f36572..8cd2290 100644 --- a/packages/core/src/diagnostics.test.ts +++ b/packages/core/src/diagnostics.test.ts @@ -161,9 +161,9 @@ describe('messageForCode', () => { 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( - '—', - ); + expect( + messageForCode(code, { term: 'x', clause: 'while', alias: 'a', canonical: 'b' }), + ).not.toContain('—'); } }); diff --git a/packages/core/src/expression-parser.test.ts b/packages/core/src/expression-parser.test.ts index 7294d2d..a942dbc 100644 --- a/packages/core/src/expression-parser.test.ts +++ b/packages/core/src/expression-parser.test.ts @@ -98,7 +98,10 @@ describe('parseClauseExpression', () => { 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 { 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'); diff --git a/packages/core/src/expression-parser.ts b/packages/core/src/expression-parser.ts index 9b31ea7..f76e000 100644 --- a/packages/core/src/expression-parser.ts +++ b/packages/core/src/expression-parser.ts @@ -130,7 +130,12 @@ function tokenizeExpression( } if (c === '(') { depth++; - tokens.push({ kind: TokenKind.LParen, text: '(', start: baseOffset + i, end: baseOffset + i + 1 }); + tokens.push({ + kind: TokenKind.LParen, + text: '(', + start: baseOffset + i, + end: baseOffset + i + 1, + }); i++; continue; } @@ -143,12 +148,22 @@ function tokenizeExpression( } else { depth--; } - tokens.push({ kind: TokenKind.RParen, text: ')', start: baseOffset + i, end: baseOffset + i + 1 }); + 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 }); + tokens.push({ + kind: TokenKind.Comma, + text: ',', + start: baseOffset + i, + end: baseOffset + i + 1, + }); i++; continue; } @@ -156,7 +171,15 @@ function tokenizeExpression( const start = i; while (i < raw.length) { const x = raw[i]; - if (x === ' ' || x === '\t' || x === '\n' || x === '\r' || x === '(' || x === ')' || x === ',') { + if ( + x === ' ' || + x === '\t' || + x === '\n' || + x === '\r' || + x === '(' || + x === ')' || + x === ',' + ) { break; } i++; diff --git a/packages/core/src/lint.test.ts b/packages/core/src/lint.test.ts index c681be8..ebcd60a 100644 --- a/packages/core/src/lint.test.ts +++ b/packages/core/src/lint.test.ts @@ -92,9 +92,7 @@ describe('lintEars: clause expression trees', () => { }); it('flags mixed and/or without grouping', () => { - const result = lintEars( - 'When a or b and c, the billing service shall process the event.', - ); + 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'); }); }); diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts index 46cc985..773ef50 100644 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -162,7 +162,11 @@ function runPipeline( const structural: Diagnostic[] = []; if (!shell.ast) { - return { references: [], structural: recoverFailedShell(text, shell.findings, opts), semantic: [] }; + return { + references: [], + structural: recoverFailedShell(text, shell.findings, opts), + semantic: [], + }; } // Re-parse each clause body into a real expression tree before catalog @@ -201,7 +205,10 @@ function runPipeline( // Split and lint the response; the AST gains its final responses through // construction rather than mutation. - const { responses, diagnostics: responseDiagnostics } = lintResponses(resolvedAst.responses, opts); + const { responses, diagnostics: responseDiagnostics } = lintResponses( + resolvedAst.responses, + opts, + ); const ast: EarsAst = { ...resolvedAst, responses }; const semantic: Diagnostic[] = [...catalogDiagnostics, ...responseDiagnostics]; @@ -244,8 +251,7 @@ function recoverFailedShell( let kept = findings; if (hasNoMatch) { kept = findings.filter( - (finding) => - finding.code !== 'ears.missing_shall' && finding.code !== 'ears.missing_system', + (finding) => finding.code !== 'ears.missing_shall' && finding.code !== 'ears.missing_system', ); } else if (hasMissingShall) { kept = findings.filter((finding) => finding.code !== 'ears.missing_system'); diff --git a/packages/core/src/shell-parser.ts b/packages/core/src/shell-parser.ts index 5982e50..d2d3f46 100644 --- a/packages/core/src/shell-parser.ts +++ b/packages/core/src/shell-parser.ts @@ -23,7 +23,15 @@ * matching. The same input always yields the same result. */ -import type { ClauseExpr, DiagnosticCode, EarsAst, FreeTextExpr, Pattern, Span, TermMatch } from './types.js'; +import type { + ClauseExpr, + DiagnosticCode, + EarsAst, + FreeTextExpr, + Pattern, + Span, + TermMatch, +} from './types.js'; /** * A raw structural finding produced by the shell parser. @@ -451,7 +459,11 @@ const LEADING_CLAUSE_KEYWORDS: readonly string[] = ['while', 'when', 'where', 'i * 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 } { +function scanUntilClauseBoundary( + text: string, + start: number, + commaAsAnd: boolean, +): { end: number; next: number } { let depth = 0; for (let i = start; i < text.length; i++) { const ch = text[i]; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 62b997a..a2d7537 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -61,14 +61,7 @@ export type ClauseType = 'while' | 'when' | 'where' | 'if'; * one onto these roles (for example `systems` entries carry role `system`). */ export type TermRole = - | 'system' - | 'actor' - | 'event' - | 'state' - | 'feature' - | 'mode' - | 'condition' - | 'data-term'; + 'system' | 'actor' | 'event' | 'state' | 'feature' | 'mode' | 'condition' | 'data-term'; /** * A half-open source offset range `[start, end)`. diff --git a/packages/core/test/shell-parser.test.ts b/packages/core/test/shell-parser.test.ts index 1d26104..5038d12 100644 --- a/packages/core/test/shell-parser.test.ts +++ b/packages/core/test/shell-parser.test.ts @@ -41,7 +41,9 @@ describe('parseShell: valid shell patterns', () => { }); it('parses an optional-feature requirement (Where)', () => { - const { ast, findings } = parseShell('Where analytics is enabled, the system shall log events.'); + 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'); @@ -74,7 +76,9 @@ describe('parseShell: case-insensitive keywords', () => { 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.'); + 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'); @@ -94,12 +98,16 @@ describe('parseShell: complex multi-clause', () => { }); 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.'); + 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.'); + 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'); @@ -135,7 +143,9 @@ describe('parseShell: commaAsAnd', () => { 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.'); + 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(); }); diff --git a/packages/extract/src/dispatch.test.ts b/packages/extract/src/dispatch.test.ts index 267c959..55cd017 100644 --- a/packages/extract/src/dispatch.test.ts +++ b/packages/extract/src/dispatch.test.ts @@ -6,8 +6,17 @@ import { extractFromContent, extractFromFile } from './dispatch.js'; describe('extractFromContent', () => { it('routes .ears content to the ears extractor', () => { - const { items } = extractFromContent('REQ-001: The billing service shall verify signatures.', 'requirements.ears'); - expect(items).toEqual([{ id: 'REQ-001', text: 'The billing service shall verify signatures.', source: { file: 'requirements.ears', line: 1 } }]); + const { items } = extractFromContent( + 'REQ-001: The billing service shall verify signatures.', + 'requirements.ears', + ); + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'The billing service shall verify signatures.', + source: { file: 'requirements.ears', line: 1 }, + }, + ]); }); it('routes .md and .markdown content to the markdown extractor', () => { @@ -17,13 +26,15 @@ describe('extractFromContent', () => { }); it('routes .yaml and .yml content to the yaml extractor', () => { - const yaml = 'requirements:\n - id: REQ-001\n text: The billing service shall verify signatures.\n'; + const yaml = + 'requirements:\n - id: REQ-001\n text: The billing service shall verify signatures.\n'; expect(extractFromContent(yaml, 'reqs.yaml').items[0].id).toBe('REQ-001'); expect(extractFromContent(yaml, 'reqs.yml').items[0].id).toBe('REQ-001'); }); it('routes .json content to the json extractor', () => { - const json = '{ "requirements": [ { "id": "REQ-001", "text": "The billing service shall verify signatures." } ] }'; + const json = + '{ "requirements": [ { "id": "REQ-001", "text": "The billing service shall verify signatures." } ] }'; expect(extractFromContent(json, 'reqs.json').items[0].id).toBe('REQ-001'); }); @@ -54,7 +65,13 @@ describe('extractFromFile', () => { const { items, errors } = extractFromFile(path); expect(errors).toEqual([]); - expect(items).toEqual([{ id: 'REQ-001', text: 'The billing service shall verify signatures.', source: { file: path, line: 1 } }]); + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'The billing service shall verify signatures.', + source: { file: path, line: 1 }, + }, + ]); }); it('reports a read error for a missing file rather than throwing', () => { diff --git a/packages/extract/src/dispatch.ts b/packages/extract/src/dispatch.ts index bb41af6..2d5a60f 100644 --- a/packages/extract/src/dispatch.ts +++ b/packages/extract/src/dispatch.ts @@ -40,7 +40,9 @@ export function extractFromContent(content: string, filename: string): ExtractRe default: return { items: [], - errors: [{ message: `Unsupported file extension: "${extension || filename}".`, file: filename }], + errors: [ + { message: `Unsupported file extension: "${extension || filename}".`, file: filename }, + ], }; } } diff --git a/packages/extract/src/ears.test.ts b/packages/extract/src/ears.test.ts index 2bd1017..02afc8a 100644 --- a/packages/extract/src/ears.test.ts +++ b/packages/extract/src/ears.test.ts @@ -26,7 +26,8 @@ describe('extractEars', () => { }); it('omits id when no ID prefix is present', () => { - const content = 'When a payment webhook is received, the billing service shall verify the HMAC signature.'; + const content = + 'When a payment webhook is received, the billing service shall verify the HMAC signature.'; const { items } = extractEars(content); @@ -37,7 +38,8 @@ describe('extractEars', () => { }); it('does not treat a colon inside requirement prose as an id prefix', () => { - const content = 'When the report is ready, the billing service shall emit: a summary and a total.'; + const content = + 'When the report is ready, the billing service shall emit: a summary and a total.'; const { items } = extractEars(content); @@ -46,9 +48,13 @@ describe('extractEars', () => { }); it('ignores comment lines and blank lines while keeping line numbers accurate', () => { - const content = ['# billing requirements', '', 'REQ-001: The billing service shall retain receipts.', ' ', '# trailing note'].join( - '\n', - ); + const content = [ + '# billing requirements', + '', + 'REQ-001: The billing service shall retain receipts.', + ' ', + '# trailing note', + ].join('\n'); const { items } = extractEars(content); @@ -78,13 +84,16 @@ describe('extractEars', () => { }); it('parses the metadata prefix source range, using the start line', () => { - const content = 'REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook.'; + const content = + 'REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook.'; const { items } = extractEars(content, 'requirements.ears'); expect(items[0].id).toBe('REQ-002'); expect(items[0].source).toEqual({ file: 'specs/checkout.md', line: 12 }); - expect(items[0].text).toBe('If the HMAC signature is invalid, then the billing service shall reject the webhook.'); + expect(items[0].text).toBe( + 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', + ); }); it('strips a malformed [source:] segment, keeps the id, and falls back to the physical location', () => { diff --git a/packages/extract/src/ears.ts b/packages/extract/src/ears.ts index 5726adf..4e9ae85 100644 --- a/packages/extract/src/ears.ts +++ b/packages/extract/src/ears.ts @@ -34,7 +34,9 @@ export function extractEars(content: string, file?: string): ExtractResult { const line = i + 1; const { id, ref, text } = splitId(trimmed); const source = - ref === undefined ? { ...(file === undefined ? {} : { file }), line } : { file: ref.file, line: ref.line }; + ref === undefined + ? { ...(file === undefined ? {} : { file }), line } + : { file: ref.file, line: ref.line }; items.push({ ...(id === undefined ? {} : { id }), text, diff --git a/packages/extract/src/json.test.ts b/packages/extract/src/json.test.ts index 4e5e1e7..578c971 100644 --- a/packages/extract/src/json.test.ts +++ b/packages/extract/src/json.test.ts @@ -6,8 +6,14 @@ describe('extractJson', () => { 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.' }, + { + 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, @@ -28,7 +34,8 @@ describe('extractJson', () => { }); it('tolerates missing ids', () => { - const content = '{ "requirements": [ { "text": "The billing service shall retain receipts." } ] }'; + const content = + '{ "requirements": [ { "text": "The billing service shall retain receipts." } ] }'; const { items, errors } = extractJson(content); @@ -39,7 +46,12 @@ describe('extractJson', () => { }); 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 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'); diff --git a/packages/extract/src/markdown.test.ts b/packages/extract/src/markdown.test.ts index 75eb8c6..32596b8 100644 --- a/packages/extract/src/markdown.test.ts +++ b/packages/extract/src/markdown.test.ts @@ -35,14 +35,19 @@ describe('extractMarkdown', () => { }); it('extracts numbered lists', () => { - const content = ['1. REQ-001: The billing service shall verify the HMAC signature.', '2) The billing service shall reject invalid webhooks.'].join( - '\n', - ); + const content = [ + '1. REQ-001: The billing service shall verify the HMAC signature.', + '2) The billing service shall reject invalid webhooks.', + ].join('\n'); const { items } = extractMarkdown(content); expect(items).toEqual([ - { id: 'REQ-001', text: 'The billing service shall verify the HMAC signature.', source: { line: 1 } }, + { + id: 'REQ-001', + text: 'The billing service shall verify the HMAC signature.', + source: { line: 1 }, + }, { text: 'The billing service shall reject invalid webhooks.', source: { line: 2 } }, ]); }); @@ -82,7 +87,11 @@ describe('extractMarkdown', () => { const { items } = extractMarkdown(content); expect(items).toEqual([ - { id: 'REQ-001', text: 'The billing service shall verify the HMAC signature.', source: { line: 3 } }, + { + id: 'REQ-001', + text: 'The billing service shall verify the HMAC signature.', + source: { line: 3 }, + }, { text: 'The billing service shall reject invalid webhooks.', source: { line: 4 } }, ]); }); @@ -112,9 +121,15 @@ describe('extractMarkdown', () => { }); it('ignores prose, headings, and horizontal rules', () => { - const content = ['# Heading', '', 'Just a paragraph of prose.', '', '---', '', '- REQ-001: The billing service shall verify signatures.'].join( - '\n', - ); + const content = [ + '# Heading', + '', + 'Just a paragraph of prose.', + '', + '---', + '', + '- REQ-001: The billing service shall verify signatures.', + ].join('\n'); const { items } = extractMarkdown(content); @@ -163,7 +178,8 @@ describe('extractMarkdown', () => { }); it('strips a malformed [source:] segment in a bullet and falls back to the physical location', () => { - const content = '- REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; + const content = + '- REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; const { items } = extractMarkdown(content, 'requirements.md'); diff --git a/packages/extract/src/markdown.ts b/packages/extract/src/markdown.ts index 63ae9d3..3025edf 100644 --- a/packages/extract/src/markdown.ts +++ b/packages/extract/src/markdown.ts @@ -41,9 +41,16 @@ export function extractMarkdown(content: string, file?: string): ExtractResult { const lines = content.split('\n'); let inFence = false; - const push = (id: string | undefined, text: string, line: number, ref: SourceRef | undefined): void => { + const push = ( + id: string | undefined, + text: string, + line: number, + ref: SourceRef | undefined, + ): void => { const source = - ref === undefined ? { ...(file === undefined ? {} : { file }), line } : { file: ref.file, line: ref.line }; + ref === undefined + ? { ...(file === undefined ? {} : { file }), line } + : { file: ref.file, line: ref.line }; items.push({ ...(id === undefined || id === '' ? {} : { id }), text, diff --git a/packages/extract/src/yaml.test.ts b/packages/extract/src/yaml.test.ts index 8cd270d..ec0ac56 100644 --- a/packages/extract/src/yaml.test.ts +++ b/packages/extract/src/yaml.test.ts @@ -29,7 +29,10 @@ describe('extractYaml', () => { }); it('tolerates missing ids', () => { - const content = ['requirements:', ' - text: The billing service shall retain receipts for seven years.'].join('\n'); + const content = [ + 'requirements:', + ' - text: The billing service shall retain receipts for seven years.', + ].join('\n'); const { items, errors } = extractYaml(content); @@ -41,9 +44,12 @@ describe('extractYaml', () => { }); 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 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'); @@ -74,6 +80,9 @@ describe('extractYaml', () => { }); it('returns no items for an empty document', () => { - expect(extractYaml('')).toEqual({ items: [], errors: [{ message: 'Expected a top-level "requirements" sequence.' }] }); + expect(extractYaml('')).toEqual({ + items: [], + errors: [{ message: 'Expected a top-level "requirements" sequence.' }], + }); }); }); diff --git a/specs/checkout.md b/specs/checkout.md index ce1fb90..dc7a8d2 100644 --- a/specs/checkout.md +++ b/specs/checkout.md @@ -16,4 +16,4 @@ 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. \ No newline at end of file +When a payment is declined, the customer should be notified quickly. From 4ea9b4f04998536e46987b03a10e6c2c7523c357 Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 29 Jul 2026 09:56:25 +0300 Subject: [PATCH 03/20] ci: replace blackbox workflows with earsyntax pipeline --- .github/ISSUE_TEMPLATE/bug_report.md | 6 +- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/dependabot.yml | 7 -- .github/pull_request_template.md | 1 - .github/verdaccio/config.yaml | 26 ----- .github/workflows/ci.yml | 71 ++++--------- .github/workflows/e2e-release.yml | 118 ---------------------- .github/workflows/e2e.yml | 77 -------------- .github/workflows/pr-title.yml | 6 +- .github/workflows/publish-packages.yml | 5 - .github/workflows/release-packages.yml | 7 -- .prettierignore | 10 ++ 12 files changed, 37 insertions(+), 299 deletions(-) delete mode 100644 .github/verdaccio/config.yaml delete mode 100644 .github/workflows/e2e-release.yml delete mode 100644 .github/workflows/e2e.yml create mode 100644 .prettierignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7dd4688..817d01c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,6 @@ --- name: Bug report -about: Report a problem with @earsyntax/blackbox +about: Report a problem with the earsyntax toolkit title: '[bug] ' labels: bug assignees: '' @@ -20,11 +20,11 @@ What you expected to happen. ## Environment -- `@earsyntax/blackbox` version: +- earsyntax package + version (e.g. `@earsyntax/core`): - Node version: - Package manager (npm/pnpm/yarn) + version: - OS: ## Additional context -Logs, coverage output, or anything else relevant. +Logs, diagnostics output, or anything else relevant. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bc189c8..aaf555a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Feature request -about: Suggest an idea for @earsyntax/blackbox +about: Suggest an idea for the earsyntax toolkit title: '[feature] ' labels: enhancement assignees: '' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d687370..ab386ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,13 +9,6 @@ updates: prefix: chore include: scope groups: - opentelemetry: - patterns: - - '@opentelemetry/*' - testcontainers: - patterns: - - 'testcontainers' - - '@testcontainers/*' eslint: patterns: - 'eslint' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 404b8b8..0629b38 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,7 +22,6 @@ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert - [ ] `pnpm lint` and `pnpm typecheck` pass - [ ] `pnpm build` succeeds - [ ] Tests added or updated where relevant -- [ ] For packaging changes: `pnpm check:packed` passes ## Notes for reviewers diff --git a/.github/verdaccio/config.yaml b/.github/verdaccio/config.yaml deleted file mode 100644 index 92ea8d6..0000000 --- a/.github/verdaccio/config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -storage: /verdaccio/storage - -uplinks: - npmjs: - url: https://registry.npmjs.org/ - -packages: - '@earsyntax/*': - access: $all - publish: $all - proxy: npmjs - - '**': - access: $all - publish: $all - proxy: npmjs - -# Anonymous publish is allowed (CI-only simulation registry); any auth token -# is accepted. Do not use this config for a real registry. -auth: - htpasswd: - file: ./htpasswd - max_users: -1 - -logs: - - { type: stdout, format: pretty, level: http } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1222d52..0df9876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,15 +8,14 @@ on: permissions: contents: read - checks: write env: PNPM_VERSION: 9.15.4 NODE_VERSION: 22.x jobs: - build: - name: Build + lint: + name: Lint runs-on: ubuntu-latest steps: - name: Checkout @@ -36,11 +35,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build - run: pnpm build + - name: Lint + run: pnpm lint - lint: - name: Lint & Format + format: + name: Format Check runs-on: ubuntu-latest steps: - name: Checkout @@ -60,11 +59,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint (packages) - run: pnpm -r --filter './packages/**' lint - - - name: Format check (packages) - run: pnpm prettier --check "packages/**/*.{ts,cjs,json,md}" + - name: Format check + run: pnpm format:check typecheck: name: Typecheck @@ -93,17 +89,9 @@ jobs: - name: Typecheck (tests) run: pnpm typecheck:tests - unit: - name: Unit Tests (${{ matrix.package }}) + build: + name: Build runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - package: - - blackbox - - blackbox-features - - blackbox-coverage - - blackbox-otel-static steps: - name: Checkout uses: actions/checkout@v5 @@ -122,24 +110,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Test - run: > - pnpm --filter "./packages/${{ matrix.package }}" test -- - --reporter=verbose - --reporter=junit - --outputFile.junit=./junit.xml - - - name: Publish test results - if: always() - uses: dorny/test-reporter@v2 - with: - name: Unit Tests (${{ matrix.package }}) - path: packages/${{ matrix.package }}/junit.xml - reporter: java-junit - fail-on-error: true + - name: Build + run: pnpm build - deps: - name: Dependency Boundaries + test: + name: Test runs-on: ubuntu-latest steps: - name: Checkout @@ -159,11 +134,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check dependency boundaries - run: pnpm check:deps + - name: Test + run: pnpm test - smoke: - name: Smoke Tests + deps: + name: Dependency Boundaries runs-on: ubuntu-latest steps: - name: Checkout @@ -183,11 +158,5 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build OTEL bootstrap image - run: pnpm build:otel-image - - - name: Build - run: pnpm build - - - name: Smoke tests - run: pnpm test + - name: Check dependency boundaries + run: pnpm check:deps diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml deleted file mode 100644 index a4280a0..0000000 --- a/.github/workflows/e2e-release.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: E2E Release Simulation - -env: - CI: true - -on: - workflow_call: - inputs: - target_branch: - description: 'Branch to simulate release from' - required: true - type: string - workflow_dispatch: - inputs: - target_branch: - description: 'Branch to simulate release from' - required: true - type: string - default: 'next' - -permissions: - contents: read - -env: - PNPM_VERSION: 9.15.4 - NODE_VERSION: 22.x - REGISTRY_URL: http://localhost:4873 - -jobs: - release-e2e: - name: Packed-artifact E2E via Verdaccio - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - ref: ${{ github.event.pull_request.head.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: Build - run: pnpm build - - # Validates the core packaging guarantee before we even publish: no - # catalog:/workspace: specifier survives into the packed manifests. - - name: Check packed specifiers - run: pnpm check:packed - - - name: Start Verdaccio - run: | - docker run -d --name verdaccio \ - -p 4873:4873 \ - -v ${{ github.workspace }}/.github/verdaccio/config.yaml:/verdaccio/conf/config.yaml \ - verdaccio/verdaccio - for i in $(seq 1 30); do - if curl -sf http://localhost:4873/-/ping >/dev/null; then - echo "Verdaccio is up"; break - fi - sleep 2 - done - - - name: Configure registry auth - run: | - cat >> .npmrc <<'EOF' - @earsyntax:registry=http://localhost:4873/ - //localhost:4873/:_authToken=blackbox-e2e - EOF - - # Verdaccio has no OIDC; provenance would fail. Strip it for the - # simulation only (the real publish workflow keeps it). - - name: Strip provenance for simulation - run: | - sudo apt-get install -y jq - for f in packages/*/package.json; do - jq 'del(.publishConfig.provenance)' "$f" > "$f.tmp" && mv "$f.tmp" "$f" - done - - - name: Publish packages to Verdaccio - run: pnpm -r --filter './packages/**' publish --no-git-checks --registry http://localhost:4873/ - - - name: Capture released version - id: ver - run: echo "version=$(node -p "require('./packages/blackbox/package.json').version")" >> "$GITHUB_OUTPUT" - - # Materialize a catalog-free consumer (npm has no catalog: support) and - # install the just-published tarballs from Verdaccio with plain npm. - - name: Prepare release consumer - run: node scripts/prepare-release-consumer.mjs /tmp/release-consumer ${{ steps.ver.outputs.version }} - - - name: Install consumer from Verdaccio - working-directory: /tmp/release-consumer - run: npm install --no-audit --no-fund - - - name: Run e2e against published packages - working-directory: /tmp/release-consumer - run: npx playwright test --config ./playwright.config.ts - - - name: Upload consumer report - if: always() - uses: actions/upload-artifact@v4 - with: - name: release-e2e-report - path: /tmp/release-consumer/.playwright-report/ - if-no-files-found: ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 3d15336..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: E2E Tests - -env: - CI: true - -on: - pull_request: - branches: - - main - - next - workflow_dispatch: - inputs: - target_branch: - description: 'Branch to run e2e from' - required: true - type: string - default: 'next' - -permissions: - contents: read - -env: - PNPM_VERSION: 9.15.4 - NODE_VERSION: 22.x - -jobs: - e2e: - name: Subscription Service E2E (testcontainers) - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - ref: ${{ github.event.pull_request.head.ref || inputs.target_branch }} - - - 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 - - # CRITICAL: the OTEL bootstrap travels as a Docker image so its runtime - # dependencies are real files inside the container filesystem. - - name: Build OTEL bootstrap image - run: pnpm build:otel-image - - # e2e consumes @earsyntax/blackbox through its dist exports map. - - name: Build - run: pnpm build - - - name: Run system e2e - run: pnpm test:system - - - name: Upload coverage artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: blackbox-coverage - path: e2e/.blackbox-coverage/ - if-no-files-found: ignore - - - name: Upload Playwright report - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: e2e/.playwright-report/ - if-no-files-found: ignore diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 953a6ce..1415992 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -2,7 +2,7 @@ name: PR Title # Enforces a Conventional Commits prefix on every pull request title, e.g. # feat(core): add catalog glob matcher -# fix(runners/bdd): resolve catalog source for scenario specs +# 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. @@ -40,8 +40,8 @@ jobs: ci chore revert - # Scopes are optional and free-form (e.g. core, drivers, - # runners/playwright, runners/bdd, otel.bootstrap). + # 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]).+$ diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index f57ea04..107c88e 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -63,11 +63,6 @@ jobs: - name: Build run: pnpm build - # Hard gate: fail before publish if any catalog:/workspace: specifier - # would ship verbatim (lerna's blind spot; pnpm publish resolves them). - - name: Check packed specifiers - run: pnpm check:packed - - name: Capture versions for report run: | echo "## Publishing to npm" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 3dbfe25..8bc440e 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -45,15 +45,8 @@ env: NODE_VERSION: 22.x jobs: - release-e2e: - name: Release E2E Gate - uses: ./.github/workflows/e2e-release.yml - with: - target_branch: ${{ github.event.inputs.target_branch }} - tag-version: name: Version & Tag - needs: [release-e2e] runs-on: ubuntu-latest steps: - name: Checkout 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 From ad9c74418fd558f180be5d60e56012b557677b3f Mon Sep 17 00:00:00 2001 From: omermorad Date: Wed, 29 Jul 2026 10:06:57 +0300 Subject: [PATCH 04/20] ci: build before lint and test jobs --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df9876..6e0420f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: - 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 @@ -134,6 +139,11 @@ jobs: - 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 From e3ec3212d2930851aa998ebff2ec61c251633236 Mon Sep 17 00:00:00 2001 From: omermorad Date: Thu, 30 Jul 2026 00:42:43 +0300 Subject: [PATCH 05/20] chore: pin node 22 via engines fields and .nvmrc --- .nvmrc | 1 + packages/cli-contract/package.json | 3 +++ packages/cli/package.json | 3 +++ packages/core/package.json | 3 +++ packages/extract/package.json | 3 +++ 5 files changed, 13 insertions(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/packages/cli-contract/package.json b/packages/cli-contract/package.json index 811c103..1c076e3 100644 --- a/packages/cli-contract/package.json +++ b/packages/cli-contract/package.json @@ -33,6 +33,9 @@ "verify": "pnpm build && pnpm test", "prepack": "pnpm build" }, + "engines": { + "node": ">=22" + }, "dependencies": { "@earsyntax/core": "workspace:*" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 502379a..7879c23 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "engines": { + "node": ">=22" + }, "dependencies": { "@earsyntax/cli-contract": "workspace:*", "@earsyntax/core": "workspace:*", diff --git a/packages/core/package.json b/packages/core/package.json index 8ae3f1c..da06959 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,9 @@ "test:watch": "vitest", "typecheck": "pnpm tsc -p tsconfig.build.json --noEmit" }, + "engines": { + "node": ">=22" + }, "devDependencies": { "@types/node": "catalog:", "rimraf": "catalog:", diff --git a/packages/extract/package.json b/packages/extract/package.json index 32f43af..fc062ca 100644 --- a/packages/extract/package.json +++ b/packages/extract/package.json @@ -31,6 +31,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "engines": { + "node": ">=22" + }, "dependencies": { "@earsyntax/core": "workspace:*", "js-yaml": "catalog:" From 4f0b955848adb53f9fca4c06abb167dfa2a58c62 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 19:26:32 +0300 Subject: [PATCH 06/20] docs: freeze host-native contracts, findings model, profile schema, id migration --- docs/contracts/findings.md | 194 +++++++++++ docs/contracts/profile.md | 175 ++++++++++ docs/refactor/host-native-facade.md | 511 ++++++++++++++++++++++++++++ docs/refactor/inventory.md | 381 +++++++++++++++++++++ 4 files changed, 1261 insertions(+) create mode 100644 docs/contracts/findings.md create mode 100644 docs/contracts/profile.md create mode 100644 docs/refactor/host-native-facade.md create mode 100644 docs/refactor/inventory.md diff --git a/docs/contracts/findings.md b/docs/contracts/findings.md new file mode 100644 index 0000000..347b4bd --- /dev/null +++ b/docs/contracts/findings.md @@ -0,0 +1,194 @@ +# 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 current CLI does not emit this model. `validate` today builds a +`ValidationResult[]` shape (`packages/cli/src/facade-types.ts:110-119`) with +`references`, `ast`, and core `Diagnostic` objects keyed by the old +`DiagnosticCode` union. The current `@earsyntax/cli-contract` `JsonReport` +(`packages/cli-contract/src/json-report.ts`) is a separate projection with an +`infos` count and `span` offsets. + +Findings v1 replaces both as the canonical result: + +- `Diagnostic.id` is the new `EARS-E###` / `EARS-W###` ID, not the old dotted + code. The migration table is in `docs/refactor/host-native-facade.md`. +- Positions are `file` + `line` (+ `col`), mapped to the original host + document, replacing the character `span` offsets that only made sense for + single-requirement input. +- There is no `references`, `ast`, `pattern`, or `infos` in the Findings model. + A command MAY expose parser detail (pattern, AST) as its own optional + command-specific data, but that data is not part of the frozen Findings + contract and `ok` never depends on it. +- `summary` gains `valid` (count of clean requirements) and drops `infos`. diff --git a/docs/contracts/profile.md b/docs/contracts/profile.md new file mode 100644 index 0000000..52e4fcc --- /dev/null +++ b/docs/contracts/profile.md @@ -0,0 +1,175 @@ +# 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'; + heading?: string; + listMarker?: 'bullet' | 'ordered' | 'any'; + note?: string; +} +``` + +`LocatorRule` is the unit the extractor reports as the matching rule for each +candidate (`extract` returns `locatorRuleId`). Its concrete field set is +finalized by the profile schema agent (plan Agent 03) and the host profile +agents (plan Agents 09-12); the `id` field and the four `kind` values are the +frozen part, because `extract` output and profile fixtures depend on them. + +## 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`. | + +## 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. 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/refactor/host-native-facade.md b/docs/refactor/host-native-facade.md new file mode 100644 index 0000000..1ee1a25 --- /dev/null +++ b/docs/refactor/host-native-facade.md @@ -0,0 +1,511 @@ +# 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` | + +### 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, one per old code, no gaps. + +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..66a4fba --- /dev/null +++ b/docs/refactor/inventory.md @@ -0,0 +1,381 @@ +# 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`. Only `extractFromFile` touches disk. (`packages/extract/src/`) | +| `@earsyntax/cli-contract` | Report projections: JSON report, SARIF, pretty, exit codes, diagnostic registry descriptions. 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` already contains a `JsonReport`, a `SarifLog` +builder, an exit-code helper, and a `DIAGNOSTIC_DESCRIPTIONS` registry keyed by +the 29-code `DiagnosticCode` union. The CLI does not currently consume these +projections; `validate` builds its own `ValidationResult[]` shape 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 via `extractFromFile` (`validate.ts:136`); unreadable file with + errors throws `validate.unreadable` (exit `2`). +- Lint via `lintEarsBatch` (`validate.ts:144`). +- 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, 29 codes) + +`packages/core/src/types.ts:191-228` defines the `DiagnosticCode` union. +`packages/cli-contract/src/diagnostic-registry.ts:19-57` maps each to a +one-line description. `docs/diagnostics.md` documents severity by mode. + +Groups: `ears.*` (8), `expr.*` (7), `catalog.*` (9), `lint.*` (5). Total 29. +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/`). From 0db64dd1eaa2cd1c009cca835d7b0e62d2bd0a3a Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 20:07:29 +0300 Subject: [PATCH 07/20] feat(core): diagnostic registry, findings model, profile schema, core hardening Registry with EARS-E/W ids and alias resolution, findings model v1 with effective severity, profile schema v1 with five builtins, parser crash fixes (recursion depth, span merge), catalog canonical precedence, span coverage for not/group expressions. Go deviations documented. --- docs/compatibility.md | 13 + docs/contracts/profile.md | 31 +- docs/diagnostics.md | 84 +-- fixtures/diagnostics/migration-table.json | 34 ++ fixtures/ears-lint-go-parity/K901.json | 8 +- fixtures/ears-lint-go-parity/V102.json | 2 +- .../schema/invalid/bad-enum-keywordcase.json | 23 + .../schema/invalid/bad-locator-kind.json | 23 + .../schema/invalid/bad-regex-pattern.json | 23 + .../schema/invalid/missing-dialect.json | 15 + .../invalid/unknown-nested-locator-key.json | 25 + .../schema/invalid/unknown-severity-id.json | 23 + .../schema/invalid/unknown-top-key.json | 24 + .../profiles/schema/valid/full-markdown.json | 37 ++ .../profiles/schema/valid/minimal-strict.json | 20 + .../cli-contract/src/diagnostic-registry.ts | 10 +- packages/cli-contract/src/exit-codes.test.ts | 48 +- packages/cli-contract/src/exit-codes.ts | 34 +- .../cli-contract/src/findings-report.test.ts | 102 ++++ packages/cli-contract/src/findings-report.ts | 78 +++ packages/cli-contract/src/index.ts | 34 +- packages/cli-contract/src/json-report.test.ts | 83 --- packages/cli-contract/src/json-report.ts | 164 ------ packages/core/src/catalog.test.ts | 55 +- packages/core/src/catalog.ts | 50 +- packages/core/src/expression-parser.test.ts | 64 +++ packages/core/src/expression-parser.ts | 123 ++++- packages/core/src/findings.test.ts | 289 ++++++++++ packages/core/src/findings.ts | 294 ++++++++++ packages/core/src/index.ts | 60 +++ packages/core/src/profiles/builtins.test.ts | 85 +++ packages/core/src/profiles/builtins.ts | 211 ++++++++ packages/core/src/profiles/diff.test.ts | 114 ++++ packages/core/src/profiles/diff.ts | 122 +++++ packages/core/src/profiles/index.ts | 36 ++ packages/core/src/profiles/registry-ids.ts | 35 ++ packages/core/src/profiles/resolve.test.ts | 34 ++ packages/core/src/profiles/resolve.ts | 55 ++ packages/core/src/profiles/schema.test.ts | 187 +++++++ packages/core/src/profiles/schema.ts | 486 +++++++++++++++++ packages/core/src/registry.test.ts | 203 +++++++ packages/core/src/registry.ts | 505 ++++++++++++++++++ packages/core/test/profiles-schema.test.ts | 70 +++ 43 files changed, 3597 insertions(+), 419 deletions(-) create mode 100644 fixtures/diagnostics/migration-table.json create mode 100644 fixtures/profiles/schema/invalid/bad-enum-keywordcase.json create mode 100644 fixtures/profiles/schema/invalid/bad-locator-kind.json create mode 100644 fixtures/profiles/schema/invalid/bad-regex-pattern.json create mode 100644 fixtures/profiles/schema/invalid/missing-dialect.json create mode 100644 fixtures/profiles/schema/invalid/unknown-nested-locator-key.json create mode 100644 fixtures/profiles/schema/invalid/unknown-severity-id.json create mode 100644 fixtures/profiles/schema/invalid/unknown-top-key.json create mode 100644 fixtures/profiles/schema/valid/full-markdown.json create mode 100644 fixtures/profiles/schema/valid/minimal-strict.json create mode 100644 packages/cli-contract/src/findings-report.test.ts create mode 100644 packages/cli-contract/src/findings-report.ts delete mode 100644 packages/cli-contract/src/json-report.test.ts delete mode 100644 packages/cli-contract/src/json-report.ts create mode 100644 packages/core/src/findings.test.ts create mode 100644 packages/core/src/findings.ts create mode 100644 packages/core/src/profiles/builtins.test.ts create mode 100644 packages/core/src/profiles/builtins.ts create mode 100644 packages/core/src/profiles/diff.test.ts create mode 100644 packages/core/src/profiles/diff.ts create mode 100644 packages/core/src/profiles/index.ts create mode 100644 packages/core/src/profiles/registry-ids.ts create mode 100644 packages/core/src/profiles/resolve.test.ts create mode 100644 packages/core/src/profiles/resolve.ts create mode 100644 packages/core/src/profiles/schema.test.ts create mode 100644 packages/core/src/profiles/schema.ts create mode 100644 packages/core/src/registry.test.ts create mode 100644 packages/core/src/registry.ts create mode 100644 packages/core/test/profiles-schema.test.ts diff --git a/docs/compatibility.md b/docs/compatibility.md index b4d7b3e..04f384c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -68,6 +68,9 @@ for those cases. | 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. | ## Behaviors where the fixtures follow the Go reference verbatim @@ -89,6 +92,16 @@ the behavior to pass them. 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 diff --git a/docs/contracts/profile.md b/docs/contracts/profile.md index 52e4fcc..a61ed9b 100644 --- a/docs/contracts/profile.md +++ b/docs/contracts/profile.md @@ -41,17 +41,33 @@ interface Profile { interface LocatorRule { id: string; kind: 'every-line' | 'heading-section' | 'list-item' | 'block'; - heading?: string; + 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`). Its concrete field set is -finalized by the profile schema agent (plan Agent 03) and the host profile -agents (plan Agents 09-12); the `id` field and the four `kind` values are the -frozen part, because `extract` output and profile fixtures depend on them. +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 @@ -115,7 +131,10 @@ severity is never hard-coded in the linter. 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. A malformed profile is an environment failure: exit `2` (see the facade +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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1798fa7..e8ad500 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,66 +27,74 @@ Severity depends on the finding's code and the active 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. "Severity" columns show the value in each mode. +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. -| Code | Meaning | strict | guided | Example trigger | -| --------------------------- | ----------------------------------------------------------- | ------- | --------- | ------------------------------------------------------------------- | -| `ears.no_match` | The text does not match any supported EARS shell pattern. | `error` | `warning` | `The quick brown fox.` | -| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | `error` | `warning` | `When the timer fires, while idle, the system shall reset.` | -| `ears.missing_system` | The system name before `shall` is absent or empty. | `error` | `warning` | `When the timer fires, shall reset.` | -| `ears.missing_shall` | The requirement has no single `shall` response boundary. | `error` | `warning` | `The system resets the timer.` | -| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | `error` | `warning` | `The system shall reset and shall log the event.` | -| `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.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | `error` | `warning` | `When , the system shall reset.` | -| `ears.empty_response` | The response after `shall` is empty. | `error` | `warning` | `The system shall .` | +| 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` | `The 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 .` | ### `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. -| Code | Meaning | strict | guided | Example trigger | -| ---------------------------------- | ------------------------------------------------------ | --------- | --------- | ---------------------------------------------------- | -| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | `error` | `warning` | `When (A and B, the system shall reset.` | -| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | `error` | `warning` | `While A or or B, the system shall reset.` | -| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | `error` | `warning` | `While A and (), the system shall reset.` | -| `expr.operator_precedence_warning` | A mixed `and`/`or` expression may need parentheses. | `warning` | `warning` | `While A and B or C, the system shall reset.` | -| `expr.unknown_term` | A clause term matches no catalog entry for its role. | `warning` | `warning` | A `When` term absent from the catalog. | -| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | `warning` | `warning` | A term whose name collides across catalog groups. | -| `expr.mixed_unresolved_terms` | One clause mixes resolved and unresolved terms. | `warning` | `warning` | `While A and B` where `A` resolves and `B` does not. | +| 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. -| Code | Meaning | strict | guided | Example trigger | -| ---------------------------- | ------------------------------------------------------------- | --------- | --------- | ---------------------------------------------------------------- | -| `catalog.system_unresolved` | The system name matches no known system. | `error` | `warning` | `The invoicing engine shall ...` when only `BFF` is cataloged. | -| `catalog.system_ambiguous` | The system name matches more than one known system. | `error` | `warning` | A system name that two catalog entries share. | -| `catalog.state_unresolved` | A state term matches no known state. | `warning` | `warning` | `While the queue is draining, ...` with no such state. | -| `catalog.state_ambiguous` | A state term matches more than one known state. | `warning` | `warning` | A state name two entries share. | -| `catalog.event_unresolved` | An event term matches no known event. | `warning` | `warning` | `When a refund is requested, ...` with no such event. | -| `catalog.event_ambiguous` | An event term matches more than one known event. | `warning` | `warning` | An event name two entries share. | -| `catalog.feature_unresolved` | A feature term matches no known feature. | `warning` | `warning` | `Where retries are enabled, ...` with no such feature. | -| `catalog.feature_ambiguous` | A feature term matches more than one known feature. | `warning` | `warning` | A feature name two entries share. | -| `catalog.term_unreferenced` | A cataloged term is never referenced by any requirement text. | `warning` | `warning` | A `payment-http` entry no requirement mentions (coverage check). | +| 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 `payment-http` entry no requirement mentions (coverage check). | ### `lint.*` style advice Stylistic findings that never affect validity. All are always warnings. -| Code | Meaning | strict | guided | Example trigger | -| ---------------------------- | --------------------------------------------------------- | --------- | --------- | -------------------------------------------------------- | -| `lint.multiple_responses` | The response holds several semicolon-joined responses. | `warning` | `warning` | `The system shall log the event; notify the operator.` | -| `lint.vague_response` | The response contains a configured vague term. | `warning` | `warning` | `The system shall respond as needed.` | -| `lint.unparsed_tail` | Text remains after the parsed requirement. | `warning` | `warning` | Trailing tokens the expression parser could not consume. | -| `lint.alias_used` | A catalog alias matched; the canonical name is preferred. | `warning` | `warning` | `db` matching a `Postgres` entry via an alias. | -| `lint.suspicious_text_shape` | The sentence shape is likely accidental or malformed. | `warning` | `warning` | Guided-mode text that resembles no EARS shell. | +| 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` | Guided-mode text that resembles no EARS shell. | ## Ordering diff --git a/fixtures/diagnostics/migration-table.json b/fixtures/diagnostics/migration-table.json new file mode 100644 index 0000000..918350d --- /dev/null +++ b/fixtures/diagnostics/migration-table.json @@ -0,0 +1,34 @@ +{ + "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-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/K901.json b/fixtures/ears-lint-go-parity/K901.json index 82f787a..fe261a1 100644 --- a/fixtures/ears-lint-go-parity/K901.json +++ b/fixtures/ears-lint-go-parity/K901.json @@ -278,15 +278,15 @@ "preconditions": { "kind": "group", "span": { - "start": 7, - "end": 124 + "start": 6, + "end": 127 } }, "trigger": { "kind": "group", "span": { - "start": 136, - "end": 222 + "start": 134, + "end": 224 } }, "system": { diff --git a/fixtures/ears-lint-go-parity/V102.json b/fixtures/ears-lint-go-parity/V102.json index 117c83e..8b52f83 100644 --- a/fixtures/ears-lint-go-parity/V102.json +++ b/fixtures/ears-lint-go-parity/V102.json @@ -270,7 +270,7 @@ "kind": "and", "span": { "start": 6, - "end": 90 + "end": 91 } }, "trigger": { 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..3d9133c --- /dev/null +++ b/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json @@ -0,0 +1,25 @@ +{ + "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..ebaf35b --- /dev/null +++ b/fixtures/profiles/schema/invalid/unknown-severity-id.json @@ -0,0 +1,23 @@ +{ + "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/packages/cli-contract/src/diagnostic-registry.ts b/packages/cli-contract/src/diagnostic-registry.ts index b518f9c..ae594f7 100644 --- a/packages/cli-contract/src/diagnostic-registry.ts +++ b/packages/cli-contract/src/diagnostic-registry.ts @@ -16,7 +16,7 @@ import type { DiagnosticCode } from '@earsyntax/core'; * code means, not how to fix it. The key set is exhaustive by construction * (`Record`). */ -export const DIAGNOSTIC_DESCRIPTIONS: Record = { +export const DIAGNOSTIC_DESCRIPTIONS: Readonly> = Object.freeze({ // EARS shell diagnostics. 'ears.no_match': 'Text does not match any EARS shell pattern.', 'ears.invalid_clause_order': 'EARS clauses appear in an invalid order.', @@ -54,7 +54,7 @@ export const DIAGNOSTIC_DESCRIPTIONS: Record = { 'lint.unparsed_tail': 'Trailing text after the requirement could not be parsed.', 'lint.alias_used': 'A term was matched via an alias rather than its canonical name.', 'lint.suspicious_text_shape': 'Requirement text has a suspicious shape.', -}; +}); /** * Every diagnostic code, sorted lexicographically. @@ -62,6 +62,6 @@ export const DIAGNOSTIC_DESCRIPTIONS: Record = { * Sorting makes the SARIF rule list deterministic regardless of the key order * the registry object was written in. */ -export const DIAGNOSTIC_CODES: DiagnosticCode[] = ( - Object.keys(DIAGNOSTIC_DESCRIPTIONS) as DiagnosticCode[] -).sort(); +export const DIAGNOSTIC_CODES: readonly DiagnosticCode[] = Object.freeze( + (Object.keys(DIAGNOSTIC_DESCRIPTIONS) as DiagnosticCode[]).sort(), +); diff --git a/packages/cli-contract/src/exit-codes.test.ts b/packages/cli-contract/src/exit-codes.test.ts index 4ea7a8f..361de26 100644 --- a/packages/cli-contract/src/exit-codes.test.ts +++ b/packages/cli-contract/src/exit-codes.test.ts @@ -1,48 +1,30 @@ import { describe, expect, it } from 'vitest'; -import { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForReport } from './exit-codes.js'; -import { buildJsonReport } from './json-report.js'; -import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; +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('match the orchestration contract', () => { + 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('exitCodeForReport', () => { - it('returns EXIT_LINT_ERRORS when the report has error diagnostics', () => { - expect(exitCodeForReport(buildJsonReport(sampleRun))).toBe(EXIT_LINT_ERRORS); +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 the report has no error diagnostics', () => { - expect(exitCodeForReport(buildJsonReport(cleanRun))).toBe(EXIT_OK); + it('returns EXIT_OK when findings have no error diagnostics', () => { + expect(exitCodeForFindings(findings({}))).toBe(EXIT_OK); }); - it('treats warnings and infos alone as EXIT_OK', () => { - const report = buildJsonReport([ - { - path: 'specs/warn.ears', - items: [ - { - input: { text: 'The BFF shall respond appropriately.' }, - result: { - valid: true, - references: [], - diagnostics: [ - { - code: 'lint.vague_response', - severity: 'warning', - message: 'A response uses vague, unverifiable wording.', - }, - ], - }, - }, - ], - }, - ]); - expect(report.summary.warnings).toBe(1); - expect(exitCodeForReport(report)).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 index 447937e..be7439e 100644 --- a/packages/cli-contract/src/exit-codes.ts +++ b/packages/cli-contract/src/exit-codes.ts @@ -1,33 +1,37 @@ /** - * The exit-code contract for tools that run `ears lint`. + * The exit-code contract for the host-native `earsyntax` CLI. * - * These values match the orchestration brief. A tool derives the lint-outcome - * code from a {@link JsonReport} with {@link exitCodeForReport}; it selects - * {@link EXIT_USAGE} itself for argument, config, or file-read failures, since - * those are not represented in a report. + * 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 { JsonReport } from './json-report.js'; +import type { Findings } from '@earsyntax/core'; -/** No error-severity diagnostics. The run succeeded. */ +/** No error-severity findings. The run succeeded. */ export const EXIT_OK = 0; -/** At least one error-severity diagnostic was reported. */ +/** At least one error-severity finding was reported. */ export const EXIT_LINT_ERRORS = 1; -/** A CLI usage, configuration, or file-read error occurred. */ +/** A usage or environment failure occurred (bad flag, unknown profile, ...). */ export const EXIT_USAGE = 2; /** - * The exit code implied by a report's findings. + * The exit code implied by a findings result. * - * Returns {@link EXIT_LINT_ERRORS} when the report contains any error-severity + * Returns {@link EXIT_LINT_ERRORS} when the findings contain any error-severity * diagnostic, otherwise {@link EXIT_OK}. It never returns {@link EXIT_USAGE}: - * usage failures are a CLI concern, not a property of a completed report. + * 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 report The JSON report to inspect. + * @param findings The findings to inspect. * @returns {@link EXIT_OK} or {@link EXIT_LINT_ERRORS}. */ -export function exitCodeForReport(report: JsonReport): typeof EXIT_OK | typeof EXIT_LINT_ERRORS { - return report.summary.errors > 0 ? EXIT_LINT_ERRORS : EXIT_OK; +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 index d988098..e48dd0c 100644 --- a/packages/cli-contract/src/index.ts +++ b/packages/cli-contract/src/index.ts @@ -1,28 +1,30 @@ /** * `@earsyntax/cli-contract` public API surface. * - * Shared report output contracts so external tools can consume `ears lint` - * reports without depending on the CLI. This package defines report DATA - * shapes and pure serializers only: no I/O, no argument parsing, no - * `process.exit`, and no assumptions about command names or flags. + * 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. * - * Determinism contract: builders preserve input order and construct object - * keys in a fixed order, so identical input always produces identical output. + * The canonical result is the Findings model, built in `@earsyntax/core` + * (`toFindings`) and serialized here by {@link serializeFindings}. SARIF and the + * pretty model are legacy projections kept compiling; they will be rebuilt from + * Findings by a later agent. + * + * Determinism contract: serializers construct object keys in a fixed order and + * preserve input order, so identical input always produces identical output. */ +export type { Findings, FindingsDiagnostic, FindingsSummary, FindingsSeverity } from '@earsyntax/core'; + +export { canonicalizeFindings, serializeFindings } from './findings-report.js'; + +export { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForFindings } from './exit-codes.js'; + export type { ReportInput, ReportInputFile, ReportInputItem } from './input.js'; export { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; -export type { - JsonDiagnostic, - JsonReport, - JsonReportFile, - JsonReportSummary, - JsonRequirement, -} from './json-report.js'; -export { buildJsonReport, JSON_REPORT_VERSION, serializeJsonReport } from './json-report.js'; - export type { SarifDriver, SarifLevel, @@ -38,5 +40,3 @@ export { buildSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './s export type { PrettyModel, PrettyRecord, PrettySummary } from './pretty.js'; export { buildPrettyModel } from './pretty.js'; - -export { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForReport } from './exit-codes.js'; diff --git a/packages/cli-contract/src/json-report.test.ts b/packages/cli-contract/src/json-report.test.ts deleted file mode 100644 index fb1bba9..0000000 --- a/packages/cli-contract/src/json-report.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildJsonReport, JSON_REPORT_VERSION, serializeJsonReport } from './json-report.js'; -import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; - -describe('buildJsonReport', () => { - it('counts diagnostics by severity across all files', () => { - const report = buildJsonReport(sampleRun); - expect(report.summary).toEqual({ - files: 2, - requirements: 3, - errors: 2, - warnings: 1, - infos: 1, - valid: false, - }); - }); - - it('marks the report valid when there are no error diagnostics', () => { - const report = buildJsonReport(cleanRun); - expect(report.summary.errors).toBe(0); - expect(report.summary.valid).toBe(true); - }); - - it('carries the schema version', () => { - expect(buildJsonReport(sampleRun).version).toBe(JSON_REPORT_VERSION); - expect(JSON_REPORT_VERSION).toBe(1); - }); - - it('preserves file and requirement input order', () => { - const report = buildJsonReport(sampleRun); - expect(report.files.map((f) => f.path)).toEqual(['specs/subscribe.ears', 'specs/payment.ears']); - expect(report.files[0]?.requirements.map((r) => r.id)).toEqual(['REQ-1', 'REQ-2']); - }); - - it('omits optional keys when absent and includes them when present', () => { - const report = buildJsonReport(sampleRun); - const withMeta = report.files[0]?.requirements[0]; - expect(withMeta).toMatchObject({ id: 'REQ-1', line: 3, pattern: 'ubiquitous' }); - - const noMeta = report.files[1]?.requirements[0]; - expect(Object.hasOwn(noMeta, 'id')).toBe(false); - expect(Object.hasOwn(noMeta, 'line')).toBe(false); - expect(Object.hasOwn(noMeta, 'pattern')).toBe(false); - }); - - it('preserves diagnostic order and spans', () => { - const report = buildJsonReport(sampleRun); - const diags = report.files[0]?.requirements[1]?.diagnostics; - expect(diags.map((d) => d.code)).toEqual(['lint.vague_response', 'catalog.system_unresolved']); - expect(diags[0].span).toEqual({ start: 44, end: 57 }); - }); -}); - -describe('serializeJsonReport', () => { - it('is deterministic: the same input serializes identically', () => { - const a = serializeJsonReport(buildJsonReport(sampleRun)); - const b = serializeJsonReport(buildJsonReport(sampleRun)); - expect(a).toBe(b); - }); - - it('uses 2-space indentation and a trailing newline', () => { - const out = serializeJsonReport(buildJsonReport(cleanRun)); - expect(out.endsWith('\n')).toBe(true); - expect(out).toContain('\n "version": 1'); - expect(JSON.parse(out)).toEqual(buildJsonReport(cleanRun)); - }); - - it('orders requirement keys as id, line, text, valid, pattern, diagnostics', () => { - const req = buildJsonReport(sampleRun).files[0]?.requirements[0]; - expect(Object.keys(req)).toEqual(['id', 'line', 'text', 'valid', 'pattern', 'diagnostics']); - // The serialized key order matches the object key order. - const serializedKeys = Array.from(JSON.stringify(req).matchAll(/"(\w+)":/g), (m) => m[1]); - expect(serializedKeys).toEqual(['id', 'line', 'text', 'valid', 'pattern', 'diagnostics']); - - // A requirement carrying diagnostics keeps the diagnostic keys ordered too. - const withDiags = buildJsonReport(sampleRun).files[0]?.requirements[1]; - const diagKeys = Array.from( - JSON.stringify(withDiags.diagnostics[0]).matchAll(/"(\w+)":/g), - (m) => m[1], - ); - expect(diagKeys).toEqual(['code', 'severity', 'message', 'span', 'start', 'end']); - }); -}); diff --git a/packages/cli-contract/src/json-report.ts b/packages/cli-contract/src/json-report.ts deleted file mode 100644 index b11daa0..0000000 --- a/packages/cli-contract/src/json-report.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The JSON report contract. - * - * A stable, machine-readable serialization of a linting run that external - * tools can consume without depending on the CLI. Object keys are constructed - * in a fixed order and arrays preserve input order, so the same input always - * serializes to a byte-identical string. - */ - -import type { DiagnosticCode, Pattern, Severity, Span } from '@earsyntax/core'; -import type { ReportInput } from './input.js'; - -/** The current JSON report schema version. Bumped on breaking shape changes. */ -export const JSON_REPORT_VERSION = 1 as const; - -/** A single finding as it appears in the JSON report. */ -export interface JsonDiagnostic { - /** The registered diagnostic code. */ - code: DiagnosticCode; - /** Severity of the finding. */ - severity: Severity; - /** Human-readable explanation. */ - message: string; - /** Source span within the requirement text, when known. */ - span?: Span; -} - -/** A single linted requirement as it appears in the JSON report. */ -export interface JsonRequirement { - /** Caller-supplied identifier, when one was provided. */ - id?: string; - /** 1-based line in the source file, when known. */ - line?: number; - /** The requirement text that was linted. */ - text: string; - /** `false` when any diagnostic on this requirement is an error. */ - valid: boolean; - /** The classified shell pattern, when the requirement parsed. */ - pattern?: Pattern; - /** Findings for this requirement, in the order core produced them. */ - diagnostics: JsonDiagnostic[]; -} - -/** One source file and its linted requirements in the JSON report. */ -export interface JsonReportFile { - /** The source file path. */ - path: string; - /** The requirements linted from this file, in source order. */ - requirements: JsonRequirement[]; -} - -/** Aggregate counts across the whole report. */ -export interface JsonReportSummary { - /** Number of source files. */ - files: number; - /** Number of linted requirements. */ - requirements: number; - /** Total error-severity diagnostics. */ - errors: number; - /** Total warning-severity diagnostics. */ - warnings: number; - /** Total info-severity diagnostics. */ - infos: number; - /** `true` when there are no error-severity diagnostics anywhere. */ - valid: boolean; -} - -/** The complete JSON report. */ -export interface JsonReport { - /** Schema version. Always {@link JSON_REPORT_VERSION}. */ - version: typeof JSON_REPORT_VERSION; - /** Aggregate counts. */ - summary: JsonReportSummary; - /** Per-file results, in the order they were processed. */ - files: JsonReportFile[]; -} - -/** - * Build a {@link JsonReport} from a linting run. - * - * Files and requirements keep their input order. Optional keys (`id`, `line`, - * `pattern`, `span`) are included only when a value is present, and always in - * their fixed position, so serialization is deterministic. - * - * @param input The linted requirements grouped by source file. - * @returns The assembled JSON report. - */ -export function buildJsonReport(input: ReportInput): JsonReport { - let errors = 0; - let warnings = 0; - let infos = 0; - let requirements = 0; - - const files: JsonReportFile[] = input.map((file) => { - const reqs: JsonRequirement[] = file.items.map((item) => { - requirements += 1; - - const diagnostics: JsonDiagnostic[] = item.result.diagnostics.map((diagnostic) => { - if (diagnostic.severity === 'error') { - errors += 1; - } else if (diagnostic.severity === 'warning') { - warnings += 1; - } else { - infos += 1; - } - - // Keys constructed in fixed order: code, severity, message, span. - const jsonDiagnostic: JsonDiagnostic = { - code: diagnostic.code, - severity: diagnostic.severity, - message: diagnostic.message, - }; - if (diagnostic.span !== undefined) { - jsonDiagnostic.span = { start: diagnostic.span.start, end: diagnostic.span.end }; - } - return jsonDiagnostic; - }); - - // Keys constructed in fixed order: id, line, text, valid, pattern, - // diagnostics. - const requirement = {} as JsonRequirement; - if (item.input.id !== undefined) { - requirement.id = item.input.id; - } - if (item.input.source?.line !== undefined) { - requirement.line = item.input.source.line; - } - requirement.text = item.input.text; - requirement.valid = item.result.valid; - if (item.result.pattern !== undefined) { - requirement.pattern = item.result.pattern; - } - requirement.diagnostics = diagnostics; - return requirement; - }); - - return { path: file.path, requirements: reqs }; - }); - - const summary: JsonReportSummary = { - files: files.length, - requirements, - errors, - warnings, - infos, - valid: errors === 0, - }; - - return { version: JSON_REPORT_VERSION, summary, files }; -} - -/** - * Serialize a {@link JsonReport} to a stable string. - * - * Uses 2-space indentation and appends a trailing newline. Because - * {@link buildJsonReport} constructs keys in a fixed order, the same input - * always yields a byte-identical string. - * - * @param report The report to serialize. - * @returns The pretty-printed JSON with a trailing newline. - */ -export function serializeJsonReport(report: JsonReport): string { - return `${JSON.stringify(report, null, 2)}\n`; -} diff --git a/packages/core/src/catalog.test.ts b/packages/core/src/catalog.test.ts index c20c42a..d041751 100644 --- a/packages/core/src/catalog.test.ts +++ b/packages/core/src/catalog.test.ts @@ -128,17 +128,46 @@ describe('resolveTerm: unresolved', () => { }); }); -describe('resolveTerm: ambiguous', () => { - it('reports all candidates when more than one entry matches', () => { +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: 'session ended' }, + { group: 'states', id: 'ST-B', name: 'timeout' }, ]); expect(diagnostics).toEqual([ { @@ -207,6 +236,17 @@ describe('findMatches: role-scoped groups', () => { 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', () => { @@ -274,6 +314,15 @@ describe('resolveAndCollect', () => { 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); diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index e8d46af..3bb65d8 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -21,6 +21,7 @@ * catalog means "nothing to match against". */ +import { sortDiagnostics } from './diagnostics.js'; import type { Catalog, CatalogEntry, @@ -180,11 +181,19 @@ export function findMatches( return []; } const groups = allowedGroups(requestedRole, allGroups(catalog)); - const matches: MatchCandidate[] = []; + // 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) { - matches.push({ + canonical.push({ ref: { group: group.group, id: entry.id, name: entry.name }, role: group.role, viaAlias: false, @@ -193,7 +202,7 @@ export function findMatches( } for (const alias of entry.aliases ?? []) { if (normalizeKey(alias) === key) { - matches.push({ + aliased.push({ ref: { group: group.group, id: entry.id, name: entry.name }, role: group.role, viaAlias: true, @@ -203,7 +212,7 @@ export function findMatches( } } } - return dedupeMatches(matches); + return dedupeMatches(canonical.length > 0 ? canonical : aliased); } /** Remove duplicate candidates by group and id, keeping the first occurrence. */ @@ -297,7 +306,11 @@ export function resolveTerm( if (matches.length === 1) { const match = matches[0]; - term.role = match.role; + // 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; @@ -468,29 +481,6 @@ function sortReferences(references: ReferenceMatch[]): ReferenceMatch[] { return out; } -/** Stable sort of diagnostics by span start, then code, then message, then severity. */ -function sortDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { - const out = [...diagnostics]; - 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.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 out; -} - /** * Resolve every catalog reference in a requirement AST. * @@ -539,7 +529,9 @@ export function resolveAndCollect( const nextAst: EarsAst = { pattern: ast.pattern, system: systemResolution.term, - responses: ast.responses, + // 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 } : {}), diff --git a/packages/core/src/expression-parser.test.ts b/packages/core/src/expression-parser.test.ts index a942dbc..bad93a0 100644 --- a/packages/core/src/expression-parser.test.ts +++ b/packages/core/src/expression-parser.test.ts @@ -226,3 +226,67 @@ describe('parseClauseExpression', () => { 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 index f76e000..2d58971 100644 --- a/packages/core/src/expression-parser.ts +++ b/packages/core/src/expression-parser.ts @@ -210,12 +210,22 @@ function tokenizeExpression( 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; @@ -279,17 +289,38 @@ class ExprParser { } private parseUnary(): ClauseExpr | null { - if (this.match(TokenKind.Not)) { - const inner = this.parseUnary(); - if (inner === null) { - this.addFinding('expr.invalid_operator_sequence', this.currentSpan()); - const dangling: FreeTextExpr = { kind: 'free-text', text: 'not' }; - return dangling; - } - const notExpr: NotExpr = { kind: 'not', item: inner, span: inner.span }; - return notExpr; + // 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(); } - 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 { @@ -311,17 +342,33 @@ class ExprParser { 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(); - if (!this.match(TokenKind.RParen)) { + 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; } - const group: GroupExpr = { kind: 'group', item: inner, span: inner.span }; + // 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; } @@ -401,21 +448,55 @@ class ExprParser { 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 { - const starts: number[] = []; - const ends: number[] = []; + // 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) { - starts.push(item.span.start); - ends.push(item.span.end); + 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; } } - if (starts.length === 0) { - return undefined; - } - return { start: Math.min(...starts), end: Math.max(...ends) }; + return start === undefined || end === undefined ? undefined : { start, end }; } /** diff --git a/packages/core/src/findings.test.ts b/packages/core/src/findings.test.ts new file mode 100644 index 0000000..3f7523f --- /dev/null +++ b/packages/core/src/findings.test.ts @@ -0,0 +1,289 @@ +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..ac0c6ea --- /dev/null +++ b/packages/core/src/findings.ts @@ -0,0 +1,294 @@ +/** + * 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, mirroring `@earsyntax/cli-contract`'s report input item. + */ +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 index 9dd91e2..2c914cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,3 +14,63 @@ export type * from './types.js'; export { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from './lint.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'; diff --git a/packages/core/src/profiles/builtins.test.ts b/packages/core/src/profiles/builtins.test.ts new file mode 100644 index 0000000..b212a13 --- /dev/null +++ b/packages/core/src/profiles/builtins.test.ts @@ -0,0 +1,85 @@ +/** + * 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..0bb2f85 --- /dev/null +++ b/packages/core/src/profiles/builtins.ts @@ -0,0 +1,211 @@ +/** + * 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. Differs + * from strict mainly in its markdown locator: requirement sections are targeted + * and design/background prose is excluded so narrative that merely opens with an + * EARS keyword does not become a candidate. + */ +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 sections in specs/**/spec.md.', + }, + ], + exclude: [ + { + id: 'speckit.non-requirement-section', + kind: 'heading-section', + headingPattern: '^(design|background|context|overview|non-goals?)$', + note: 'Narrative sections that must not produce candidates.', + }, + ], + codeFences: 'ignore', + }, + severity: {}, + idFormat: { required: false }, +}; + +/** + * `openspec`: EARS in OpenSpec specs and changes. Near-strict dialect. Locates + * `### Requirement:` bodies and `#### Scenario:` blocks inside + * `openspec/specs/**` and `openspec/changes/**`; non-requirement prose is + * skipped by the block locator. + */ +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: 'Body lines of a ### Requirement: heading block.', + }, + { + id: 'openspec.scenario', + kind: 'block', + blockPrefix: '#### Scenario:', + note: 'Body lines of a #### Scenario: heading block.', + }, + ], + 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..d597836 --- /dev/null +++ b/packages/core/src/profiles/diff.test.ts @@ -0,0 +1,114 @@ +/** + * 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..d4028e6 --- /dev/null +++ b/packages/core/src/profiles/diff.ts @@ -0,0 +1,122 @@ +/** + * 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..e00875b --- /dev/null +++ b/packages/core/src/profiles/resolve.ts @@ -0,0 +1,55 @@ +/** + * 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..949c6a4 --- /dev/null +++ b/packages/core/src/profiles/schema.test.ts @@ -0,0 +1,187 @@ +/** + * 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); +} + +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(); + (input.dialect as Record).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(); + (input.locator.include[0] as Record).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(); + (input.idFormat as Record).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(); + (input.locator.include[0] as Record).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(); + (input.dialect as Record).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..0b99861 --- /dev/null +++ b/packages/core/src/profiles/schema.ts @@ -0,0 +1,486 @@ +/** + * 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. */ +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..0e9f39e --- /dev/null +++ b/packages/core/src/registry.test.ts @@ -0,0 +1,203 @@ +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-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 29 entries, one per old code', () => { + expect(DIAGNOSTIC_REGISTRY).toHaveLength(29); + expect(EXPECTED).toHaveLength(29); + expect(ALL_CODES).toHaveLength(29); + }); + + 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 29 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..78f98b1 --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,505 @@ +/** + * 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.'; + +/** + * 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-E013. + { + 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: 'The 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, + }, + + // 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: 'A payment-http entry that no requirement references.', + goodExample: 'When the payment-http call fails, the system shall retry once.', + profileNotes: WARNING_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.', + 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: WARNING_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/test/profiles-schema.test.ts b/packages/core/test/profiles-schema.test.ts new file mode 100644 index 0000000..fb30b0f --- /dev/null +++ b/packages/core/test/profiles-schema.test.ts @@ -0,0 +1,70 @@ +/** + * 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(); + } + } + }); + } +}); From 2ea07bce47d708cb24527ddbecd9e0ee527e23cb Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 20:13:39 +0300 Subject: [PATCH 08/20] feat(core): add host-native diagnostic codes and dialect options EARS-E014 keyword_case, EARS-E015 missing_leading_comma, EARS-E016 prohibition_not_allowed, all strict errors. Additive DialectOptions, Options.dialect, EarsAst.prohibition for profile-driven parsing. --- docs/diagnostics.md | 5 +- docs/refactor/host-native-facade.md | 21 ++++++- fixtures/diagnostics/migration-table.json | 3 + .../cli-contract/src/diagnostic-registry.ts | 4 ++ packages/core/src/diagnostics.test.ts | 20 +++++- packages/core/src/diagnostics.ts | 18 ++++++ packages/core/src/registry.test.ts | 13 ++-- packages/core/src/registry.ts | 42 ++++++++++++- packages/core/src/types.ts | 61 ++++++++++++++++++- 9 files changed, 176 insertions(+), 11 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e8ad500..215108d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -41,7 +41,7 @@ Codes are grouped by prefix. The `ID` column is the primary public identifier; t ### `ears.*` shell structure -These report defects in the outer EARS sentence shape. All are mode-dependent. +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 | | ----------- | --------------------------- | ----------------------------------------------------------- | ------- | --------- | ------------------------------------------------------------------- | @@ -53,6 +53,9 @@ These report defects in the outer EARS sentence shape. All are mode-dependent. | `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 diff --git a/docs/refactor/host-native-facade.md b/docs/refactor/host-native-facade.md index 1ee1a25..088c152 100644 --- a/docs/refactor/host-native-facade.md +++ b/docs/refactor/host-native-facade.md @@ -462,6 +462,23 @@ delete an id. A new diagnostic takes the next free number in its severity band. | `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 | @@ -483,7 +500,9 @@ delete an id. A new diagnostic takes the next free number in its severity band. | `EARS-W015` | `lint.unparsed_tail` | | `EARS-W016` | `lint.vague_response` | -13 errors + 16 warnings = 29 ids, one per old code, no gaps. +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: diff --git a/fixtures/diagnostics/migration-table.json b/fixtures/diagnostics/migration-table.json index 918350d..6e6cf77 100644 --- a/fixtures/diagnostics/migration-table.json +++ b/fixtures/diagnostics/migration-table.json @@ -14,6 +14,9 @@ { "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" }, diff --git a/packages/cli-contract/src/diagnostic-registry.ts b/packages/cli-contract/src/diagnostic-registry.ts index ae594f7..3ac758b 100644 --- a/packages/cli-contract/src/diagnostic-registry.ts +++ b/packages/cli-contract/src/diagnostic-registry.ts @@ -54,6 +54,10 @@ export const DIAGNOSTIC_DESCRIPTIONS: Readonly> = 'lint.unparsed_tail': 'Trailing text after the requirement could not be parsed.', 'lint.alias_used': 'A term was matched via an alias rather than its canonical name.', 'lint.suspicious_text_shape': 'Requirement text has a suspicious shape.', + // Host-native grammar diagnostics. + 'ears.keyword_case': 'A keyword violates strict canonical casing.', + 'ears.missing_leading_comma': 'A leading clause is not comma-delimited where required.', + 'ears.prohibition_not_allowed': 'A shall not prohibition is used where the dialect forbids it.', }); /** diff --git a/packages/core/src/diagnostics.test.ts b/packages/core/src/diagnostics.test.ts index 8cd2290..f65c80a 100644 --- a/packages/core/src/diagnostics.test.ts +++ b/packages/core/src/diagnostics.test.ts @@ -42,6 +42,9 @@ const ALL_CODES = [ '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 = [ @@ -63,6 +66,12 @@ const SYSTEM_CATALOG_CODES = [ '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', @@ -115,6 +124,13 @@ describe('severityForCode', () => { } }); + 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'); @@ -143,8 +159,8 @@ describe('severityForCode', () => { } }); - it('MODE_DEPENDENT_CODES contains exactly the structural and system catalog codes', () => { - const expected = [...STRUCTURAL_CODES, ...SYSTEM_CATALOG_CODES].sort(); + 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); }); diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index a904cfc..4029bb9 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -85,6 +85,12 @@ export const MODE_DEPENDENT_CODES = new Set([ // 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', ]); /** @@ -220,6 +226,18 @@ const MESSAGE_BUILDERS = { 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>; /** diff --git a/packages/core/src/registry.test.ts b/packages/core/src/registry.test.ts index 0e9f39e..ff9e14e 100644 --- a/packages/core/src/registry.test.ts +++ b/packages/core/src/registry.test.ts @@ -34,6 +34,9 @@ const EXPECTED: readonly { id: string; oldCode: DiagnosticCode }[] = [ { 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' }, @@ -66,10 +69,10 @@ function loadMigrationFixture(): MigrationTableFixture { } describe('diagnostic registry', () => { - it('has 29 entries, one per old code', () => { - expect(DIAGNOSTIC_REGISTRY).toHaveLength(29); - expect(EXPECTED).toHaveLength(29); - expect(ALL_CODES).toHaveLength(29); + 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', () => { @@ -97,7 +100,7 @@ describe('diagnostic registry', () => { expect(actual).toEqual(fixture.rows); }); - it('maps every one of the 29 old codes to a current id', () => { + 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(); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 78f98b1..041b1d1 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -71,7 +71,7 @@ const WARNING_PROFILE_NOTE = * severity, errors `E001+`, warnings `W001+`. */ const ENTRIES: DiagnosticRegistryEntry[] = [ - // Errors: EARS-E001 - EARS-E013. + // Errors: EARS-E001 - EARS-E016. { id: 'EARS-E001', oldCode: 'catalog.system_ambiguous', @@ -228,6 +228,46 @@ const ENTRIES: DiagnosticRegistryEntry[] = [ 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. { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a2d7537..3df4ee1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -225,7 +225,13 @@ export type DiagnosticCode = | 'lint.vague_response' | 'lint.unparsed_tail' | 'lint.alias_used' - | 'lint.suspicious_text_shape'; + | '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. @@ -345,6 +351,54 @@ export interface EarsAst { 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; } /** @@ -361,6 +415,11 @@ export interface Options { 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; } /** From 4f5d899e18ac779185e35d6fb81e939a2dc99756 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 20:38:25 +0300 Subject: [PATCH 09/20] feat(core): strict grammar rulings, dialect options, host profile fixtures ES-D rulings: leading comma, then discriminator, single When, system form, keyword casing, prohibition, frame metadata, one shall per sentence. Dialect knobs relax per profile; Go parity suite pinned to a loose dialect. Fixture sets for strict, ears-x, kiro, speckit, and openspec with candidates and expected-diagnostics sidecars. --- docs/compatibility.md | 82 ++++-- fixtures/invalid/frame-metadata-strict.json | 8 + .../invalid/keyword-case-lowercase-when.json | 9 + fixtures/invalid/missing-leading-comma.json | 9 + fixtures/invalid/multiple-when.json | 9 + fixtures/invalid/prohibition-strict.json | 9 + fixtures/invalid/pronoun-system.json | 8 + fixtures/invalid/then-outside-if.json | 9 + fixtures/profiles/README.md | 146 +++++++++++ .../ears-x/frame-metadata.candidates.json | 35 +++ fixtures/profiles/ears-x/frame-metadata.ears | 3 + .../ears-x/frame-metadata.expected.json | 43 ++++ .../ears-x/prohibition.candidates.json | 32 +++ fixtures/profiles/ears-x/prohibition.ears | 3 + .../profiles/ears-x/prohibition.expected.json | 44 ++++ fixtures/profiles/kiro/NOTES.md | 46 ++++ fixtures/profiles/kiro/design.candidates.json | 7 + fixtures/profiles/kiro/design.md | 44 ++++ .../kiro/requirements.candidates.json | 80 ++++++ .../profiles/kiro/requirements.expected.json | 125 +++++++++ fixtures/profiles/kiro/requirements.md | 44 ++++ fixtures/profiles/matrix.json | 27 ++ fixtures/profiles/openspec/NOTES.md | 140 ++++++++++ .../profiles/openspec/change.candidates.json | 41 +++ fixtures/profiles/openspec/change.md | 46 ++++ .../profiles/openspec/project.candidates.json | 35 +++ fixtures/profiles/openspec/project.md | 28 ++ .../profiles/openspec/spec.candidates.json | 49 ++++ fixtures/profiles/openspec/spec.md | 70 +++++ .../invalid/unknown-nested-locator-key.json | 7 +- .../schema/invalid/unknown-severity-id.json | 4 +- fixtures/profiles/speckit/NOTES.md | 93 +++++++ .../profiles/speckit/plan.candidates.json | 29 +++ fixtures/profiles/speckit/plan.md | 41 +++ .../profiles/speckit/spec.candidates.json | 85 ++++++ fixtures/profiles/speckit/spec.expected.json | 24 ++ fixtures/profiles/speckit/spec.md | 77 ++++++ .../profiles/strict/invalid.candidates.json | 64 +++++ fixtures/profiles/strict/invalid.ears | 7 + .../profiles/strict/invalid.expected.json | 59 +++++ .../profiles/strict/valid.candidates.json | 64 +++++ fixtures/profiles/strict/valid.ears | 7 + fixtures/profiles/strict/valid.expected.json | 16 ++ .../valid/case-insensitive-if-then-upper.json | 5 + .../valid/case-insensitive-when-lower.json | 5 + .../valid/case-insensitive-when-upper.json | 5 + .../valid/case-insensitive-while-mixed.json | 5 + fixtures/valid/complex-where-if.json | 11 + fixtures/valid/frame-metadata-earsx.json | 12 + .../valid/kiro-optional-leading-comma.json | 12 + fixtures/valid/literal-system-name.json | 14 + fixtures/valid/prohibition-earsx.json | 16 ++ fixtures/valid/single-shall-and-response.json | 10 + packages/core/src/index.ts | 24 +- packages/core/src/lint.test.ts | 40 ++- packages/core/src/lint.ts | 33 ++- packages/core/src/options.ts | 68 ++++- packages/core/src/profiles/builtins.ts | 47 +++- packages/core/src/shell-parser.ts | 241 ++++++++++++++++-- packages/core/test/fixtures.test.ts | 27 +- .../core/test/profiles-strict-earsx.test.ts | 154 +++++++++++ packages/core/test/shell-parser.test.ts | 137 +++++++++- 62 files changed, 2613 insertions(+), 61 deletions(-) create mode 100644 fixtures/invalid/frame-metadata-strict.json create mode 100644 fixtures/invalid/keyword-case-lowercase-when.json create mode 100644 fixtures/invalid/missing-leading-comma.json create mode 100644 fixtures/invalid/multiple-when.json create mode 100644 fixtures/invalid/prohibition-strict.json create mode 100644 fixtures/invalid/pronoun-system.json create mode 100644 fixtures/invalid/then-outside-if.json create mode 100644 fixtures/profiles/README.md create mode 100644 fixtures/profiles/ears-x/frame-metadata.candidates.json create mode 100644 fixtures/profiles/ears-x/frame-metadata.ears create mode 100644 fixtures/profiles/ears-x/frame-metadata.expected.json create mode 100644 fixtures/profiles/ears-x/prohibition.candidates.json create mode 100644 fixtures/profiles/ears-x/prohibition.ears create mode 100644 fixtures/profiles/ears-x/prohibition.expected.json create mode 100644 fixtures/profiles/kiro/NOTES.md create mode 100644 fixtures/profiles/kiro/design.candidates.json create mode 100644 fixtures/profiles/kiro/design.md create mode 100644 fixtures/profiles/kiro/requirements.candidates.json create mode 100644 fixtures/profiles/kiro/requirements.expected.json create mode 100644 fixtures/profiles/kiro/requirements.md create mode 100644 fixtures/profiles/matrix.json create mode 100644 fixtures/profiles/openspec/NOTES.md create mode 100644 fixtures/profiles/openspec/change.candidates.json create mode 100644 fixtures/profiles/openspec/change.md create mode 100644 fixtures/profiles/openspec/project.candidates.json create mode 100644 fixtures/profiles/openspec/project.md create mode 100644 fixtures/profiles/openspec/spec.candidates.json create mode 100644 fixtures/profiles/openspec/spec.md create mode 100644 fixtures/profiles/speckit/NOTES.md create mode 100644 fixtures/profiles/speckit/plan.candidates.json create mode 100644 fixtures/profiles/speckit/plan.md create mode 100644 fixtures/profiles/speckit/spec.candidates.json create mode 100644 fixtures/profiles/speckit/spec.expected.json create mode 100644 fixtures/profiles/speckit/spec.md create mode 100644 fixtures/profiles/strict/invalid.candidates.json create mode 100644 fixtures/profiles/strict/invalid.ears create mode 100644 fixtures/profiles/strict/invalid.expected.json create mode 100644 fixtures/profiles/strict/valid.candidates.json create mode 100644 fixtures/profiles/strict/valid.ears create mode 100644 fixtures/profiles/strict/valid.expected.json create mode 100644 fixtures/valid/complex-where-if.json create mode 100644 fixtures/valid/frame-metadata-earsx.json create mode 100644 fixtures/valid/kiro-optional-leading-comma.json create mode 100644 fixtures/valid/literal-system-name.json create mode 100644 fixtures/valid/prohibition-earsx.json create mode 100644 fixtures/valid/single-shall-and-response.json create mode 100644 packages/core/test/profiles-strict-earsx.test.ts diff --git a/docs/compatibility.md b/docs/compatibility.md index 04f384c..2031a8f 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -51,26 +51,68 @@ 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. | +| 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 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/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/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/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/profiles/README.md b/fixtures/profiles/README.md new file mode 100644 index 0000000..9e46cda --- /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..f229687 --- /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. text is the raw line including the REQ-### prefix and [source:] tag. requirementId is populated from the frame id once frame-metadata parsing lands (pending C4b); the every-line locator itself yields one candidate per non-empty line unconditionally.", + "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..cc71fd2 --- /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..ff8f363 --- /dev/null +++ b/fixtures/profiles/kiro/requirements.expected.json @@ -0,0 +1,125 @@ +{ + "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..c1f9f20 --- /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/unknown-nested-locator-key.json b/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json index 3d9133c..fcb5029 100644 --- a/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json +++ b/fixtures/profiles/schema/invalid/unknown-nested-locator-key.json @@ -14,7 +14,12 @@ "locator": { "documentKinds": ["markdown"], "include": [ - { "id": "speckit.req", "kind": "heading-section", "headingPattern": "^requirements$", "regex": true } + { + "id": "speckit.req", + "kind": "heading-section", + "headingPattern": "^requirements$", + "regex": true + } ], "exclude": [], "codeFences": "ignore" diff --git a/fixtures/profiles/schema/invalid/unknown-severity-id.json b/fixtures/profiles/schema/invalid/unknown-severity-id.json index ebaf35b..f3f629c 100644 --- a/fixtures/profiles/schema/invalid/unknown-severity-id.json +++ b/fixtures/profiles/schema/invalid/unknown-severity-id.json @@ -13,7 +13,9 @@ }, "locator": { "documentKinds": ["markdown"], - "include": [{ "id": "kiro.item", "kind": "list-item", "underHeading": "^acceptance criteria$" }], + "include": [ + { "id": "kiro.item", "kind": "list-item", "underHeading": "^acceptance criteria$" } + ], "exclude": [], "codeFences": "ignore" }, 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..7a704d6 --- /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": "fixtures/profiles/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..f8f0934 --- /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": "fixtures/profiles/speckit/spec.md", + "profile": "speckit", + "candidates": [ + { + "file": "fixtures/profiles/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": "fixtures/profiles/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": "fixtures/profiles/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": "fixtures/profiles/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": "fixtures/profiles/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..29a464e --- /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": "fixtures/profiles/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": "fixtures/profiles/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/valid/case-insensitive-if-then-upper.json b/fixtures/valid/case-insensitive-if-then-upper.json index 1ce5ee5..d3e5868 100644 --- a/fixtures/valid/case-insensitive-if-then-upper.json +++ b/fixtures/valid/case-insensitive-if-then-upper.json @@ -1,6 +1,11 @@ { "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", diff --git a/fixtures/valid/case-insensitive-when-lower.json b/fixtures/valid/case-insensitive-when-lower.json index 30cd204..efa538e 100644 --- a/fixtures/valid/case-insensitive-when-lower.json +++ b/fixtures/valid/case-insensitive-when-lower.json @@ -1,6 +1,11 @@ { "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", diff --git a/fixtures/valid/case-insensitive-when-upper.json b/fixtures/valid/case-insensitive-when-upper.json index eb4b706..3a0a475 100644 --- a/fixtures/valid/case-insensitive-when-upper.json +++ b/fixtures/valid/case-insensitive-when-upper.json @@ -1,6 +1,11 @@ { "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", diff --git a/fixtures/valid/case-insensitive-while-mixed.json b/fixtures/valid/case-insensitive-while-mixed.json index 667407d..50af062 100644 --- a/fixtures/valid/case-insensitive-while-mixed.json +++ b/fixtures/valid/case-insensitive-while-mixed.json @@ -1,6 +1,11 @@ { "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", 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/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/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/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/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/packages/core/src/index.ts b/packages/core/src/index.ts index 2c914cc..086db0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,7 +13,19 @@ export type * from './types.js'; -export { lintEars, lintEarsBatch, parseEars, lintCatalogCoverage } from './lint.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 @@ -74,3 +86,13 @@ export type { 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 index ebcd60a..a52528f 100644 --- a/packages/core/src/lint.test.ts +++ b/packages/core/src/lint.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { lintCatalogCoverage, lintEars, lintEarsBatch, parseEars } from './index.js'; -import { DEFAULT_VAGUE_TERMS, withDefaults } from './options.js'; +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. */ @@ -326,6 +332,7 @@ describe('withDefaults', () => { mode: 'strict', commaAsAnd: false, vagueTerms: [...DEFAULT_VAGUE_TERMS], + dialect: { ...STRICT_DIALECT }, }); }); @@ -336,4 +343,33 @@ describe('withDefaults', () => { 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 index 773ef50..3fdd690 100644 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -158,7 +158,7 @@ function runPipeline( catalog: Catalog | undefined, opts: ResolvedOptions, ): PipelineResult { - const shell = parseShell(text, { commaAsAnd: opts.commaAsAnd }); + const shell = parseShell(text, { commaAsAnd: opts.commaAsAnd, dialect: opts.dialect }); const structural: Diagnostic[] = []; if (!shell.ast) { @@ -209,7 +209,13 @@ function runPipeline( resolvedAst.responses, opts, ); - const ast: EarsAst = { ...resolvedAst, responses }; + // 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]; @@ -384,6 +390,29 @@ function parseClauseNode( 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( diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 430543a..f9d3c94 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -14,7 +14,7 @@ * Determinism note: no dependencies, no clock, no file system, no network. */ -import type { Mode, Options } from './types.js'; +import type { DialectOptions, Mode, Options } from './types.js'; /** * The default vague terms flagged in responses when the caller supplies none. @@ -26,6 +26,69 @@ export const DEFAULT_VAGUE_TERMS: readonly string[] = ['appropriate', 'sufficien /** 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. * @@ -39,6 +102,8 @@ export interface ResolvedOptions { 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; } /** @@ -60,5 +125,6 @@ export function withDefaults(options?: Options): ResolvedOptions { mode: options?.mode ?? DEFAULT_MODE, commaAsAnd: options?.commaAsAnd ?? false, vagueTerms, + dialect: resolveDialect(options), }; } diff --git a/packages/core/src/profiles/builtins.ts b/packages/core/src/profiles/builtins.ts index 0bb2f85..eda1e2c 100644 --- a/packages/core/src/profiles/builtins.ts +++ b/packages/core/src/profiles/builtins.ts @@ -109,10 +109,23 @@ const KIRO: Profile = { }; /** - * `speckit`: EARS in Spec Kit `specs/**\/spec.md`. Near-strict dialect. Differs - * from strict mainly in its markdown locator: requirement sections are targeted - * and design/background prose is excluded so narrative that merely opens with an - * EARS keyword does not become a candidate. + * `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', @@ -132,15 +145,15 @@ const SPECKIT: Profile = { id: 'speckit.requirements-section', kind: 'heading-section', headingPattern: '^(functional )?requirements$', - note: 'Body lines of Requirements sections in specs/**/spec.md.', + 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?)$', - note: 'Narrative sections that must not produce candidates.', + 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', @@ -150,10 +163,18 @@ const SPECKIT: Profile = { }; /** - * `openspec`: EARS in OpenSpec specs and changes. Near-strict dialect. Locates - * `### Requirement:` bodies and `#### Scenario:` blocks inside - * `openspec/specs/**` and `openspec/changes/**`; non-requirement prose is - * skipped by the block locator. + * `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', @@ -173,13 +194,13 @@ const OPENSPEC: Profile = { id: 'openspec.requirement', kind: 'block', blockPrefix: '### Requirement:', - note: 'Body lines of a ### Requirement: heading block.', + 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: 'Body lines of a #### Scenario: heading block.', + 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: [], diff --git a/packages/core/src/shell-parser.ts b/packages/core/src/shell-parser.ts index d2d3f46..06529b8 100644 --- a/packages/core/src/shell-parser.ts +++ b/packages/core/src/shell-parser.ts @@ -23,6 +23,7 @@ * matching. The same input always yields the same result. */ +import { STRICT_DIALECT, type ResolvedDialect } from './options.js'; import type { ClauseExpr, DiagnosticCode, @@ -86,6 +87,12 @@ export interface ShellParseOptions { * 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; } /** @@ -96,29 +103,45 @@ export interface ShellParseOptions { * @param options Optional shell tokenization options. */ export function parseShell(input: string, options?: ShellParseOptions): ShellParseResult { - const parser = new ShellParser(input, options?.commaAsAnd ?? false); + 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) { + constructor(input: string, commaAsAnd: boolean, dialect: ResolvedDialect) { this.text = input; this.commaAsAnd = commaAsAnd; + this.dialect = dialect; } run(): ShellParseResult { - const firstNonWs = skipWS(this.text, 0); + 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'); @@ -126,6 +149,7 @@ class ShellParser { this.add('ears.multiple_shall'); } + this.start = firstNonWs; this.pos = firstNonWs; const ast = this.parse(); return ast ? { ast, findings: this.findings } : { findings: this.findings }; @@ -137,16 +161,16 @@ class ShellParser { for (;;) { this.pos = skipWS(this.text, this.pos); if (this.peekKeyword('while')) { - this.consumeKeyword('while'); + this.consumeClauseKeyword('while'); this.pushClause(this.scanCommaClause('while')); } else if (this.peekKeyword('when')) { - this.consumeKeyword('when'); + this.consumeClauseKeyword('when'); this.pushClause(this.scanCommaClause('when')); } else if (this.peekKeyword('where')) { - this.consumeKeyword('where'); + this.consumeClauseKeyword('where'); this.pushClause(this.scanCommaClause('where')); } else if (this.peekKeyword('if')) { - this.consumeKeyword('if'); + this.consumeClauseKeyword('if'); const ifClause = this.scanIfClause(); if (!ifClause) { return undefined; @@ -157,18 +181,26 @@ class ShellParser { } } - // Mandatory tail: the shall . - if (!this.consumeKeyword('the')) { + // 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 systemStart = this.pos; - const shallIdx = findWordFrom(this.text, this.pos, 'shall'); + 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); @@ -178,9 +210,28 @@ class ShellParser { 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); @@ -192,19 +243,53 @@ class ShellParser { } 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 comma-delimited clause body, tracking parenthesis depth. */ + /** + * 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 } = scanUntilClauseBoundary(this.text, this.pos, this.commaAsAnd); + 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); } @@ -226,6 +311,7 @@ class ShellParser { 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(); @@ -287,6 +373,47 @@ class ShellParser { 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}. */ @@ -418,6 +545,56 @@ function hasAnyKeywordAt(text: string, pos: number, keywords: readonly string[]) 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)) { @@ -463,7 +640,7 @@ function scanUntilClauseBoundary( text: string, start: number, commaAsAnd: boolean, -): { end: number; next: number } { +): { end: number; next: number; comma: boolean } { let depth = 0; for (let i = start; i < text.length; i++) { const ch = text[i]; @@ -473,10 +650,42 @@ function scanUntilClauseBoundary( depth--; } if (depth === 0 && ch === ',' && isClauseBoundaryAfterComma(text, i + 1, commaAsAnd)) { - return { end: i, next: skipWS(text, i + 1) }; + 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 { end: text.length, next: text.length }; + 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. */ diff --git a/packages/core/test/fixtures.test.ts b/packages/core/test/fixtures.test.ts index 6ab5efd..562888e 100644 --- a/packages/core/test/fixtures.test.ts +++ b/packages/core/test/fixtures.test.ts @@ -15,6 +15,7 @@ import { describe, expect, test } from 'vitest'; import { lintEars } from '../src/index.js'; import type { Catalog, + DialectOptions, DiagnosticCode, LintResult, Options, @@ -67,10 +68,34 @@ function loadGroup(group: string): LoadedFixture[] { }); } +/** + * 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, fixture.options); + const result: LintResult = lintEars(fixture.text, fixture.catalog, fixtureOptions(loaded)); expect(result.valid, 'valid mismatch').toBe(expected.valid); 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..073aec7 --- /dev/null +++ b/packages/core/test/profiles-strict-earsx.test.ts @@ -0,0 +1,154 @@ +/** + * 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 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 dialect = dialectOf('strict'); + + test('every line is valid under strict with no diagnostics', () => { + for (const text of lines) { + const result = lintEars(text, undefined, { dialect }); + expect(result.valid, text).toBe(true); + expect(result.diagnostics, text).toHaveLength(0); + } + }); +}); + +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(true); + expect(underEarsX.diagnostics, entry.text).toHaveLength(0); + expect(underEarsX.ast?.prohibition, entry.text).toBe(true); + 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(true); + expect(underEarsX.diagnostics, entry.text).toHaveLength(0); + 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 index 5038d12..191539d 100644 --- a/packages/core/test/shell-parser.test.ts +++ b/packages/core/test/shell-parser.test.ts @@ -1,11 +1,19 @@ 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, DiagnosticCode, FreeTextExpr } from '../src/types.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.'); @@ -65,8 +73,10 @@ describe('parseShell: valid shell patterns', () => { }); describe('parseShell: case-insensitive keywords', () => { - it('accepts upper-case and mixed-case keywords', () => { - const { ast, findings } = parseShell('WHILE the door is open, THE system SHALL respond.'); + 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'); @@ -74,6 +84,127 @@ describe('parseShell: case-insensitive keywords', () => { }); }); +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( From ac2bd3bff924a1362a31a1b6c0e174c3c88c7996 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 20:40:09 +0300 Subject: [PATCH 10/20] feat(extract): profile-driven locator pipeline and extractor hardening Candidate model with locate, extract, parse, lint stages; fence-aware markdown scanning; BOM handling; line-offset index; verified profile fixture sidecars. --- docs/input-formats.md | 72 +++ .../kiro-requirements.candidates.json | 18 + fixtures/pipeline/kiro-requirements.md | 16 + .../pipeline/openspec-spec.candidates.json | 26 + fixtures/pipeline/openspec-spec.md | 13 + .../pipeline/speckit-spec.candidates.json | 18 + fixtures/pipeline/speckit-spec.md | 10 + .../pipeline/strict-basic.candidates.json | 18 + fixtures/pipeline/strict-basic.ears | 4 + packages/core/src/pipeline.test.ts | 107 ++++ packages/core/src/pipeline.ts | 148 ++++++ packages/core/src/profiles/schema.test.ts | 26 +- packages/extract/src/ears.ts | 7 +- packages/extract/src/fixtures.test.ts | 40 ++ packages/extract/src/index.ts | 19 + packages/extract/src/internal.ts | 40 +- packages/extract/src/json.ts | 19 +- packages/extract/src/locator.test.ts | 154 ++++++ packages/extract/src/locator.ts | 458 ++++++++++++++++++ packages/extract/src/markdown-scan.test.ts | 43 ++ packages/extract/src/markdown-scan.ts | 151 ++++++ packages/extract/src/markdown.test.ts | 29 ++ packages/extract/src/markdown.ts | 120 +++-- packages/extract/src/normalize.ts | 98 ++++ packages/extract/src/pipeline.test.ts | 96 ++++ packages/extract/src/pipeline.ts | 229 +++++++++ packages/extract/src/yaml.ts | 25 +- 27 files changed, 1909 insertions(+), 95 deletions(-) create mode 100644 fixtures/pipeline/kiro-requirements.candidates.json create mode 100644 fixtures/pipeline/kiro-requirements.md create mode 100644 fixtures/pipeline/openspec-spec.candidates.json create mode 100644 fixtures/pipeline/openspec-spec.md create mode 100644 fixtures/pipeline/speckit-spec.candidates.json create mode 100644 fixtures/pipeline/speckit-spec.md create mode 100644 fixtures/pipeline/strict-basic.candidates.json create mode 100644 fixtures/pipeline/strict-basic.ears create mode 100644 packages/core/src/pipeline.test.ts create mode 100644 packages/core/src/pipeline.ts create mode 100644 packages/extract/src/fixtures.test.ts create mode 100644 packages/extract/src/locator.test.ts create mode 100644 packages/extract/src/locator.ts create mode 100644 packages/extract/src/markdown-scan.test.ts create mode 100644 packages/extract/src/markdown-scan.ts create mode 100644 packages/extract/src/normalize.ts create mode 100644 packages/extract/src/pipeline.test.ts create mode 100644 packages/extract/src/pipeline.ts diff --git a/docs/input-formats.md b/docs/input-formats.md index 0106855..ade048f 100644 --- a/docs/input-formats.md +++ b/docs/input-formats.md @@ -238,3 +238,75 @@ const result = extractFromContent(content, 'requirements.md'); // the file system; a read failure is reported in `errors`, not thrown. const fromDisk = extractFromFile('specs/requirements.yaml'); ``` + +## Host-native pipeline + +The `extractEars`/`extractMarkdown`/`extractYaml`/`extractJson` functions above +are the non-profile surface: they read every list item, table row, and structured +entry regardless of context. The host-native commands (`validate`, `extract`) +use a profile-driven pipeline instead, so a profile controls which regions of a +host document become requirement candidates. + +Two entry points cover the pipeline: + +```ts +import { extractCandidates, runPipeline } from '@earsyntax/extract'; +import { BUILTIN_PROFILES } from '@earsyntax/core'; + +// Stage 1-2 only: locate candidates (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, id?, locatorRuleId, profile }`. +`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 +lifted metadata prefix. `locatorRuleId` is the id of the profile `LocatorRule` +that selected the candidate, so `extract` output can be traced back to the +profile. + +### 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. + +### Frame metadata under the pipeline + +Under the pipeline, a `REQ-001:` id prefix and a `[source: path:line]` tag are +lifted from a line only when the profile's dialect sets `allowFrameMetadata` +(the `ears-x` profile does; `strict`, `kiro`, `speckit`, and `openspec` do not). +Under a profile that does not allow frame metadata, the prefix is kept as part of +the requirement text, and the linter reports it. A lifted `[source: ...]` tag is +stripped from the text but does not move the reported position: findings point at +the physical line and column where the text sits, so an agent can edit it in +place. This differs from the non-profile `extractEars`, where a `[source: ...]` +reference overrides the item's source location. + +### 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. diff --git a/fixtures/pipeline/kiro-requirements.candidates.json b/fixtures/pipeline/kiro-requirements.candidates.json new file mode 100644 index 0000000..3eddb85 --- /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.", + "locatorRuleId": "kiro.acceptance-criteria-item", + "profile": "kiro" + }, + { + "file": "kiro-requirements.md", + "line": 8, + "col": 4, + "text": "IF the signature is invalid THEN THE SYSTEM SHALL reject the webhook.", + "locatorRuleId": "kiro.acceptance-criteria-item", + "profile": "kiro" + } +] 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..7d490de --- /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.", + "locatorRuleId": "openspec.requirement", + "profile": "openspec" + }, + { + "file": "openspec-spec.md", + "line": 9, + "col": 1, + "text": "If the signature is invalid, then the billing service shall reject the webhook.", + "locatorRuleId": "openspec.requirement", + "profile": "openspec" + }, + { + "file": "openspec-spec.md", + "line": 13, + "col": 1, + "text": "The billing service shall log every attempt.", + "locatorRuleId": "openspec.requirement", + "profile": "openspec" + } +] 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..3c48e94 --- /dev/null +++ b/fixtures/pipeline/speckit-spec.candidates.json @@ -0,0 +1,18 @@ +[ + { + "file": "speckit-spec.md", + "line": 5, + "col": 1, + "text": "The billing service shall verify the signature.", + "locatorRuleId": "speckit.requirements-section", + "profile": "speckit" + }, + { + "file": "speckit-spec.md", + "line": 6, + "col": 1, + "text": "When a payment webhook arrives, the billing service shall record the attempt.", + "locatorRuleId": "speckit.requirements-section", + "profile": "speckit" + } +] diff --git a/fixtures/pipeline/speckit-spec.md b/fixtures/pipeline/speckit-spec.md new file mode 100644 index 0000000..b6ad3b2 --- /dev/null +++ b/fixtures/pipeline/speckit-spec.md @@ -0,0 +1,10 @@ +# Feature spec + +## Requirements + +The billing service shall verify the signature. +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..afe565d --- /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.", + "locatorRuleId": "strict.every-line", + "profile": "strict" + }, + { + "file": "strict-basic.ears", + "line": 4, + "col": 1, + "text": "When a payment webhook arrives, the billing service shall record the attempt.", + "locatorRuleId": "strict.every-line", + "profile": "strict" + } +] 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/packages/core/src/pipeline.test.ts b/packages/core/src/pipeline.test.ts new file mode 100644 index 0000000..2d05c60 --- /dev/null +++ b/packages/core/src/pipeline.test.ts @@ -0,0 +1,107 @@ +/** + * 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, + locatorRuleId: 'strict.every-line', + profile: 'strict', + ...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({ id: '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..87148a9 --- /dev/null +++ b/packages/core/src/pipeline.ts @@ -0,0 +1,148 @@ +/** + * 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`, `id?`, `locatorRuleId`, `profile` when serialized by the CLI. + */ +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, with any located marker or metadata prefix removed. */ + text: string; + /** The requirement's own id (for example `REQ-001`), when the locator found one. */ + id?: string; + /** The id of the {@link import('./profiles/index.js').LocatorRule} that selected this candidate. */ + locatorRuleId: string; + /** The active profile's name. */ + profile: ProfileName; +} + +/** + * 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.id === undefined ? {} : { id: candidate.id }), + text: candidate.text, + source, + }; +} diff --git a/packages/core/src/profiles/schema.test.ts b/packages/core/src/profiles/schema.test.ts index 949c6a4..877037a 100644 --- a/packages/core/src/profiles/schema.test.ts +++ b/packages/core/src/profiles/schema.test.ts @@ -32,10 +32,20 @@ function baseProfile(): Profile { }; } -function errorAt(errors: ProfileValidationError[], path: string): ProfileValidationError | undefined { +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()); @@ -63,7 +73,7 @@ describe('validateProfile', () => { test('rejects an unknown key inside dialect', () => { const input = baseProfile(); - (input.dialect as Record).surprise = 1; + mutable(input.dialect).surprise = 1; const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { @@ -73,7 +83,7 @@ describe('validateProfile', () => { test('rejects an unknown key inside a nested locator rule (recursive)', () => { const input = baseProfile(); - (input.locator.include[0] as Record).bogus = 'x'; + mutable(input.locator.include[0]).bogus = 'x'; const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { @@ -83,7 +93,7 @@ describe('validateProfile', () => { test('rejects an unknown key inside idFormat', () => { const input = baseProfile(); - (input.idFormat as Record).flavor = 'x'; + mutable(input.idFormat).flavor = 'x'; const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { @@ -111,7 +121,7 @@ describe('validateProfile', () => { test('rejects a bad locator-rule kind', () => { const input = baseProfile(); - (input.locator.include[0] as Record).kind = 'paragraph'; + mutable(input.locator.include[0]).kind = 'paragraph'; const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { @@ -162,7 +172,9 @@ describe('validateProfile', () => { const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { - expect(errorAt(result.errors, 'locator.include[0].headingPattern')?.code).toBe('invalid-pattern'); + expect(errorAt(result.errors, 'locator.include[0].headingPattern')?.code).toBe( + 'invalid-pattern', + ); } }); @@ -177,7 +189,7 @@ describe('validateProfile', () => { test('rejects a non-string entry in allowLiteralSystemName', () => { const input = baseProfile(); - (input.dialect as Record).allowLiteralSystemName = ['THE SYSTEM', 3]; + mutable(input.dialect).allowLiteralSystemName = ['THE SYSTEM', 3]; const result = validateProfile(input); expect(result.ok).toBe(false); if (!result.ok) { diff --git a/packages/extract/src/ears.ts b/packages/extract/src/ears.ts index 4e9ae85..48d241a 100644 --- a/packages/extract/src/ears.ts +++ b/packages/extract/src/ears.ts @@ -14,16 +14,17 @@ import type { RequirementInput } from '@earsyntax/core'; import type { ExtractResult } from './types.js'; import { splitId } from './internal.js'; +import { stripBom } from './normalize.js'; /** * Extract requirements from `.ears` content. * - * @param content Raw file contents. + * @param rawContent Raw file contents. * @param file Optional source path, echoed onto each item's source location. */ -export function extractEars(content: string, file?: string): ExtractResult { +export function extractEars(rawContent: string, file?: string): ExtractResult { const items: RequirementInput[] = []; - const lines = content.split('\n'); + const lines = stripBom(rawContent).split('\n'); for (let i = 0; i < lines.length; i++) { const raw = lines[i]; 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 index 1857228..18a32c9 100644 --- a/packages/extract/src/index.ts +++ b/packages/extract/src/index.ts @@ -16,3 +16,22 @@ export { extractMarkdown } from './markdown.js'; export { extractYaml } from './yaml.js'; export { extractJson } from './json.js'; export { extractFromContent, extractFromFile } from './dispatch.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 index 7b09a9f..a27518a 100644 --- a/packages/extract/src/internal.ts +++ b/packages/extract/src/internal.ts @@ -39,8 +39,13 @@ export interface SplitId { * - `REQ-001 [source: path:line]: ` * - `REQ-001 [source: path:line-line]: ` * - * Group 1 is the identifier (a letter followed by letters, digits, underscores, - * dots, or hyphens). Group 2 is the optional bracketed segment, captured as an + * 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. * @@ -48,7 +53,7 @@ export interface SplitId { * `When a payment ...` never matches: the first token `When` is followed by * neither a bracket nor a colon. */ -const ID_PREFIX = /^([A-Za-z][A-Za-z0-9_.-]*)(?:\s*(\[[^\]]*\]))?\s*:\s*(\S.*)$/; +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 @@ -91,25 +96,20 @@ export function splitId(raw: string): SplitId { } /** - * Find the 1-based line number of the first occurrence of `needle` in - * `content`. Returns `undefined` when the needle is absent or empty. + * Read the `requirements` array from a parsed structured document. * - * Used to give structured formats (YAML, JSON) a best-effort source line by - * locating an item's id or text in the raw 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 findLine(content: string, needle: string | undefined): number | undefined { - if (!needle) { - return undefined; - } - const index = content.indexOf(needle); - if (index === -1) { +export function readRequirementsArray(parsed: unknown): unknown[] | undefined { + if (typeof parsed !== 'object' || parsed === null) { return undefined; } - let line = 1; - for (let i = 0; i < index; i++) { - if (content[i] === '\n') { - line++; - } - } - return line; + const requirements = (parsed as Record).requirements; + return Array.isArray(requirements) ? requirements : undefined; } diff --git a/packages/extract/src/json.ts b/packages/extract/src/json.ts index d0edc6c..8366c82 100644 --- a/packages/extract/src/json.ts +++ b/packages/extract/src/json.ts @@ -15,15 +15,17 @@ import type { RequirementInput } from '@earsyntax/core'; import type { ExtractError, ExtractResult } from './types.js'; -import { findLine } from './internal.js'; +import { readRequirementsArray } from './internal.js'; +import { LineFinder, stripBom } from './normalize.js'; /** * Extract requirements from JSON content. * - * @param content Raw file contents. + * @param rawContent Raw file contents. * @param file Optional source path, echoed onto each item and error. */ -export function extractJson(content: string, file?: string): ExtractResult { +export function extractJson(rawContent: string, file?: string): ExtractResult { + const content = stripBom(rawContent); const items: RequirementInput[] = []; const errors: ExtractError[] = []; @@ -47,6 +49,7 @@ export function extractJson(content: string, file?: string): ExtractResult { 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) { @@ -66,7 +69,7 @@ export function extractJson(content: string, file?: string): ExtractResult { continue; } const id = typeof record.id === 'string' ? record.id : undefined; - const line = findLine(content, id) ?? findLine(content, text); + const line = finder.locate(id) ?? finder.locate(text); items.push({ ...(id === undefined ? {} : { id }), text: text.trim(), @@ -79,11 +82,3 @@ export function extractJson(content: string, file?: string): ExtractResult { return { items, errors }; } - -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/locator.test.ts b/packages/extract/src/locator.test.ts new file mode 100644 index 0000000..0cb88a2 --- /dev/null +++ b/packages/extract/src/locator.test.ts @@ -0,0 +1,154 @@ +/** + * 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 strip 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].id).toBeUndefined(); + }); + + it('lifts a REQ- prefix under ears-x and advances the column to the text', () => { + const result = candidates('r.ears', 'REQ-001: The system shall stop.', earsX); + expect(result[0].id).toBe('REQ-001'); + expect(result[0].text).toBe('The system shall stop.'); + // 'REQ-001: ' is 9 characters, so the text starts at column 10. + expect(result[0].col).toBe(10); + }); +}); + +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'); + }); +}); + +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.', + ]); + }); +}); + +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..7b3ea41 --- /dev/null +++ b/packages/extract/src/locator.ts @@ -0,0 +1,458 @@ +/** + * 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: body lines until the next same/higher heading. */ +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 inBlock = new Array(model.lines.length).fill(false); + + let open = false; + for (let i = 0; i < model.lines.length; i++) { + if (model.fenceStates[i] === 'outside' && model.lines[i].trim().startsWith(prefix)) { + open = true; + continue; // the prefix heading line itself is not a candidate + } + if (open && model.headingLevels[i] > 0 && model.headingLevels[i] <= prefixLevel) { + open = false; + } + inBlock[i] = open; + } + + return bodyLineCandidates(model, profile, file, includeCode, rule.id, (i) => inBlock[i]); +} + +/** 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 }; +} + +/** + * Build one {@link Candidate}, computing the 1-based column of the text's first + * character in the raw line and lifting a frame-metadata prefix when allowed. + * + * 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 id: string | undefined; + + if (allowFrameMetadata) { + const split = splitId(text); + if (split.id !== undefined || split.ref !== undefined) { + // The stripped prefix is the removed leading run; advance the column past + // it so the reported position points at the requirement text. A declared + // `[source: ...]` reference is stripped from the text but does not move the + // physical position (the finding must point where the text actually is). + textStart += text.length - split.text.length; + text = split.text; + id = split.id; + } + } + + if (continuation.length > 0) { + text = [text, ...continuation].join(' ').trim(); + } + if (text === '') { + return undefined; + } + + const candidate: Candidate = { + file, + line, + col: textStart + 1, + text, + ...(id === undefined ? {} : { id }), + locatorRuleId, + profile: profile.name, + }; + 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..f8aa7b8 --- /dev/null +++ b/packages/extract/src/markdown-scan.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the shared Markdown scanning primitives: fence tracking by marker + * character and length, and pipe-row splitting that respects escaped pipes and + * inline code spans. These lock the Codex-review fixes in place. + */ + +import { describe, expect, it } from 'vitest'; +import { classifyFences, FenceTracker, splitTableRow } 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. + expect(tracker.feed('```')).toBe('inside'); + expect(tracker.isOpen).toBe(true); + expect(tracker.feed('````')).toBe('close'); + expect(tracker.isOpen).toBe(false); + }); + + 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']); + }); +}); + +describe('splitTableRow', () => { + it('drops leading and trailing pipes and trims cells', () => { + expect(splitTableRow('| ID | Requirement |')).toEqual(['ID', 'Requirement']); + }); + + it('accepts rows without a leading pipe (GFM)', () => { + expect(splitTableRow('ID | Requirement')).toEqual(['ID', 'Requirement']); + }); + + it('does not split on an escaped pipe', () => { + expect(splitTableRow('| a \\| b | c |')).toEqual(['a \\| b', 'c']); + }); + + it('does not split on a pipe inside an inline code span', () => { + expect(splitTableRow('| `a | b` | c |')).toEqual(['`a | b`', 'c']); + }); +}); diff --git a/packages/extract/src/markdown-scan.ts b/packages/extract/src/markdown-scan.ts new file mode 100644 index 0000000..f94d8c1 --- /dev/null +++ b/packages/extract/src/markdown-scan.ts @@ -0,0 +1,151 @@ +/** + * Shared Markdown scanning primitives for `@earsyntax/extract`. Not part of the + * public API. + * + * Two structures are shared by the legacy Markdown extractor and the + * profile-driven locator so fenced-code and table handling stay consistent: + * + * 1. {@link FenceTracker}, a CommonMark-aware fenced-code-block tracker that + * 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. + * 2. {@link splitTableRow}, a GFM pipe-row splitter that ignores escaped pipes + * (`\|`) and pipes inside inline code spans (`` `a | b` ``), and tolerates + * rows with or without leading and trailing pipes. + */ + +/** 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'; + } + + /** Whether the tracker is currently inside an unclosed fence. */ + get isOpen(): boolean { + return this.open !== null; + } +} + +/** + * 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)); +} + +/** + * Split a GFM pipe-table row into trimmed cells. + * + * Pipes escaped as `\|` and pipes inside inline code spans are not treated as + * cell separators. A single leading and a single trailing pipe (the common GFM + * form) are dropped; rows without them are still split correctly. + * + * @param row The trimmed row text. + * @returns The row's cells, trimmed, in order. + */ +export function splitTableRow(row: string): string[] { + const cells: string[] = []; + let current = ''; + let codeRun = 0; // length of the backtick run that opened the current code span + let i = 0; + + while (i < row.length) { + const ch = row[i]; + + if (ch === '\\' && codeRun === 0 && i + 1 < row.length) { + // A backslash escape outside code spans: keep the escaped character whole + // so an escaped pipe never splits the cell. + current += ch + row[i + 1]; + i += 2; + continue; + } + + if (ch === '`') { + let run = 0; + while (i + run < row.length && row[i + run] === '`') { + run++; + } + if (codeRun === 0) { + codeRun = run; + } else if (run === codeRun) { + codeRun = 0; + } + current += '`'.repeat(run); + i += run; + continue; + } + + if (ch === '|' && codeRun === 0) { + cells.push(current); + current = ''; + i++; + continue; + } + + current += ch; + i++; + } + cells.push(current); + + if (cells.length > 0 && cells[0].trim() === '' && row.startsWith('|')) { + cells.shift(); + } + if (cells.length > 0 && cells[cells.length - 1].trim() === '' && row.endsWith('|')) { + cells.pop(); + } + return cells.map((cell) => cell.trim()); +} diff --git a/packages/extract/src/markdown.test.ts b/packages/extract/src/markdown.test.ts index 32596b8..e3eee17 100644 --- a/packages/extract/src/markdown.test.ts +++ b/packages/extract/src/markdown.test.ts @@ -195,4 +195,33 @@ describe('extractMarkdown', () => { it('returns no items for an empty document', () => { expect(extractMarkdown('')).toEqual({ items: [], errors: [] }); }); + + it('joins indented continuation lines of a list item', () => { + const content = [ + '- When a payment webhook is received, the billing service', + ' shall verify the HMAC signature.', + '- The billing service shall retain receipts.', + ].join('\n'); + const { items } = extractMarkdown(content, 'requirements.md'); + expect(items.map((item) => item.text)).toEqual([ + 'When a payment webhook is received, the billing service shall verify the HMAC signature.', + 'The billing service shall retain receipts.', + ]); + }); + + it('accepts a GFM table without leading or trailing pipes', () => { + const content = [ + 'ID | Requirement', + '--- | ---', + 'REQ-001 | The billing service shall verify the HMAC signature.', + ].join('\n'); + const { items } = extractMarkdown(content, 'requirements.md'); + expect(items).toEqual([ + { + id: 'REQ-001', + text: 'The billing service shall verify the HMAC signature.', + source: { file: 'requirements.md', line: 3 }, + }, + ]); + }); }); diff --git a/packages/extract/src/markdown.ts b/packages/extract/src/markdown.ts index 3025edf..4d7457f 100644 --- a/packages/extract/src/markdown.ts +++ b/packages/extract/src/markdown.ts @@ -1,27 +1,33 @@ /** - * Markdown extractor. + * Markdown extractor (legacy, non-profile surface). * * Extracts requirements from three Markdown structures: * - * - bullet lists (`-`, `*`, `+`) - * - numbered lists (`1.`, `1)`) + * - bullet lists (`-`, `*`, `+`), including indented continuation lines + * - numbered lists (`1.`, `1)`), including indented continuation lines * - GFM pipe tables (`| ID | Requirement |` and single-column requirement - * tables) + * tables), with or without leading and trailing pipes * - * Everything else is ignored: prose paragraphs, headings, and fenced code - * blocks (both ``` ``` ``` and `~~~`). A metadata prefix inside a bullet, list - * item, or single-column table cell (`REQ-001:` or - * `REQ-001 [source: path:line]:`) is lifted into the item's `id`, and a - * `[source: ...]` reference becomes the item's source location. Tables with an - * explicit `ID` column take the id from that column instead. + * Everything else is ignored: prose paragraphs, headings, and fenced code blocks + * (both ` ``` ` and `~~~`, tracked by opening marker character and length). A + * metadata prefix inside a bullet, list item, or single-column table cell + * (`REQ-001:` or `REQ-001 [source: path:line]:`) is lifted into the item's `id`, + * and a `[source: ...]` reference becomes the item's source location. Tables with + * an explicit `ID` column take the id from that column instead. + * + * This is the non-profile extraction surface consumed by callers that want every + * list item and table row. The profile-driven host-native locator lives in + * `./locator.ts` and does not do table extraction (see `docs/input-formats.md`). */ import type { RequirementInput } from '@earsyntax/core'; import type { ExtractResult } from './types.js'; import { splitId, type SourceRef } from './internal.js'; +import { classifyFences, splitTableRow } from './markdown-scan.js'; +import { stripBom } from './normalize.js'; -const BULLET = /^\s*[-*+]\s+(.+)$/; -const NUMBERED = /^\s*\d+[.)]\s+(.+)$/; +const BULLET = /^(\s*)[-*+](\s+)(.+)$/; +const NUMBERED = /^(\s*)\d+[.)](\s+)(.+)$/; const SEPARATOR_CELL = /^:?-{1,}:?$/; const REQUIREMENT_HEADERS = new Set(['requirement', 'requirements', 'text', 'statement']); @@ -33,13 +39,13 @@ interface TableRow { /** * Extract requirements from Markdown content. * - * @param content Raw file contents. + * @param rawContent Raw file contents. * @param file Optional source path, echoed onto each item's source location. */ -export function extractMarkdown(content: string, file?: string): ExtractResult { +export function extractMarkdown(rawContent: string, file?: string): ExtractResult { const items: RequirementInput[] = []; - const lines = content.split('\n'); - let inFence = false; + const lines = stripBom(rawContent).split('\n'); + const fenceStates = classifyFences(lines); const push = ( id: string | undefined, @@ -61,43 +67,54 @@ export function extractMarkdown(content: string, file?: string): ExtractResult { let i = 0; while (i < lines.length) { const raw = lines[i]; - const trimmed = raw.trim(); - - if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { - inFence = !inFence; + if (fenceStates[i] !== 'outside') { + // A fence delimiter or a fenced-code line: never a requirement. i++; continue; } - if (inFence || trimmed === '' || trimmed.startsWith('#')) { + + const trimmed = raw.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { i++; continue; } const bullet = BULLET.exec(raw); if (bullet) { - const { id, ref, text } = splitId(bullet[1]); + const { text: itemText, next } = gatherListItem(lines, fenceStates, i, bullet[1].length); + const { id, ref, text } = splitId(itemText); push(id, text, i + 1, ref); - i++; + i = next; continue; } const numbered = NUMBERED.exec(raw); if (numbered) { - const { id, ref, text } = splitId(numbered[1]); + const { text: itemText, next } = gatherListItem(lines, fenceStates, i, numbered[1].length); + const { id, ref, text } = splitId(itemText); push(id, text, i + 1, ref); - i++; + i = next; continue; } - if (trimmed.startsWith('|')) { + if (trimmed.includes('|')) { const block: TableRow[] = []; let j = i; - while (j < lines.length && lines[j].trim().startsWith('|')) { - block.push({ line: j + 1, cells: splitRow(lines[j].trim()) }); + while (j < lines.length && fenceStates[j] === 'outside') { + const rowTrimmed = lines[j].trim(); + if (!rowTrimmed.includes('|') || rowTrimmed.startsWith('#') || rowTrimmed === '') { + break; + } + block.push({ line: j + 1, cells: splitTableRow(rowTrimmed) }); j++; } - extractTable(block, push); - i = j; + if (block.length >= 2) { + extractTable(block, push); + i = j; + continue; + } + // Not a table (single pipe line): treat as prose and skip just this line. + i++; continue; } @@ -109,18 +126,41 @@ export function extractMarkdown(content: string, file?: string): ExtractResult { } /** - * Split a pipe-delimited table row into trimmed cells, dropping the empty - * segments produced by leading and trailing pipes. + * Gather a list item's text starting at `start`, absorbing indented continuation + * lines that are more indented than the marker and are not themselves a new list + * item, heading, blank line, or fenced-code delimiter. Continuation lines are + * joined to the first line with single spaces. + * + * @returns The joined item text and the index of the first unconsumed line. */ -function splitRow(trimmed: string): string[] { - let body = trimmed; - if (body.startsWith('|')) { - body = body.slice(1); - } - if (body.endsWith('|')) { - body = body.slice(0, -1); +function gatherListItem( + lines: string[], + fenceStates: readonly string[], + start: number, + markerIndent: number, +): { text: string; next: number } { + const first = lines[start]; + const firstBody = (BULLET.exec(first) ?? NUMBERED.exec(first))?.[3] ?? first.trim(); + const parts = [firstBody.trim()]; + + let j = start + 1; + while (j < lines.length) { + const line = lines[j]; + if (line.trim() === '' || fenceStates[j] !== 'outside') { + break; + } + const indent = line.length - line.trimStart().length; + if (indent <= markerIndent) { + break; + } + if (BULLET.test(line) || NUMBERED.test(line) || line.trim().startsWith('#')) { + break; + } + parts.push(line.trim()); + j++; } - return body.split('|').map((cell) => cell.trim()); + + return { text: parts.join(' '), next: j }; } /** 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..fdef1e2 --- /dev/null +++ b/packages/extract/src/pipeline.test.ts @@ -0,0 +1,96 @@ +/** + * 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', + id: '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].id).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..9c9d1ef --- /dev/null +++ b/packages/extract/src/pipeline.ts @@ -0,0 +1,229 @@ +/** + * 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, + ...(item.id === undefined ? {} : { id: item.id }), + locatorRuleId, + profile: profile.name, + }; + 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/yaml.ts b/packages/extract/src/yaml.ts index 7c9f34d..14c1e7e 100644 --- a/packages/extract/src/yaml.ts +++ b/packages/extract/src/yaml.ts @@ -18,15 +18,17 @@ import jsYaml from 'js-yaml'; import type { RequirementInput } from '@earsyntax/core'; import type { ExtractError, ExtractResult } from './types.js'; -import { findLine } from './internal.js'; +import { readRequirementsArray } from './internal.js'; +import { LineFinder, stripBom } from './normalize.js'; /** * Extract requirements from YAML content. * - * @param content Raw file contents. + * @param rawContent Raw file contents. * @param file Optional source path, echoed onto each item and error. */ -export function extractYaml(content: string, file?: string): ExtractResult { +export function extractYaml(rawContent: string, file?: string): ExtractResult { + const content = stripBom(rawContent); const items: RequirementInput[] = []; const errors: ExtractError[] = []; @@ -35,11 +37,15 @@ export function extractYaml(content: string, file?: string): ExtractResult { 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. - line: error.mark.line + 1, + ...(mark ? { line: mark.line + 1 } : {}), }); } else { errors.push({ @@ -59,6 +65,7 @@ export function extractYaml(content: string, file?: string): ExtractResult { 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) { @@ -78,21 +85,13 @@ export function extractYaml(content: string, file?: string): ExtractResult { continue; } const id = typeof record.id === 'string' ? record.id : undefined; - const line = findLine(content, id) ?? findLine(content, text); + const line = finder.locate(id) ?? finder.locate(text); items.push(buildInput(id, text.trim(), file, line)); } return { items, errors }; } -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; -} - function buildInput( id: string | undefined, text: string, From 513a30fae035d63db28f38cbcf946d206759726b Mon Sep 17 00:00:00 2001 From: omermorad Date: Sat, 1 Aug 2026 20:53:43 +0300 Subject: [PATCH 11/20] fix(extract): facade candidate shape, block locator ears-only capture requirementId field with fixed key order, bold ID labels stripped and lifted, ears-x frame prefix retained with position fidelity, Gherkin scenario steps excluded from block candidates. --- fixtures/facade/README.md | 42 ---- fixtures/facade/accepted.manifest.json | 29 --- fixtures/facade/instructions-author.json | 48 ----- fixtures/facade/instructions-convert.json | 75 ------- fixtures/facade/instructions-repair.json | 64 ------ fixtures/facade/instructions-review.json | 51 ----- fixtures/facade/scaffolded.manifest.json | 22 -- fixtures/facade/stale.manifest.json | 29 --- fixtures/facade/status.response.json | 29 --- fixtures/facade/traceability.json | 99 --------- fixtures/facade/valid.manifest.json | 23 -- fixtures/facade/validate.response.json | 85 -------- .../kiro-requirements.candidates.json | 8 +- .../pipeline/openspec-spec.candidates.json | 12 +- .../pipeline/speckit-spec.candidates.json | 10 +- fixtures/pipeline/speckit-spec.md | 4 +- .../pipeline/strict-basic.candidates.json | 8 +- .../ears-x/frame-metadata.candidates.json | 2 +- .../profiles/speckit/plan.candidates.json | 2 +- .../profiles/speckit/spec.candidates.json | 12 +- fixtures/profiles/speckit/spec.expected.json | 4 +- packages/cli/src/commands/accept.ts | 76 ------- packages/cli/src/commands/list.ts | 34 --- packages/cli/src/commands/new.ts | 197 ------------------ packages/cli/src/commands/show.ts | 79 ------- packages/cli/src/commands/status.ts | 72 ------- packages/cli/src/hash.ts | 15 -- packages/cli/src/mode.ts | 21 -- packages/cli/src/next-actions.ts | 61 ------ packages/cli/src/project.ts | 101 --------- packages/cli/src/rules.ts | 104 --------- packages/cli/src/workspace.ts | 192 ----------------- packages/core/src/pipeline.test.ts | 4 +- packages/core/src/pipeline.ts | 15 +- packages/extract/src/locator.test.ts | 50 ++++- packages/extract/src/locator.ts | 103 +++++++-- packages/extract/src/pipeline.test.ts | 4 +- packages/extract/src/pipeline.ts | 4 +- 38 files changed, 170 insertions(+), 1620 deletions(-) delete mode 100644 fixtures/facade/README.md delete mode 100644 fixtures/facade/accepted.manifest.json delete mode 100644 fixtures/facade/instructions-author.json delete mode 100644 fixtures/facade/instructions-convert.json delete mode 100644 fixtures/facade/instructions-repair.json delete mode 100644 fixtures/facade/instructions-review.json delete mode 100644 fixtures/facade/scaffolded.manifest.json delete mode 100644 fixtures/facade/stale.manifest.json delete mode 100644 fixtures/facade/status.response.json delete mode 100644 fixtures/facade/traceability.json delete mode 100644 fixtures/facade/valid.manifest.json delete mode 100644 fixtures/facade/validate.response.json delete mode 100644 packages/cli/src/commands/accept.ts delete mode 100644 packages/cli/src/commands/list.ts delete mode 100644 packages/cli/src/commands/new.ts delete mode 100644 packages/cli/src/commands/show.ts delete mode 100644 packages/cli/src/commands/status.ts delete mode 100644 packages/cli/src/hash.ts delete mode 100644 packages/cli/src/mode.ts delete mode 100644 packages/cli/src/next-actions.ts delete mode 100644 packages/cli/src/project.ts delete mode 100644 packages/cli/src/rules.ts delete mode 100644 packages/cli/src/workspace.ts diff --git a/fixtures/facade/README.md b/fixtures/facade/README.md deleted file mode 100644 index a80e916..0000000 --- a/fixtures/facade/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Facade fixtures - -Golden JSON examples for the `earsyntax` facade contracts. The CLI agent tests its output against these files. They are documents, not code: each pins the exact shape of one contract so that a change in CLI output that drifts from the contract shows up as a fixture diff. - -All fixtures use one running example, the `checkout-webhooks` work item converted from `specs/checkout.md`. Values that would otherwise vary by run are fixed so the fixtures are deterministic: - -- Source hash (version 1): `sha256:1111...1111` -- Output hash: `sha256:2222...2222` -- Source hash (version 2, the changed source that triggers `stale`): `sha256:3333...3333` -- Timestamps are fixed ISO 8601 UTC values on 2026-07-28. - -The normative field descriptions live in `docs/facade-api.md`. The loop and state machine live in `docs/agentic-loop.md`. The instruction bodies live in `docs/agent-rules.md`. - -## Manifests - -Each manifest pins the `WorkManifest` shape at one `WorkStatus` state. They share one id and progress through the loop. - -| File | Pins | -| -------------------------- | ------------------------------------------------------------------------------------------------------- | -| `scaffolded.manifest.json` | State right after `earsyntax new`: source hashed, output path reserved, no output hash, no acceptance. | -| `valid.manifest.json` | State after a clean `earsyntax validate`: output hash recorded, status `valid`, no acceptance yet. | -| `accepted.manifest.json` | State after `earsyntax accept`: an `accepted` block with `at`, `by`, `sourceHash`, and `outputHash`. | -| `stale.manifest.json` | Source hash changed after acceptance: `source.hash` differs from `accepted.sourceHash`, status `stale`. | - -## Instruction responses - -Each pins the `instructions --json` response for one mode. Every `next[]` entry is a real `earsyntax` command from the facade contract. - -| File | Pins | -| --------------------------- | ------------------------------------------------------------------------------------------------- | -| `instructions-author.json` | Author mode: no `source` block, rules forbid inventing behavior beyond the prompt. | -| `instructions-convert.json` | Convert mode: a `source` block with hash and excerpts, full convert rule set. | -| `instructions-repair.json` | Repair mode: a `diagnostics` array the repair must address, rules keyed to the reported codes. | -| `instructions-review.json` | Review mode: review-summary rules, `next[]` points at reading questions and recording acceptance. | - -## Other responses - -| File | Pins | -| ------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `traceability.json` | The `TraceabilityFile` shape: requirement traces with source lines and confidence, plus a blocking question. | -| `validate.response.json` | A `validate --json` response with one error result, one warning result, and a `work` summary; `ok` is false. | -| `status.response.json` | A `status --json` response for a `valid` work item, with review and accept as next actions. | diff --git a/fixtures/facade/accepted.manifest.json b/fixtures/facade/accepted.manifest.json deleted file mode 100644 index b4ddce0..0000000 --- a/fixtures/facade/accepted.manifest.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schemaVersion": 1, - "id": "checkout-webhooks", - "mode": "convert", - "status": "accepted", - "source": { - "path": "specs/checkout.md", - "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kind": "markdown" - }, - "output": { - "path": ".earsyntax/work/checkout-webhooks/requirements.ears", - "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" - }, - "artifacts": { - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", - "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", - "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" - }, - "createdAt": "2026-07-28T10:00:00.000Z", - "updatedAt": "2026-07-28T10:30:00.000Z", - "accepted": { - "at": "2026-07-28T10:30:00.000Z", - "by": "omer", - "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" - } -} diff --git a/fixtures/facade/instructions-author.json b/fixtures/facade/instructions-author.json deleted file mode 100644 index 5cac898..0000000 --- a/fixtures/facade/instructions-author.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": "0.1.0", - "command": "instructions author", - "ok": true, - "root": "/repo", - "mode": "author", - "work": { - "id": "checkout-webhooks", - "status": "scaffolded", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "rules": [ - "Treat the prompt as the only source of behavior; do not add requirements it does not state.", - "Write only EARS requirements in the .ears output file.", - "Use one requirement per non-empty line.", - "Give every requirement a stable ID such as REQ-001.", - "Use the narrowest EARS pattern that fits the described behavior.", - "Do not invent behavior that seems reasonable, secure, or common but is absent from the prompt.", - "Do not hide ambiguity inside vague wording.", - "Split compound behavior into separate requirements when the response holds more than one observable obligation.", - "Write unclear behavior to questions.md instead of guessing a precise requirement.", - "Record each generated requirement in traceability.json; author-mode requirements have no source line, so leave the source object empty and set confidence." - ], - "format": { - "line": "REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.", - "allowedPatterns": [ - "The shall .", - "When , the shall .", - "While , the shall .", - "Where , the shall .", - "If , then the shall ." - ], - "metadataPrefixes": [ - "REQ-001:", - "REQ-001 [source: path:line]:", - "REQ-001 [source: path:line-line]:" - ] - }, - "next": [ - { - "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --json", - "reason": "Validate the authored .ears file once it is written.", - "forAgent": true - } - ] -} diff --git a/fixtures/facade/instructions-convert.json b/fixtures/facade/instructions-convert.json deleted file mode 100644 index 6742411..0000000 --- a/fixtures/facade/instructions-convert.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "version": "0.1.0", - "command": "instructions convert", - "ok": true, - "root": "/repo", - "mode": "convert", - "work": { - "id": "checkout-webhooks", - "status": "scaffolded", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "rules": [ - "Read the full source before writing requirements.", - "Write only EARS requirements in the .ears output file.", - "Use one requirement per non-empty line.", - "Give every requirement a stable ID such as REQ-001.", - "Preserve source traceability with a [source: path:line] prefix when the source line is known.", - "Use the narrowest EARS pattern that fits the source behavior.", - "Do not invent behavior that is not stated in the source.", - "Do not hide ambiguity inside vague wording.", - "Split compound behavior into separate requirements when the response holds more than one observable obligation.", - "Write unclear behavior to questions.md instead of guessing a precise requirement.", - "Maintain traceability.json for every generated requirement and question.", - "Do not edit the source spec unless the user explicitly asks." - ], - "format": { - "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", - "allowedPatterns": [ - "The shall .", - "When , the shall .", - "While , the shall .", - "Where , the shall .", - "If , then the shall ." - ], - "metadataPrefixes": [ - "REQ-001:", - "REQ-001 [source: path:line]:", - "REQ-001 [source: path:line-line]:" - ] - }, - "source": { - "path": "specs/checkout.md", - "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "excerpts": [ - { - "path": "specs/checkout.md", - "startLine": 7, - "endLine": 8, - "text": "When a payment webhook is received, the billing service must verify the HMAC signature before doing anything else." - }, - { - "path": "specs/checkout.md", - "startLine": 10, - "endLine": 10, - "text": "If the HMAC signature is invalid, the webhook must be rejected." - }, - { - "path": "specs/checkout.md", - "startLine": 12, - "endLine": 13, - "text": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." - } - ] - }, - "next": [ - { - "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", - "reason": "Validate the generated .ears file once it is written.", - "forAgent": true - } - ] -} diff --git a/fixtures/facade/instructions-repair.json b/fixtures/facade/instructions-repair.json deleted file mode 100644 index eb9c7fd..0000000 --- a/fixtures/facade/instructions-repair.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "version": "0.1.0", - "command": "instructions repair", - "ok": true, - "root": "/repo", - "mode": "repair", - "work": { - "id": "checkout-webhooks", - "status": "invalid", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "rules": [ - "Change only what the diagnostics justify; leave passing requirements untouched.", - "Preserve requirement IDs unless a duplicate-ID diagnostic forces a change.", - "Preserve the [source: path:line] references on repaired lines.", - "Preserve the intended behavior; do not weaken a requirement because it is harder to parse.", - "Do not delete a failing requirement to make validation pass.", - "For ears.invalid_if_then_form, add the missing then boundary: If , then the shall .", - "For lint.vague_response, replace the vague term with an observable, bounded response, or raise a question in questions.md if the bound is unknown.", - "If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing.", - "Update traceability.json if an ID or source reference changes." - ], - "format": { - "line": "REQ-002 [source: specs/checkout.md:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook.", - "allowedPatterns": [ - "The shall .", - "When , the shall .", - "While , the shall .", - "Where , the shall .", - "If , then the shall ." - ], - "metadataPrefixes": [ - "REQ-001:", - "REQ-001 [source: path:line]:", - "REQ-001 [source: path:line-line]:" - ] - }, - "diagnostics": [ - { - "code": "ears.invalid_if_then_form", - "severity": "error", - "message": "If requirements must include a then boundary before the system response.", - "path": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 2 - }, - { - "code": "lint.vague_response", - "severity": "warning", - "message": "The response contains a configured vague term.", - "path": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 8 - } - ], - "next": [ - { - "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", - "reason": "Re-validate after applying the repairs and repeat until validation is clean.", - "forAgent": true - } - ] -} diff --git a/fixtures/facade/instructions-review.json b/fixtures/facade/instructions-review.json deleted file mode 100644 index 0c8b706..0000000 --- a/fixtures/facade/instructions-review.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "version": "0.1.0", - "command": "instructions review", - "ok": true, - "root": "/repo", - "mode": "review", - "work": { - "id": "checkout-webhooks", - "status": "valid", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "rules": [ - "Present a review summary before recommending acceptance; do not accept on the human's behalf.", - "Report how many requirements were generated and the distribution across the six EARS patterns.", - "List every unresolved question in questions.md and the requirements each one blocks.", - "State the validation status and the exact command that produced it.", - "Call out source lines translated with medium or low confidence.", - "Call out any source behavior that was intentionally not converted, with the reason.", - "Recommend acceptance only when validation is clean and no blocking questions remain." - ], - "format": { - "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", - "allowedPatterns": [ - "The shall .", - "When , the shall .", - "While , the shall .", - "Where , the shall .", - "If , then the shall ." - ], - "metadataPrefixes": [ - "REQ-001:", - "REQ-001 [source: path:line]:", - "REQ-001 [source: path:line-line]:" - ] - }, - "next": [ - { - "command": "earsyntax show checkout-webhooks --artifact questions --json", - "reason": "Read the open questions the human must resolve before acceptance.", - "forAgent": true - }, - { - "command": "earsyntax accept checkout-webhooks --by \"omer\"", - "reason": "Record human acceptance once the reviewer approves.", - "blocking": true - } - ] -} diff --git a/fixtures/facade/scaffolded.manifest.json b/fixtures/facade/scaffolded.manifest.json deleted file mode 100644 index d76af28..0000000 --- a/fixtures/facade/scaffolded.manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "schemaVersion": 1, - "id": "checkout-webhooks", - "mode": "convert", - "status": "scaffolded", - "source": { - "path": "specs/checkout.md", - "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kind": "markdown" - }, - "output": { - "path": ".earsyntax/work/checkout-webhooks/requirements.ears" - }, - "artifacts": { - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", - "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", - "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" - }, - "createdAt": "2026-07-28T10:00:00.000Z", - "updatedAt": "2026-07-28T10:00:00.000Z" -} diff --git a/fixtures/facade/stale.manifest.json b/fixtures/facade/stale.manifest.json deleted file mode 100644 index 5e5f9aa..0000000 --- a/fixtures/facade/stale.manifest.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schemaVersion": 1, - "id": "checkout-webhooks", - "mode": "convert", - "status": "stale", - "source": { - "path": "specs/checkout.md", - "hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "kind": "markdown" - }, - "output": { - "path": ".earsyntax/work/checkout-webhooks/requirements.ears", - "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" - }, - "artifacts": { - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", - "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", - "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" - }, - "createdAt": "2026-07-28T10:00:00.000Z", - "updatedAt": "2026-07-28T11:00:00.000Z", - "accepted": { - "at": "2026-07-28T10:30:00.000Z", - "by": "omer", - "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" - } -} diff --git a/fixtures/facade/status.response.json b/fixtures/facade/status.response.json deleted file mode 100644 index 740c332..0000000 --- a/fixtures/facade/status.response.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "version": "0.1.0", - "command": "status", - "ok": true, - "root": "/repo", - "work": { - "id": "checkout-webhooks", - "status": "valid", - "mode": "convert", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "acceptedHash": null, - "stale": false - }, - "next": [ - { - "command": "earsyntax instructions review --work checkout-webhooks --json", - "reason": "Prepare the human review summary.", - "forAgent": true - }, - { - "command": "earsyntax accept checkout-webhooks", - "reason": "Record acceptance after human approval.", - "blocking": true - } - ] -} diff --git a/fixtures/facade/traceability.json b/fixtures/facade/traceability.json deleted file mode 100644 index 0ecb964..0000000 --- a/fixtures/facade/traceability.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "requirements": [ - { - "id": "REQ-001", - "source": { - "path": "specs/checkout.md", - "startLine": 7, - "endLine": 8, - "quote": "When a payment webhook is received, the billing service must verify the HMAC signature before doing anything else." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": [] - }, - { - "id": "REQ-002", - "source": { - "path": "specs/checkout.md", - "startLine": 10, - "endLine": 10, - "quote": "If the HMAC signature is invalid, the webhook must be rejected." - }, - "pattern": "unwanted-behaviour", - "confidence": "high", - "notes": [] - }, - { - "id": "REQ-003", - "source": { - "path": "specs/checkout.md", - "startLine": 12, - "endLine": 13, - "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": ["Split from a compound source obligation; see REQ-004 and REQ-005."] - }, - { - "id": "REQ-004", - "source": { - "path": "specs/checkout.md", - "startLine": 12, - "endLine": 13, - "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": ["Split from a compound source obligation; see REQ-003 and REQ-005."] - }, - { - "id": "REQ-005", - "source": { - "path": "specs/checkout.md", - "startLine": 12, - "endLine": 13, - "quote": "When a webhook arrives, validate the signature, persist the event, and enqueue a processing job." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": ["Split from a compound source obligation; see REQ-003 and REQ-004."] - }, - { - "id": "REQ-006", - "source": { - "path": "specs/checkout.md", - "startLine": 15, - "endLine": 15, - "quote": "While the payment provider is unavailable, queued events should be retried." - }, - "pattern": "state-driven", - "confidence": "medium", - "notes": ["Source uses \"should\"; treated as a requirement pending human confirmation."] - }, - { - "id": "REQ-007", - "source": { - "path": "specs/checkout.md", - "startLine": 17, - "endLine": 17, - "quote": "Where dunning management is enabled, declined charges are retried." - }, - "pattern": "optional-feature", - "confidence": "high", - "notes": [] - } - ], - "questions": [ - { - "source": { - "path": "specs/checkout.md", - "startLine": 19, - "endLine": 19 - }, - "question": "Source specs/checkout.md:19 says the customer should be notified \"quickly\" when a payment is declined. What channel should be used, and what is the time bound?", - "blocksRequirements": ["REQ-008"] - } - ] -} diff --git a/fixtures/facade/valid.manifest.json b/fixtures/facade/valid.manifest.json deleted file mode 100644 index 978844d..0000000 --- a/fixtures/facade/valid.manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": 1, - "id": "checkout-webhooks", - "mode": "convert", - "status": "valid", - "source": { - "path": "specs/checkout.md", - "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kind": "markdown" - }, - "output": { - "path": ".earsyntax/work/checkout-webhooks/requirements.ears", - "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" - }, - "artifacts": { - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json", - "validationJson": ".earsyntax/work/checkout-webhooks/validation.json", - "validationMarkdown": ".earsyntax/work/checkout-webhooks/validation.md" - }, - "createdAt": "2026-07-28T10:00:00.000Z", - "updatedAt": "2026-07-28T10:15:00.000Z" -} diff --git a/fixtures/facade/validate.response.json b/fixtures/facade/validate.response.json deleted file mode 100644 index f45d5ae..0000000 --- a/fixtures/facade/validate.response.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "version": "0.1.0", - "command": "validate", - "ok": false, - "root": "/repo", - "summary": { - "files": 1, - "requirements": 8, - "valid": 7, - "errors": 1, - "warnings": 1 - }, - "results": [ - { - "id": "REQ-001", - "file": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 1, - "valid": true, - "pattern": "event-driven", - "ast": { - "pattern": "event-driven", - "trigger": { - "kind": "term", - "text": "a payment webhook is received" - }, - "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." - }, - "references": [], - "diagnostics": [] - }, - { - "id": "REQ-002", - "file": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 2, - "valid": false, - "pattern": "unwanted-behaviour", - "references": [], - "diagnostics": [ - { - "code": "ears.invalid_if_then_form", - "severity": "error", - "message": "If requirements must include a then boundary before the system response.", - "span": { "start": 0, "end": 42 } - } - ] - }, - { - "id": "REQ-008", - "file": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 8, - "valid": true, - "pattern": "event-driven", - "references": [], - "diagnostics": [ - { - "code": "lint.vague_response", - "severity": "warning", - "message": "The response contains a configured vague term.", - "span": { "start": 41, "end": 65 } - } - ] - } - ], - "work": { - "id": "checkout-webhooks", - "status": "invalid", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "stale": false, - "next": [ - { - "command": "earsyntax instructions repair --work checkout-webhooks --json", - "reason": "Get targeted repair rules for the reported diagnostics.", - "forAgent": true - } - ] -} diff --git a/fixtures/pipeline/kiro-requirements.candidates.json b/fixtures/pipeline/kiro-requirements.candidates.json index 3eddb85..d3bdf4e 100644 --- a/fixtures/pipeline/kiro-requirements.candidates.json +++ b/fixtures/pipeline/kiro-requirements.candidates.json @@ -4,15 +4,15 @@ "line": 7, "col": 4, "text": "WHEN a payment webhook arrives THE SYSTEM SHALL verify the signature.", - "locatorRuleId": "kiro.acceptance-criteria-item", - "profile": "kiro" + "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.", - "locatorRuleId": "kiro.acceptance-criteria-item", - "profile": "kiro" + "profile": "kiro", + "locatorRuleId": "kiro.acceptance-criteria-item" } ] diff --git a/fixtures/pipeline/openspec-spec.candidates.json b/fixtures/pipeline/openspec-spec.candidates.json index 7d490de..c0e8d00 100644 --- a/fixtures/pipeline/openspec-spec.candidates.json +++ b/fixtures/pipeline/openspec-spec.candidates.json @@ -4,23 +4,23 @@ "line": 5, "col": 1, "text": "The billing service shall verify the signature.", - "locatorRuleId": "openspec.requirement", - "profile": "openspec" + "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.", - "locatorRuleId": "openspec.requirement", - "profile": "openspec" + "profile": "openspec", + "locatorRuleId": "openspec.scenario" }, { "file": "openspec-spec.md", "line": 13, "col": 1, "text": "The billing service shall log every attempt.", - "locatorRuleId": "openspec.requirement", - "profile": "openspec" + "profile": "openspec", + "locatorRuleId": "openspec.requirement" } ] diff --git a/fixtures/pipeline/speckit-spec.candidates.json b/fixtures/pipeline/speckit-spec.candidates.json index 3c48e94..e38f818 100644 --- a/fixtures/pipeline/speckit-spec.candidates.json +++ b/fixtures/pipeline/speckit-spec.candidates.json @@ -2,17 +2,19 @@ { "file": "speckit-spec.md", "line": 5, - "col": 1, + "col": 15, "text": "The billing service shall verify the signature.", + "profile": "speckit", "locatorRuleId": "speckit.requirements-section", - "profile": "speckit" + "requirementId": "FR-001" }, { "file": "speckit-spec.md", "line": 6, - "col": 1, + "col": 15, "text": "When a payment webhook arrives, the billing service shall record the attempt.", + "profile": "speckit", "locatorRuleId": "speckit.requirements-section", - "profile": "speckit" + "requirementId": "FR-002" } ] diff --git a/fixtures/pipeline/speckit-spec.md b/fixtures/pipeline/speckit-spec.md index b6ad3b2..ec464bc 100644 --- a/fixtures/pipeline/speckit-spec.md +++ b/fixtures/pipeline/speckit-spec.md @@ -2,8 +2,8 @@ ## Requirements -The billing service shall verify the signature. -When a payment webhook arrives, the billing service shall record the attempt. +- **FR-001**: The billing service shall verify the signature. +- **FR-002**: When a payment webhook arrives, the billing service shall record the attempt. ## Design diff --git a/fixtures/pipeline/strict-basic.candidates.json b/fixtures/pipeline/strict-basic.candidates.json index afe565d..443f096 100644 --- a/fixtures/pipeline/strict-basic.candidates.json +++ b/fixtures/pipeline/strict-basic.candidates.json @@ -4,15 +4,15 @@ "line": 3, "col": 1, "text": "The billing service shall verify the signature.", - "locatorRuleId": "strict.every-line", - "profile": "strict" + "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.", - "locatorRuleId": "strict.every-line", - "profile": "strict" + "profile": "strict", + "locatorRuleId": "strict.every-line" } ] diff --git a/fixtures/profiles/ears-x/frame-metadata.candidates.json b/fixtures/profiles/ears-x/frame-metadata.candidates.json index f229687..5ab83b4 100644 --- a/fixtures/profiles/ears-x/frame-metadata.candidates.json +++ b/fixtures/profiles/ears-x/frame-metadata.candidates.json @@ -2,7 +2,7 @@ "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. text is the raw line including the REQ-### prefix and [source:] tag. requirementId is populated from the frame id once frame-metadata parsing lands (pending C4b); the every-line locator itself yields one candidate per non-empty line unconditionally.", + "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", diff --git a/fixtures/profiles/speckit/plan.candidates.json b/fixtures/profiles/speckit/plan.candidates.json index 7a704d6..f6e0034 100644 --- a/fixtures/profiles/speckit/plan.candidates.json +++ b/fixtures/profiles/speckit/plan.candidates.json @@ -1,7 +1,7 @@ { "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": "fixtures/profiles/speckit/plan.md", + "file": "speckit/plan.md", "profile": "speckit", "candidates": [], "keywordOpeningDecoys": [ diff --git a/fixtures/profiles/speckit/spec.candidates.json b/fixtures/profiles/speckit/spec.candidates.json index f8f0934..752e073 100644 --- a/fixtures/profiles/speckit/spec.candidates.json +++ b/fixtures/profiles/speckit/spec.candidates.json @@ -1,11 +1,11 @@ { "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": "fixtures/profiles/speckit/spec.md", + "file": "speckit/spec.md", "profile": "speckit", "candidates": [ { - "file": "fixtures/profiles/speckit/spec.md", + "file": "speckit/spec.md", "line": 55, "col": 15, "text": "The system shall allow a workspace owner to invite a teammate by email address.", @@ -14,7 +14,7 @@ "requirementId": "FR-001" }, { - "file": "fixtures/profiles/speckit/spec.md", + "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.", @@ -23,7 +23,7 @@ "requirementId": "FR-002" }, { - "file": "fixtures/profiles/speckit/spec.md", + "file": "speckit/spec.md", "line": 57, "col": 15, "text": "While an invitation is pending, the system shall allow the owner to revoke it.", @@ -32,7 +32,7 @@ "requirementId": "FR-003" }, { - "file": "fixtures/profiles/speckit/spec.md", + "file": "speckit/spec.md", "line": 58, "col": 15, "text": "Where the workspace has audit logging enabled, the system shall record every member role change.", @@ -41,7 +41,7 @@ "requirementId": "FR-004" }, { - "file": "fixtures/profiles/speckit/spec.md", + "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.", diff --git a/fixtures/profiles/speckit/spec.expected.json b/fixtures/profiles/speckit/spec.expected.json index 29a464e..8fe6cad 100644 --- a/fixtures/profiles/speckit/spec.expected.json +++ b/fixtures/profiles/speckit/spec.expected.json @@ -3,14 +3,14 @@ "pendingVerification": true, "cases": [ { - "file": "fixtures/profiles/speckit/spec.md", + "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": "fixtures/profiles/speckit/plan.md", + "file": "speckit/plan.md", "profile": "speckit", "expect": "clean", "diagnostics": [], diff --git a/packages/cli/src/commands/accept.ts b/packages/cli/src/commands/accept.ts deleted file mode 100644 index 2cb906d..0000000 --- a/packages/cli/src/commands/accept.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * `earsyntax accept ` — record that a human accepted the `.ears` file. - * - * The human gate. Refuses (exit 3) when the status is not `valid`, when the - * source is stale, or when the `.ears` output changed since it was validated. - * On success it writes the `accepted` block and never touches source or output. - */ - -import type { CommandContext } from '../context.js'; -import type { WorkManifest } from '../facade-types.js'; -import { refusalError, usageError } from '../errors.js'; -import { loadConfig, requireRoot } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { currentOutputHash, isStale, requireManifest, writeManifest } from '../workspace.js'; - -export function acceptCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - - const slug = context.args.positionals.at(0); - if (slug === undefined) { - throw usageError('accept.missing_slug', 'Provide a work-item slug to accept.'); - } - const { paths, manifest } = requireManifest(root, config, slug); - - if (manifest.status !== 'valid') { - throw refusalError( - 'accept.not_valid', - `Cannot accept "${slug}": status is ${manifest.status}, not valid. Validate it first.`, - ); - } - if (isStale(root, manifest)) { - throw refusalError( - 'accept.stale', - `Cannot accept "${slug}": the source changed since it was validated. Re-run the loop.`, - ); - } - const outputHash = currentOutputHash(root, manifest); - if (outputHash === undefined) { - throw refusalError( - 'accept.output_missing', - `Cannot accept "${slug}": the .ears output is unreadable.`, - ); - } - if (manifest.output.hash !== undefined && outputHash !== manifest.output.hash) { - throw refusalError( - 'accept.output_changed', - `Cannot accept "${slug}": the .ears file was edited after validation. Re-validate first.`, - ); - } - - const by = context.args.values.get('by'); - const accepted: WorkManifest['accepted'] = { - at: new Date().toISOString(), - ...(by !== undefined ? { by } : {}), - ...(manifest.source?.hash !== undefined ? { sourceHash: manifest.source.hash } : {}), - outputHash, - }; - - const updated: WorkManifest = { ...manifest, status: 'accepted', accepted }; - writeManifest(paths, updated); - - const work: Record = { - id: manifest.id, - status: 'accepted', - mode: manifest.mode, - source: manifest.source?.path ?? null, - output: manifest.output.path, - accepted, - }; - - const response = buildResponse({ command: 'accept', ok: true, root, next: [] }, { work }); - const pretty = `Accepted "${slug}"${by ? ` by ${by}` : ''}.`; - emit(context.emitter, response, pretty); - return 0; -} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts deleted file mode 100644 index fd9584c..0000000 --- a/packages/cli/src/commands/list.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * `earsyntax list` — list work items, sorted by id. - * - * Read-only. `--status ` filters by the reported (computed) status, so - * `--status stale` surfaces items whose source drifted after validation. - */ - -import type { CommandContext } from '../context.js'; -import type { WorkStatus } from '../facade-types.js'; -import { loadConfig, requireRoot } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { listSlugs, readManifest, toWorkSummary, workPaths } from '../workspace.js'; - -export function listCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - const filter = context.args.values.get('status') as WorkStatus | undefined; - - const items = listSlugs(root, config) - .map((slug) => readManifest(workPaths(root, config, slug))) - .filter((manifest): manifest is NonNullable => manifest !== undefined) - .map((manifest) => toWorkSummary(root, manifest)) - .filter((summary) => filter === undefined || summary.status === filter); - - const response = buildResponse({ command: 'list', ok: true, root, next: [] }, { items }); - - const pretty = - items.length === 0 - ? 'No work items.' - : items.map((item) => `${item.id} ${item.status}`).join('\n'); - - emit(context.emitter, response, pretty); - return 0; -} diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts deleted file mode 100644 index 691f843..0000000 --- a/packages/cli/src/commands/new.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * `earsyntax new ` — scaffold a work item. - * - * Prepares the directory an agent writes into; it never generates EARS content. - * Convert mode requires `--source `, author mode requires `--prompt`. - * Refuses to clobber an existing work item without `--force` (exit 3). - */ - -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { extname, resolve } from 'node:path'; -import type { CommandContext } from '../context.js'; -import type { WorkManifest, WorkMode } from '../facade-types.js'; -import { refusalError, usageError } from '../errors.js'; -import { hashContent } from '../hash.js'; -import { loadConfig, requireRoot, resolveInput, toRelative } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { nextForStatus } from '../next-actions.js'; -import { sourceKind, workPaths } from '../workspace.js'; - -const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i; - -const QUESTIONS_TEMPLATE = [ - '# Open questions', - '', - '', - '', -].join('\n'); - -const TRACEABILITY_TEMPLATE = `${JSON.stringify({ requirements: [], questions: [] }, null, 2)}\n`; - -interface ModeResolution { - mode: WorkMode; - source?: string; - prompt?: string; -} - -function resolveMode(context: CommandContext): ModeResolution { - const modeFlag = context.args.values.get('mode'); - const source = context.args.values.get('source'); - const prompt = context.args.values.get('prompt'); - - if (modeFlag !== undefined && modeFlag !== 'convert' && modeFlag !== 'author') { - throw usageError('new.bad_mode', `Unknown --mode "${modeFlag}". Expected convert or author.`); - } - - const mode: WorkMode | undefined = - modeFlag === 'convert' || modeFlag === 'author' - ? modeFlag - : source !== undefined - ? 'convert' - : prompt !== undefined - ? 'author' - : undefined; - - if (mode === undefined) { - throw usageError( - 'new.missing_mode', - 'Specify a mode: pass --source (convert) or --prompt (author).', - ); - } - if (mode === 'convert' && source === undefined) { - throw usageError('new.missing_source', 'Convert mode requires --source .'); - } - if (mode === 'author' && prompt === undefined) { - throw usageError('new.missing_prompt', 'Author mode requires --prompt .'); - } - - return { - mode, - ...(source !== undefined ? { source } : {}), - ...(prompt !== undefined ? { prompt } : {}), - }; -} - -export function newCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - - const slug = context.args.positionals.at(0); - if (slug === undefined || !SLUG_RE.test(slug)) { - throw usageError( - 'new.bad_slug', - `Provide a valid work-item slug (letters, digits, ., -, _). Got: "${slug ?? ''}".`, - ); - } - - const { mode, source, prompt } = resolveMode(context); - const force = context.args.booleans.has('force'); - - const paths = workPaths(root, config, slug); - if (existsSync(paths.manifest) && !force) { - throw refusalError( - 'new.exists', - `Work item "${slug}" already exists. Pass --force to recreate.`, - ); - } - - // Read and hash the source for convert mode. - let sourceRel: string | undefined; - let sourceHash: string | undefined; - let snapshotRel: string | undefined; - if (mode === 'convert' && source !== undefined) { - const sourceAbs = resolveInput(context.cwd, source); - let content: string; - try { - content = readFileSync(sourceAbs, 'utf8'); - } catch { - throw usageError('new.source_unreadable', `Could not read source file: ${source}.`); - } - sourceRel = toRelative(root, sourceAbs); - sourceHash = hashContent(content); - if (context.args.booleans.has('snapshot-source')) { - const snapshotAbs = resolve(paths.dir, `source${extname(sourceAbs) || '.txt'}`); - mkdirSync(paths.dir, { recursive: true }); - copyFileSync(sourceAbs, snapshotAbs); - snapshotRel = toRelative(root, snapshotAbs); - } - } - - const outFlag = context.args.values.get('out'); - const outputRel = outFlag - ? toRelative(root, resolveInput(context.cwd, outFlag)) - : toRelative(root, paths.requirements); - - const now = new Date().toISOString(); - const sourceBlock = - mode === 'convert' && sourceRel !== undefined - ? { - source: { - path: sourceRel, - hash: sourceHash, - kind: sourceKind(sourceRel), - ...(snapshotRel !== undefined ? { snapshotPath: snapshotRel } : {}), - }, - } - : {}; - const promptBlock = mode === 'author' && prompt !== undefined ? { prompt } : {}; - const manifest: WorkManifest = { - schemaVersion: 1, - id: slug, - mode, - status: 'scaffolded', - ...sourceBlock, - ...promptBlock, - output: { path: outputRel }, - artifacts: { - questions: toRelative(root, paths.questions), - traceability: toRelative(root, paths.traceability), - validationJson: toRelative(root, paths.validationJson), - validationMarkdown: toRelative(root, paths.validationMarkdown), - }, - createdAt: now, - updatedAt: now, - }; - - // Write the manifest and empty artifacts. - mkdirSync(paths.dir, { recursive: true }); - const written: string[] = []; - const outputAbs = resolveInput(root, outputRel); - mkdirSync(resolve(outputAbs, '..'), { recursive: true }); - writeFileSync(outputAbs, ''); - written.push(outputRel); - writeFileSync(paths.questions, QUESTIONS_TEMPLATE); - written.push(toRelative(root, paths.questions)); - writeFileSync(paths.traceability, TRACEABILITY_TEMPLATE); - written.push(toRelative(root, paths.traceability)); - writeFileSync(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`); - written.push(toRelative(root, paths.manifest)); - - const work: Record = { - id: slug, - mode, - status: 'scaffolded', - ...(mode === 'convert' ? { source: sourceRel, sourceHash } : { prompt }), - output: outputRel, - }; - - const response = buildResponse( - { - command: 'new', - ok: true, - root, - next: nextForStatus(slug, 'scaffolded', sourceRel), - }, - { work, written }, - ); - - const pretty = [ - `Created work item "${slug}" (${mode}, scaffolded).`, - ...written.map((p) => ` wrote ${p}`), - ].join('\n'); - - emit(context.emitter, response, pretty); - return 0; -} diff --git a/packages/cli/src/commands/show.ts b/packages/cli/src/commands/show.ts deleted file mode 100644 index c749179..0000000 --- a/packages/cli/src/commands/show.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * `earsyntax show [--artifact ]` — resolve artifact paths or print - * artifact content. Read-only. - * - * Without `--artifact`, returns resolved paths for every artifact. With one, - * returns `{ path, exists, content }`, where content is the UTF-8 text when the - * file exists and null otherwise, so an agent needs no second read. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import type { CommandContext } from '../context.js'; -import { usageError } from '../errors.js'; -import { loadConfig, requireRoot, toRelative } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { reportedStatus, requireManifest } from '../workspace.js'; - -const ARTIFACTS = ['requirements', 'questions', 'traceability', 'validation', 'manifest'] as const; -type ArtifactName = (typeof ARTIFACTS)[number]; - -export function showCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - - const slug = context.args.positionals.at(0); - if (slug === undefined) { - throw usageError('show.missing_slug', 'Provide a work-item slug.'); - } - const { paths, manifest } = requireManifest(root, config, slug); - - const absFor: Record = { - requirements: paths.requirements, - questions: paths.questions, - traceability: paths.traceability, - validation: paths.validationJson, - manifest: paths.manifest, - }; - - const artifactFlag = context.args.values.get('artifact'); - if (artifactFlag !== undefined) { - if (!(ARTIFACTS as readonly string[]).includes(artifactFlag)) { - throw usageError( - 'show.unknown_artifact', - `Unknown artifact "${artifactFlag}". Expected one of: ${ARTIFACTS.join(', ')}.`, - ); - } - const abs = absFor[artifactFlag as ArtifactName]; - const exists = existsSync(abs); - let content: string | null = null; - if (exists) { - try { - content = readFileSync(abs, 'utf8'); - } catch { - content = null; - } - } - const artifact = { path: toRelative(root, abs), exists, content }; - const response = buildResponse({ command: 'show', ok: true, root, next: [] }, { artifact }); - emit(context.emitter, response, content ?? `${toRelative(root, abs)} (missing)`); - return 0; - } - - const work = { - id: manifest.id, - status: reportedStatus(root, manifest), - source: manifest.source?.path ?? null, - output: manifest.output.path, - requirements: toRelative(root, paths.requirements), - questions: toRelative(root, paths.questions), - traceability: toRelative(root, paths.traceability), - validation: toRelative(root, paths.validationJson), - manifest: toRelative(root, paths.manifest), - }; - const response = buildResponse({ command: 'show', ok: true, root, next: [] }, { work }); - const pretty = Object.entries(work) - .map(([key, value]) => ` ${key}: ${String(value)}`) - .join('\n'); - emit(context.emitter, response, pretty); - return 0; -} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts deleted file mode 100644 index c800a2d..0000000 --- a/packages/cli/src/commands/status.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * `earsyntax status []` — report one work item's state and next steps. - * - * Read-only. With no slug it resolves the single work item when there is - * exactly one. `status` reports computed staleness on top of the stored status. - */ - -import type { CommandContext } from '../context.js'; -import { usageError } from '../errors.js'; -import { loadConfig, requireRoot } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { nextForStatus } from '../next-actions.js'; -import { isStale, listSlugs, reportedStatus, requireManifest } from '../workspace.js'; - -function resolveSlug( - context: CommandContext, - root: string, - config: ReturnType, -): string { - const explicit = context.args.positionals.at(0); - if (explicit !== undefined) { - return explicit; - } - const slugs = listSlugs(root, config); - const only = slugs.at(0); - if (slugs.length === 1 && only !== undefined) { - return only; - } - if (slugs.length === 0) { - throw usageError('status.no_work', 'No work items found. Run `earsyntax new` first.'); - } - throw usageError('status.ambiguous', `Specify a work item: ${slugs.join(', ')}.`); -} - -export function statusCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - const slug = resolveSlug(context, root, config); - const { manifest } = requireManifest(root, config, slug); - - const status = reportedStatus(root, manifest); - const work: Record = { - id: manifest.id, - status, - mode: manifest.mode, - source: manifest.source?.path ?? null, - output: manifest.output.path, - sourceHash: manifest.source?.hash ?? null, - outputHash: manifest.output.hash ?? null, - acceptedHash: manifest.accepted?.outputHash ?? null, - stale: isStale(root, manifest), - }; - - const response = buildResponse( - { - command: 'status', - ok: true, - root, - next: nextForStatus(manifest.id, status, manifest.source?.path), - }, - { work }, - ); - - const pretty = [ - `${manifest.id}: ${status} (${manifest.mode})`, - manifest.source?.path ? ` source: ${manifest.source.path}` : ' source: (none)', - ` output: ${manifest.output.path}`, - ].join('\n'); - - emit(context.emitter, response, pretty); - return 0; -} diff --git a/packages/cli/src/hash.ts b/packages/cli/src/hash.ts deleted file mode 100644 index 0863019..0000000 --- a/packages/cli/src/hash.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Content hashing for source and output files. - * - * Hashes are `sha256:` followed by 64 lowercase hex characters, computed over - * the UTF-8 bytes of the content. Used for source-drift (stale) detection and - * to catch a `.ears` file edited after it was validated. - */ - -import { createHash } from 'node:crypto'; - -/** Hash a string as `sha256:<64 hex>`. */ -export function hashContent(content: string): string { - const digest = createHash('sha256').update(content, 'utf8').digest('hex'); - return `sha256:${digest}`; -} diff --git a/packages/cli/src/mode.ts b/packages/cli/src/mode.ts deleted file mode 100644 index 893c607..0000000 --- a/packages/cli/src/mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The instruction modes and small helpers for validating them. - */ - -import { usageError } from './errors.js'; - -/** The four instruction modes `earsyntax instructions ` accepts. */ -export type InstructionMode = 'author' | 'convert' | 'repair' | 'review'; - -const MODES: readonly InstructionMode[] = ['author', 'convert', 'repair', 'review']; - -/** Narrow an arbitrary string to an {@link InstructionMode} or throw a usage error. */ -export function requireInstructionMode(value: string | undefined): InstructionMode { - if (value !== undefined && (MODES as readonly string[]).includes(value)) { - return value as InstructionMode; - } - throw usageError( - 'instructions.unknown_mode', - `Unknown instructions mode "${value ?? ''}". Expected one of: ${MODES.join(', ')}.`, - ); -} diff --git a/packages/cli/src/next-actions.ts b/packages/cli/src/next-actions.ts deleted file mode 100644 index bc701c9..0000000 --- a/packages/cli/src/next-actions.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Builds the `next` action list for a work item from its reported status. - * - * The loop is walkable: an agent follows `next[].command` without hard-coding - * the sequence. `forAgent` marks actions safe to run automatically; `blocking` - * marks the human gate (acceptance). - */ - -import type { NextAction, WorkStatus } from './facade-types.js'; - -/** Next actions for a work item, keyed by its reported status. */ -export function nextForStatus(id: string, status: WorkStatus, source?: string): NextAction[] { - const sourceFlag = source ? ` --source ${source}` : ''; - const validateCmd = `earsyntax validate .earsyntax/work/${id}/requirements.ears${sourceFlag} --json`; - - switch (status) { - case 'missing': - return []; - case 'scaffolded': - return [ - { - command: `earsyntax instructions ${source ? 'convert' : 'author'} --work ${id} --json`, - reason: 'Give the coding agent the rules for writing the .ears file.', - forAgent: true, - }, - ]; - case 'drafted': - case 'invalid': - return [ - { - command: `earsyntax instructions repair --work ${id} --json`, - reason: 'Get targeted repair rules for the reported diagnostics.', - forAgent: true, - }, - { command: validateCmd, reason: 'Re-validate after applying the repairs.', forAgent: true }, - ]; - case 'valid': - return [ - { - command: `earsyntax instructions review --work ${id} --json`, - reason: 'Prepare the human review summary.', - forAgent: true, - }, - { - command: `earsyntax accept ${id}`, - reason: 'Record acceptance after human approval.', - blocking: true, - }, - ]; - case 'accepted': - return []; - case 'stale': - return [ - { - command: `earsyntax instructions convert --work ${id} --json`, - reason: 'The source changed; re-run the loop against the new source.', - forAgent: true, - }, - ]; - } -} diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts deleted file mode 100644 index 22a7e34..0000000 --- a/packages/cli/src/project.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Project resolution, config, and path helpers. - * - * The project root is the directory containing `.earsyntax/`, discovered by - * walking up from the resolved cwd. Paths in facade JSON are relative to this - * root (POSIX separators) unless the caller supplied an absolute path. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; -import { usageError } from './errors.js'; - -/** The Earsyntax project config stored at `.earsyntax/config.json`. */ -export interface EarsyntaxConfig { - version: number; - /** Work directory relative to the root, for example `.earsyntax/work`. */ - workDir: string; - /** Tool wrappers written at init time. */ - tools: string[]; -} - -/** The default config `init` writes. */ -export function defaultConfig(tools: string[]): EarsyntaxConfig { - return { version: 1, workDir: '.earsyntax/work', tools }; -} - -/** The absolute path to the `.earsyntax` directory for a given root. */ -export function earsyntaxDir(root: string): string { - return resolve(root, '.earsyntax'); -} - -/** Find the project root by walking up from `startDir` looking for `.earsyntax/`. */ -export function findRoot(startDir: string): string | undefined { - let current = resolve(startDir); - for (;;) { - if (existsSync(earsyntaxDir(current))) { - return current; - } - const parent = dirname(current); - if (parent === current) { - return undefined; - } - current = parent; - } -} - -/** Resolve the project root or throw a usage error if the project is not initialized. */ -export function requireRoot(cwd: string): string { - const root = findRoot(cwd); - if (root === undefined) { - throw usageError( - 'project.not_initialized', - 'No .earsyntax project found. Run `earsyntax init` first.', - ); - } - return root; -} - -/** Load config from an explicit path or the project's `.earsyntax/config.json`. */ -export function loadConfig(root: string, explicitPath?: string): EarsyntaxConfig { - const path = explicitPath - ? resolve(root, explicitPath) - : resolve(earsyntaxDir(root), 'config.json'); - let raw: string; - try { - raw = readFileSync(path, 'utf8'); - } catch { - throw usageError('config.unreadable', `Could not read config at ${path}.`); - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw usageError('config.invalid', `Config at ${path} is not valid JSON.`); - } - if (!parsed || typeof parsed !== 'object') { - throw usageError('config.invalid', `Config at ${path} is not a JSON object.`); - } - const obj = parsed as Partial; - return { - version: typeof obj.version === 'number' ? obj.version : 1, - workDir: typeof obj.workDir === 'string' ? obj.workDir : '.earsyntax/work', - tools: Array.isArray(obj.tools) - ? obj.tools.filter((t): t is string => typeof t === 'string') - : [], - }; -} - -/** Convert an absolute path to a root-relative POSIX path, or keep it absolute if outside the root. */ -export function toRelative(root: string, absPath: string): string { - const rel = relative(root, absPath); - if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { - return absPath; - } - return rel.split(sep).join('/'); -} - -/** Resolve a possibly-relative user path against the resolved cwd. */ -export function resolveInput(cwd: string, input: string): string { - return isAbsolute(input) ? input : resolve(cwd, input); -} diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts deleted file mode 100644 index c710a91..0000000 --- a/packages/cli/src/rules.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * The instruction bodies the `instructions` command returns, embedded as data. - * - * These arrays are the canonical rule text from `docs/agent-rules.md`. They are - * embedded here, not read from `docs/` at runtime, so the published binary - * carries its own contract. The CLI JSON is the source of truth agents read; - * the doc is the human-readable mirror of the same content. - */ - -import type { InstructionMode } from './mode.js'; - -/** The five allowed EARS line templates, constant across all modes. */ -export const ALLOWED_PATTERNS = [ - 'The shall .', - 'When , the shall .', - 'While , the shall .', - 'Where , the shall .', - 'If , then the shall .', -]; - -/** The metadata prefixes an agent may put in front of a requirement line. */ -export const METADATA_PREFIXES = [ - 'REQ-001:', - 'REQ-001 [source: path:line]:', - 'REQ-001 [source: path:line-line]:', -]; - -const AUTHOR_RULES = [ - 'Treat the prompt as the only source of behavior; do not add requirements it does not state.', - 'Write only EARS requirements in the .ears output file.', - 'Use one requirement per non-empty line.', - 'Give every requirement a stable ID such as REQ-001.', - 'Use the narrowest EARS pattern that fits the described behavior.', - 'Do not invent behavior that seems reasonable, secure, or common but is absent from the prompt.', - 'Do not hide ambiguity inside vague wording.', - 'Split compound behavior into separate requirements when the response holds more than one observable obligation.', - 'Write unclear behavior to questions.md instead of guessing a precise requirement.', - 'Record each generated requirement in traceability.json; author-mode requirements have no source line, so leave the source object empty and set confidence.', -]; - -const CONVERT_RULES = [ - 'Read the full source before writing requirements.', - 'Write only EARS requirements in the .ears output file.', - 'Use one requirement per non-empty line.', - 'Give every requirement a stable ID such as REQ-001.', - 'Preserve source traceability with a [source: path:line] prefix when the source line is known.', - 'Use the narrowest EARS pattern that fits the source behavior.', - 'Do not invent behavior that is not stated in the source.', - 'Do not hide ambiguity inside vague wording.', - 'Split compound behavior into separate requirements when the response holds more than one observable obligation.', - 'Write unclear behavior to questions.md instead of guessing a precise requirement.', - 'Maintain traceability.json for every generated requirement and question.', - 'Do not edit the source spec unless the user explicitly asks.', -]; - -const REPAIR_RULES = [ - 'Change only what the diagnostics justify; leave passing requirements untouched.', - 'Preserve requirement IDs unless a duplicate-ID diagnostic forces a change.', - 'Preserve the [source: path:line] references on repaired lines.', - 'Preserve the intended behavior; do not weaken a requirement because it is harder to parse.', - 'Do not delete a failing requirement to make validation pass.', - 'For ears.invalid_if_then_form, add the missing then boundary: If , then the shall .', - 'For lint.vague_response, replace the vague term with an observable, bounded response, or raise a question in questions.md if the bound is unknown.', - 'If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing.', - 'Update traceability.json if an ID or source reference changes.', -]; - -const REVIEW_RULES = [ - "Present a review summary before recommending acceptance; do not accept on the human's behalf.", - 'Report how many requirements were generated and the distribution across the six EARS patterns.', - 'List every unresolved question in questions.md and the requirements each one blocks.', - 'State the validation status and the exact command that produced it.', - 'Call out source lines translated with medium or low confidence.', - 'Call out any source behavior that was intentionally not converted, with the reason.', - 'Recommend acceptance only when validation is clean and no blocking questions remain.', -]; - -/** The rule array for a given instruction mode. */ -export function rulesFor(mode: InstructionMode): string[] { - switch (mode) { - case 'author': - return [...AUTHOR_RULES]; - case 'convert': - return [...CONVERT_RULES]; - case 'repair': - return [...REPAIR_RULES]; - case 'review': - return [...REVIEW_RULES]; - } -} - -/** A representative example line for a mode, using the work item's source path. */ -export function exampleLine(mode: InstructionMode, sourcePath?: string): string { - const src = sourcePath ?? 'specs/source.md'; - switch (mode) { - case 'author': - return 'REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature.'; - case 'convert': - case 'review': - return `REQ-001 [source: ${src}:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.`; - case 'repair': - return `REQ-002 [source: ${src}:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook.`; - } -} diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts deleted file mode 100644 index c993c06..0000000 --- a/packages/cli/src/workspace.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Work-item file operations: locating, reading, and writing the manifest and - * its sibling artifacts, plus computed status and staleness. - * - * Staleness is computed, never stored: a `valid` or `accepted` item is reported - * `stale` when the current source content hashes differently from the hash - * recorded when the item last reached `valid`/`accepted`. - */ - -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { usageError } from './errors.js'; -import { hashContent } from './hash.js'; -import { type EarsyntaxConfig, resolveInput, toRelative } from './project.js'; -import type { SourceKind, WorkManifest, WorkStatus, WorkSummary } from './facade-types.js'; - -/** Absolute paths for a work item's directory and artifacts. */ -export interface WorkPaths { - dir: string; - manifest: string; - requirements: string; - questions: string; - traceability: string; - validationJson: string; - validationMarkdown: string; -} - -/** Compute the absolute artifact paths for a work item slug. */ -export function workPaths(root: string, config: EarsyntaxConfig, slug: string): WorkPaths { - const dir = resolve(root, config.workDir, slug); - return { - dir, - manifest: resolve(dir, 'manifest.json'), - requirements: resolve(dir, 'requirements.ears'), - questions: resolve(dir, 'questions.md'), - traceability: resolve(dir, 'traceability.json'), - validationJson: resolve(dir, 'validation.json'), - validationMarkdown: resolve(dir, 'validation.md'), - }; -} - -/** Read and parse a manifest, or return undefined when the item does not exist. */ -export function readManifest(paths: WorkPaths): WorkManifest | undefined { - if (!existsSync(paths.manifest)) { - return undefined; - } - const raw = readFileSync(paths.manifest, 'utf8'); - return JSON.parse(raw) as WorkManifest; -} - -/** Resolve a work item's paths and manifest, throwing a usage error when unknown. */ -export function requireManifest( - root: string, - config: EarsyntaxConfig, - slug: string, -): { paths: WorkPaths; manifest: WorkManifest } { - const paths = workPaths(root, config, slug); - const manifest = readManifest(paths); - if (manifest === undefined) { - throw usageError('work.unknown', `Unknown work item "${slug}".`); - } - return { paths, manifest }; -} - -/** Persist a manifest, bumping `updatedAt`. */ -export function writeManifest(paths: WorkPaths, manifest: WorkManifest): void { - const next = { ...manifest, updatedAt: new Date().toISOString() }; - writeFileSync(paths.manifest, `${JSON.stringify(next, null, 2)}\n`); -} - -/** List every work-item slug under the configured work directory, sorted. */ -export function listSlugs(root: string, config: EarsyntaxConfig): string[] { - const base = resolve(root, config.workDir); - if (!existsSync(base)) { - return []; - } - return readdirSync(base) - .filter((name) => { - const entry = resolve(base, name); - return statSync(entry).isDirectory() && existsSync(resolve(entry, 'manifest.json')); - }) - .sort((a, b) => a.localeCompare(b)); -} - -/** Classify a source file by extension for the manifest `source.kind`. */ -export function sourceKind(path: string): SourceKind { - const lower = path.toLowerCase(); - if (lower.endsWith('.md') || lower.endsWith('.markdown')) { - return 'markdown'; - } - if (lower.endsWith('.yaml') || lower.endsWith('.yml')) { - return 'yaml'; - } - if (lower.endsWith('.json')) { - return 'json'; - } - return 'text'; -} - -/** - * The hash of the current source content on disk, or undefined when the source - * is absent or unreadable. `sourceInput` is the manifest-stored path resolved - * against the root. - */ -export function currentSourceHash( - root: string, - sourcePath: string | undefined, -): string | undefined { - if (sourcePath === undefined) { - return undefined; - } - const abs = resolveInput(root, sourcePath); - try { - return hashContent(readFileSync(abs, 'utf8')); - } catch { - return undefined; - } -} - -/** - * Whether a work item is stale: its recorded source hash differs from the - * source content on disk now. Only `valid` and `accepted` items can be stale. - */ -export function isStale(root: string, manifest: WorkManifest): boolean { - if (manifest.status !== 'valid' && manifest.status !== 'accepted') { - return false; - } - const recorded = - manifest.status === 'accepted' ? manifest.accepted?.sourceHash : manifest.source?.hash; - if (recorded === undefined) { - return false; - } - const current = currentSourceHash(root, manifest.source?.path); - if (current === undefined) { - return false; - } - return current !== recorded; -} - -/** The status to report to callers, applying computed staleness on top of the stored status. */ -export function reportedStatus(root: string, manifest: WorkManifest): WorkStatus { - return isStale(root, manifest) ? 'stale' : manifest.status; -} - -/** Build the compact {@link WorkSummary} used by instructions, list, and show. */ -export function toWorkSummary(root: string, manifest: WorkManifest): WorkSummary { - return { - id: manifest.id, - status: reportedStatus(root, manifest), - ...(manifest.source?.path !== undefined ? { source: manifest.source.path } : {}), - output: manifest.output.path, - questions: manifest.artifacts.questions, - traceability: manifest.artifacts.traceability, - }; -} - -/** The absolute output `.ears` path for a manifest, resolved against the root. */ -export function outputAbs(root: string, manifest: WorkManifest): string { - return resolveInput(root, manifest.output.path); -} - -/** The hash of the current `.ears` output on disk, or undefined when absent. */ -export function currentOutputHash(root: string, manifest: WorkManifest): string | undefined { - const abs = outputAbs(root, manifest); - try { - return hashContent(readFileSync(abs, 'utf8')); - } catch { - return undefined; - } -} - -/** Whether the `.ears` output has content (drives scaffolded -> drafted). */ -export function outputHasContent(root: string, manifest: WorkManifest): boolean { - const abs = outputAbs(root, manifest); - try { - return readFileSync(abs, 'utf8').trim() !== ''; - } catch { - return false; - } -} - -/** Render a manifest's path fields relative to the root for JSON output. */ -export function relPaths(root: string, paths: WorkPaths): Record { - return { - manifest: toRelative(root, paths.manifest), - requirements: toRelative(root, paths.requirements), - questions: toRelative(root, paths.questions), - traceability: toRelative(root, paths.traceability), - validationJson: toRelative(root, paths.validationJson), - validationMarkdown: toRelative(root, paths.validationMarkdown), - }; -} diff --git a/packages/core/src/pipeline.test.ts b/packages/core/src/pipeline.test.ts index 2d05c60..8659c97 100644 --- a/packages/core/src/pipeline.test.ts +++ b/packages/core/src/pipeline.test.ts @@ -16,8 +16,8 @@ function candidate(over: Partial & Pick): return { file: 'requirements.ears', col: 1, - locatorRuleId: 'strict.every-line', profile: 'strict', + locatorRuleId: 'strict.every-line', ...over, }; } @@ -98,7 +98,7 @@ describe('candidatesToFindings', () => { const files: CandidateFile[] = [ { file: 'a.ears', - candidates: [candidate({ id: 'REQ-007', text: 'Bad requirement text.', line: 2 })], + candidates: [candidate({ requirementId: 'REQ-007', text: 'Bad requirement text.', line: 2 })], }, ]; const findings = candidatesToFindings(files, STRICT); diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index 87148a9..f1ed37e 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -36,7 +36,8 @@ import type { Catalog, RequirementInput } from './types.js'; * 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`, `id?`, `locatorRuleId`, `profile` when serialized by the CLI. + * `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. */ @@ -45,14 +46,14 @@ export interface Candidate { line: number; /** 1-based column of the candidate text's first character, when known. */ col?: number; - /** The candidate requirement text, with any located marker or metadata prefix removed. */ + /** 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 requirement's own id (for example `REQ-001`), when the locator found one. */ - id?: string; - /** The id of the {@link import('./profiles/index.js').LocatorRule} that selected this candidate. */ - locatorRuleId: 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; } /** @@ -141,7 +142,7 @@ function candidateToRequirementInput(file: string, candidate: Candidate): Requir source.column = candidate.col; } return { - ...(candidate.id === undefined ? {} : { id: candidate.id }), + ...(candidate.requirementId === undefined ? {} : { id: candidate.requirementId }), text: candidate.text, source, }; diff --git a/packages/extract/src/locator.test.ts b/packages/extract/src/locator.test.ts index 0cb88a2..3ab01b8 100644 --- a/packages/extract/src/locator.test.ts +++ b/packages/extract/src/locator.test.ts @@ -28,18 +28,19 @@ describe('every-line locator (strict, ears-x)', () => { expect(result[0].locatorRuleId).toBe('strict.every-line'); }); - it('does not strip a REQ- prefix under strict (allowFrameMetadata false)', () => { + 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].id).toBeUndefined(); + expect(result[0].requirementId).toBeUndefined(); }); - it('lifts a REQ- prefix under ears-x and advances the column to the text', () => { + 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].id).toBe('REQ-001'); - expect(result[0].text).toBe('The system shall stop.'); - // 'REQ-001: ' is 9 characters, so the text starts at column 10. - expect(result[0].col).toBe(10); + expect(result[0].requirementId).toBe('REQ-001'); + expect(result[0].text).toBe('REQ-001: The system shall stop.'); + expect(result[0].col).toBe(1); }); }); @@ -102,6 +103,21 @@ describe('markdown heading-section locator (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)', () => { @@ -126,6 +142,26 @@ describe('markdown block locator (openspec)', () => { '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', () => { diff --git a/packages/extract/src/locator.ts b/packages/extract/src/locator.ts index 7b3ea41..456bc13 100644 --- a/packages/extract/src/locator.ts +++ b/packages/extract/src/locator.ts @@ -203,7 +203,17 @@ function headingSectionCandidates( ); } -/** Candidates from `blockPrefix` blocks: body lines until the next same/higher heading. */ +/** + * 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, @@ -216,21 +226,51 @@ function blockCandidates( } const prefix = rule.blockPrefix.trim(); const prefixLevel = leadingHashes(prefix); - const inBlock = new Array(model.lines.length).fill(false); + const allowFrameMetadata = profile.dialect.allowFrameMetadata; + const candidates: Candidate[] = []; - let open = false; + 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)) { - open = true; + inside = true; + claimed = false; continue; // the prefix heading line itself is not a candidate } - if (open && model.headingLevels[i] > 0 && model.headingLevels[i] <= prefixLevel) { - open = false; + 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); } - inBlock[i] = open; } + return candidates; +} - return bodyLineCandidates(model, profile, file, includeCode, rule.id, (i) => inBlock[i]); +/** 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. */ @@ -389,9 +429,27 @@ function gatherContinuations( 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 and lifting a frame-metadata prefix when allowed. + * 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). */ @@ -409,18 +467,19 @@ function buildCandidate( const leadingWs = region.length - region.trimStart().length; let textStart = contentStart + leadingWs; let text = region.trim(); - let id: string | undefined; - - if (allowFrameMetadata) { + 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 || split.ref !== undefined) { - // The stripped prefix is the removed leading run; advance the column past - // it so the reported position points at the requirement text. A declared - // `[source: ...]` reference is stripped from the text but does not move the - // physical position (the finding must point where the text actually is). - textStart += text.length - split.text.length; - text = split.text; - id = split.id; + if (split.id !== undefined) { + requirementId = split.id; } } @@ -436,9 +495,9 @@ function buildCandidate( line, col: textStart + 1, text, - ...(id === undefined ? {} : { id }), - locatorRuleId, profile: profile.name, + locatorRuleId, + ...(requirementId === undefined ? {} : { requirementId }), }; return candidate; } diff --git a/packages/extract/src/pipeline.test.ts b/packages/extract/src/pipeline.test.ts index fdef1e2..8dfcad9 100644 --- a/packages/extract/src/pipeline.test.ts +++ b/packages/extract/src/pipeline.test.ts @@ -54,7 +54,7 @@ describe('extractCandidates', () => { expect(candidates).toHaveLength(1); expect(candidates[0]).toMatchObject({ file: 'r.yaml', - id: 'REQ-001', + requirementId: 'REQ-001', text: 'The system shall stop.', locatorRuleId: 'structured.yaml', profile: 'strict', @@ -67,7 +67,7 @@ describe('extractCandidates', () => { const { candidates, notices } = extractCandidates({ files: [{ path: 'r.json', content: json }], profile: strict }); expect(notices).toHaveLength(0); expect(candidates).toHaveLength(1); - expect(candidates[0].id).toBe('REQ-001'); + expect(candidates[0].requirementId).toBe('REQ-001'); }); it('flattens candidates across multiple files in order', () => { diff --git a/packages/extract/src/pipeline.ts b/packages/extract/src/pipeline.ts index 9c9d1ef..58dc730 100644 --- a/packages/extract/src/pipeline.ts +++ b/packages/extract/src/pipeline.ts @@ -186,9 +186,9 @@ function structuredCandidates( line: item.source?.line ?? 1, ...(item.source?.column === undefined ? {} : { col: item.source.column }), text: item.text, - ...(item.id === undefined ? {} : { id: item.id }), - locatorRuleId, profile: profile.name, + locatorRuleId, + ...(item.id === undefined ? {} : { requirementId: item.id }), }; return candidate; }); From d01361c49eae2f7564b302cda1f87926733074b2 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 14:46:54 +0300 Subject: [PATCH 12/20] feat(cli): host-native command shell with stateless validate and extract Dispatcher for the eight facade commands, global flags, exit codes 0/1/2, envelope responses, pipeline-backed validate with notice mapping, extract with profile snapshot coverage, workspace concepts fully removed. --- packages/cli/src/args.ts | 59 +-- packages/cli/src/cli.test.ts | 479 ++++++----------- packages/cli/src/cli.ts | 157 ++++-- packages/cli/src/commands/doctor.ts | 122 +---- packages/cli/src/commands/explain.ts | 9 + packages/cli/src/commands/extract.test.ts | 295 +++++++++++ packages/cli/src/commands/extract.ts | 175 +++++++ packages/cli/src/commands/init.ts | 140 +---- packages/cli/src/commands/instructions.ts | 159 +----- packages/cli/src/commands/profiles.ts | 9 + packages/cli/src/commands/stub.ts | 28 + packages/cli/src/commands/validate.test.ts | 351 +++++++++++++ packages/cli/src/commands/validate.ts | 567 +++++++++++---------- packages/cli/src/commands/version.ts | 22 +- packages/cli/src/context.ts | 30 +- packages/cli/src/errors.ts | 28 +- packages/cli/src/facade-types.ts | 103 +--- packages/cli/src/paths.ts | 47 ++ packages/cli/src/response.ts | 36 +- packages/cli/src/tool-wrappers.ts | 98 ---- packages/cli/src/version.ts | 21 +- 21 files changed, 1621 insertions(+), 1314 deletions(-) create mode 100644 packages/cli/src/commands/explain.ts create mode 100644 packages/cli/src/commands/extract.test.ts create mode 100644 packages/cli/src/commands/extract.ts create mode 100644 packages/cli/src/commands/profiles.ts create mode 100644 packages/cli/src/commands/stub.ts create mode 100644 packages/cli/src/commands/validate.test.ts create mode 100644 packages/cli/src/paths.ts delete mode 100644 packages/cli/src/tool-wrappers.ts diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index f7fbd22..d2eac82 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -1,33 +1,21 @@ /** * A lean, hand-rolled argument parser. * - * The facade JSON contract is exact and the command set is small, so a tiny + * 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 `--no-flag`, and separates positionals from options. + * `--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. + * 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`). */ -const VALUE_FLAGS = new Set([ - 'cwd', - 'config', - 'tools', - 'mode', - 'source', - 'prompt', - 'out', - 'by', - 'work', - 'catalog', - 'format', - 'artifact', - 'status', -]); +/** 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 { @@ -62,17 +50,10 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } - // `--flag=value` form. + // `--flag=value` form. Last assignment wins for a repeated flag. const eq = body.indexOf('='); if (eq !== -1) { - const name = body.slice(0, eq); - values.set(name, body.slice(eq + 1)); - continue; - } - - // `--no-flag` boolean negation. - if (body.startsWith('no-')) { - booleans.add(body); + values.set(body.slice(0, eq), body.slice(eq + 1)); continue; } @@ -96,19 +77,25 @@ export function parseArgs(argv: string[]): ParsedArgs { /** Resolved global options shared by every command. */ export interface GlobalOptions { json: boolean; - color: boolean; - interactive: boolean; + sarif: boolean; + strict: boolean; + quiet: boolean; + /** The active profile name; defaults to `strict`. Commands validate it against the registry. */ + profile: string; cwd?: string; - config?: string; + /** Whether pretty output may use ANSI color. Set by the dispatcher, not a user flag. */ + color: boolean; } -/** Extract global options from parsed args. */ -export function resolveGlobals(args: ParsedArgs): GlobalOptions { +/** Extract global options from parsed args. `color` is supplied by the dispatcher. */ +export function resolveGlobals(args: ParsedArgs, color: boolean): GlobalOptions { return { json: args.booleans.has('json'), - color: !args.booleans.has('no-color'), - interactive: !args.booleans.has('no-interactive'), + 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'), - config: args.values.get('config'), + color, }; } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index b404375..700a134 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,375 +1,234 @@ /** - * End-to-end facade tests. + * Dispatcher-level tests for the host-native facade shell. * - * Each test drives `run(argv, { cwd })` against a temp project directory and - * captures stdout, exercising the full loop: init -> new -> instructions -> - * agent-simulated write -> validate -> status -> accept, plus staleness, - * refusals, and exit codes. JSON responses are compared structurally to the - * golden fixtures in `fixtures/facade/` (values that vary by run are ignored). + * These exercise the command surface `run()` owns: routing across the eight + * commands, flag validation against the closed surface, help and version text, + * the response envelope, and the not-yet-reimplemented stubs. Command bodies + * (validate, extract, and the W4/W5 commands) are tested in their own suites; + * here we only prove the shell routes to them and enforces their flags. */ -import { mkdtempSync, readFileSync, 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 { parseArgs } from './args.js'; import { run } from './cli.js'; -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '..', '..', '..'); -const DEMO = resolve(REPO_ROOT, 'fixtures', 'demo'); -const DEMO_REQUIREMENTS = resolve(DEMO, 'requirements.ears'); -const DEMO_VALID_ONLY = resolve(DEMO, 'valid-only.ears'); -const DEMO_CATALOG = resolve(DEMO, 'catalog.json'); - interface RunResult { code: number; out: string; json: () => Record; } -function makeRunner(cwd: string) { - return (...argv: string[]): RunResult => { - let out = ''; - const code = run(argv, { cwd, stdout: (s) => (out += s) }); - return { code, out, json: () => JSON.parse(out) as 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 }; } -function tempProject(): string { - // Directories are left in the OS temp dir; the OS reclaims them. - return mkdtempSync(join(tmpdir(), 'earsyntax-cli-')); -} +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); + } + }); -const SOURCE_MD = [ - '# Checkout webhooks', - '', - 'When a payment webhook is received, the billing service must verify the HMAC signature.', - 'If the HMAC signature is invalid, the webhook must be rejected.', - '', -].join('\n'); + 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 features as JSON', () => { - const r = makeRunner(tempProject()); - const res = r('version', '--features', '--json'); + 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.sarif).toBe(false); + 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); }); -}); - -describe('init', () => { - it('writes the workspace and refuses to overwrite without --force', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - - const first = r('init', '--json'); - expect(first.code).toBe(0); - const body = first.json(); - expect(body.ok).toBe(true); - expect(body.written).toEqual(['.earsyntax/config.json', '.earsyntax/work/.gitkeep']); - expect(typeof body.root).toBe('string'); - - const again = r('init', '--json'); - expect(again.code).toBe(3); - const failure = again.json(); - expect(failure.ok).toBe(false); - const diags = failure.diagnostics as { code: string }[]; - expect(diags[0]?.code).toBe('init.exists'); - const forced = r('init', '--force', '--json'); - expect(forced.code).toBe(0); + 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('writes tool wrappers for --tools claude', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - const res = r('init', '--tools', 'claude', '--json'); - expect(res.code).toBe(0); - const body = res.json(); - expect(body.tools).toEqual(['claude']); - const written = body.written as string[]; - expect(written).toContain('.claude/commands/earsyntax-author.md'); - expect(written).toContain('.claude/commands/earsyntax-convert.md'); - expect(written).toContain('.claude/commands/earsyntax-repair.md'); + 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('rejects an unknown tool with exit 2', () => { - const r = makeRunner(tempProject()); - const res = r('init', '--tools', 'bogus', '--json'); - expect(res.code).toBe(2); - expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('init.bad_tools'); + it('emits pure JSON to stdout in --json mode', () => { + const res = runCli('version', '--json'); + expect(() => JSON.parse(res.out)).not.toThrow(); }); }); -describe('convert loop (happy path)', () => { - it('runs new -> instructions -> write -> validate -> status -> accept', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - writeFileSync(join(cwd, 'source.md'), SOURCE_MD); - - r('init', '--json'); - - const createdRes = r( - 'new', - 'checkout-webhooks', - '--source', - 'source.md', - '--mode', - 'convert', - '--json', - ); - expect(createdRes.code).toBe(0); - const newBody = createdRes.json(); - const work = newBody.work as Record; - expect(work.id).toBe('checkout-webhooks'); - expect(work.mode).toBe('convert'); - expect(work.status).toBe('scaffolded'); - expect(String(work.sourceHash)).toMatch(/^sha256:[0-9a-f]{64}$/); - expect(newBody.written).toContain('.earsyntax/work/checkout-webhooks/manifest.json'); - - const instr = r('instructions', 'convert', '--work', 'checkout-webhooks', '--json'); - expect(instr.code).toBe(0); - const instrBody = instr.json(); - expect(instrBody.command).toBe('instructions convert'); - expect(instrBody.mode).toBe('convert'); - expect((instrBody.rules as string[]).length).toBeGreaterThan(0); - const format = instrBody.format as Record; - expect((format.allowedPatterns as string[]).length).toBe(5); - expect((format.metadataPrefixes as string[]).length).toBe(3); - const source = instrBody.source as Record; - expect(source.path).toBe('source.md'); - expect(Array.isArray(source.excerpts)).toBe(true); - const next = instrBody.next as { command: string }[]; - expect(next[0]?.command).toContain('earsyntax validate'); - - // Agent writes valid .ears content into the reserved output path. - const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); - writeFileSync(outPath, readFileSync(DEMO_VALID_ONLY, 'utf8')); - - const validated = r( - 'validate', - '.earsyntax/work/checkout-webhooks/requirements.ears', - '--source', - 'source.md', - '--work', - 'checkout-webhooks', - '--json', - ); - expect(validated.code).toBe(0); - const vBody = validated.json(); - expect(vBody.ok).toBe(true); - const summary = vBody.summary as Record; - expect(summary.errors).toBe(0); - expect((vBody.work as Record).status).toBe('valid'); - expect(vBody.stale).toBe(false); - - const status = r('status', 'checkout-webhooks', '--json'); - expect(status.code).toBe(0); - expect((status.json().work as Record).status).toBe('valid'); - - const accepted = r('accept', 'checkout-webhooks', '--by', 'omer', '--json'); - expect(accepted.code).toBe(0); - const aWork = accepted.json().work as Record; - expect(aWork.status).toBe('accepted'); - const acceptedBlock = aWork.accepted as Record; - expect(acceptedBlock.by).toBe('omer'); - expect(String(acceptedBlock.outputHash)).toMatch(/^sha256:/); +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('invalid content and repair', () => { - it('validate exits 1 and repair surfaces diagnostics; accept refuses invalid', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - writeFileSync(join(cwd, 'source.md'), SOURCE_MD); - r('init', '--json'); - r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); - - const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); - writeFileSync(outPath, readFileSync(DEMO_REQUIREMENTS, 'utf8')); - - const validated = r( - 'validate', - '.earsyntax/work/checkout-webhooks/requirements.ears', - '--work', - 'checkout-webhooks', - '--json', - ); - expect(validated.code).toBe(1); - const vBody = validated.json(); - expect(vBody.ok).toBe(false); - expect((vBody.summary as Record).errors).toBeGreaterThan(0); - expect((vBody.work as Record).status).toBe('invalid'); +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'); + } + }); - const repair = r('instructions', 'repair', '--work', 'checkout-webhooks', '--json'); - expect(repair.code).toBe(0); - const diagnostics = repair.json().diagnostics as { code: string }[]; - expect(diagnostics.length).toBeGreaterThan(0); + 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); + }); - const accept = r('accept', 'checkout-webhooks', '--json'); - expect(accept.code).toBe(3); - expect((accept.json().diagnostics as { code: string }[])[0]?.code).toBe('accept.not_valid'); + 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('validate standalone files', () => { - it('exits 1 on the demo requirements with invalid lines', () => { - const r = makeRunner(tempProject()); - const res = r('validate', DEMO_REQUIREMENTS, '--json'); - expect(res.code).toBe(1); - expect((res.json().summary as Record).errors).toBeGreaterThan(0); +describe('not-yet-reimplemented stubs', () => { + it('returns exit 2 with cli.not_implemented for the W4/W5 commands', () => { + for (const command of ['init', 'doctor', 'explain', 'profiles']) { + const res = runCli(command, '--json'); + expect(res.code).toBe(2); + const body = res.json(); + expect(body.command).toBe(command); + expect(body.ok).toBe(false); + expect((body.diagnostics as { code: string }[])[0]?.code).toBe('cli.not_implemented'); + } }); - it('exits 0 on the valid-only demo', () => { - const r = makeRunner(tempProject()); - const res = r('validate', DEMO_VALID_ONLY, '--json'); - expect(res.code).toBe(0); - expect((res.json().summary as Record).errors).toBe(0); + it('carries the instructions mode into the stub command label', () => { + const res = runCli('instructions', 'repair', '--file', 'x.md', '--json'); + expect(res.code).toBe(2); + expect(res.json().command).toBe('instructions repair'); }); +}); - it('exits 2 on a missing file', () => { - const r = makeRunner(tempProject()); - const res = r('validate', 'does-not-exist.ears', '--json'); +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('validate.missing_file'); + expect((res.json().diagnostics as { code: string }[])[0]?.code).toBe('cli.unknown_flag'); }); - it('accepts a catalog', () => { - const r = makeRunner(tempProject()); - const res = r('validate', DEMO_VALID_ONLY, '--catalog', DEMO_CATALOG, '--json'); - expect(res.code).toBe(0); - const body = res.json(); - expect((body.summary as Record).errors).toBe(0); - expect(Array.isArray(body.results)).toBe(true); + 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('flags duplicate IDs as a facade error', () => { - const cwd = tempProject(); - const dupPath = join(cwd, 'dup.ears'); - writeFileSync( - dupPath, - [ - 'REQ-001: The billing service shall verify the HMAC signature of every incoming webhook.', - 'REQ-001: The billing service shall retry failed webhook deliveries up to five times.', - ].join('\n'), - ); - const r = makeRunner(cwd); - const res = r('validate', 'dup.ears', '--json'); - expect(res.code).toBe(1); - const diags = res.json().diagnostics as { code: string }[]; - expect(diags.some((d) => d.code === 'facade.duplicate_id')).toBe(true); + 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'); + } }); -}); -describe('staleness', () => { - it('reports stale after the source changes and accept refuses it', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - const sourcePath = join(cwd, 'source.md'); - writeFileSync(sourcePath, SOURCE_MD); - r('init', '--json'); - r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); - - const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); - writeFileSync(outPath, readFileSync(DEMO_VALID_ONLY, 'utf8')); - - const first = r( - 'validate', - '.earsyntax/work/checkout-webhooks/requirements.ears', - '--source', - 'source.md', - '--work', - 'checkout-webhooks', - '--json', - ); - expect(first.code).toBe(0); - expect(first.json().stale).toBe(false); - - // The source drifts. - writeFileSync(sourcePath, `${SOURCE_MD}\nWhile the provider is unavailable, queue events.\n`); - - const second = r( - 'validate', - '.earsyntax/work/checkout-webhooks/requirements.ears', - '--source', - 'source.md', - '--work', - 'checkout-webhooks', - '--json', - ); - expect(second.code).toBe(0); - expect(second.json().stale).toBe(true); + 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'); + }); - const status = r('status', 'checkout-webhooks', '--json'); - expect((status.json().work as Record).status).toBe('stale'); + 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'); + }); - const accept = r('accept', 'checkout-webhooks', '--json'); - expect(accept.code).toBe(3); - expect((accept.json().diagnostics as { code: string }[])[0]?.code).toBe('accept.stale'); + 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('metadata-prefix requirements', () => { - it('validates a work item whose .ears uses the [source: path:line] form', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - writeFileSync(join(cwd, 'source.md'), SOURCE_MD); - r('init', '--json'); - r('new', 'checkout-webhooks', '--source', 'source.md', '--mode', 'convert', '--json'); +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'); + }); - // The agent writes requirements using the documented metadata prefix, - // including the range form. The declared ref, not the physical line, - // becomes the reported source line. - const outPath = join(cwd, '.earsyntax', 'work', 'checkout-webhooks', 'requirements.ears'); - writeFileSync( - outPath, - [ - 'REQ-001 [source: source.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.', - 'REQ-002 [source: source.md:10-11]: If the HMAC signature is invalid, then the billing service shall reject the webhook.', - '', - ].join('\n'), - ); + 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); + }); - const res = r( - 'validate', - '.earsyntax/work/checkout-webhooks/requirements.ears', - '--source', - 'source.md', - '--work', - 'checkout-webhooks', - '--json', - ); - expect(res.code).toBe(0); - const body = res.json(); - expect((body.summary as Record).errors).toBe(0); - expect((body.work as Record).status).toBe('valid'); - const results = body.results as { id?: string; line?: number; valid: boolean }[]; - expect(results[0]?.id).toBe('REQ-001'); - expect(results[0]?.line).toBe(7); - expect(results[0]?.valid).toBe(true); - expect(results[1]?.id).toBe('REQ-002'); - expect(results[1]?.line).toBe(10); - expect(results[1]?.valid).toBe(true); + it('supports --flag=value form', () => { + const parsed = parseArgs(['--cwd=/tmp/x']); + expect(parsed.values.get('cwd')).toBe('/tmp/x'); }); -}); -describe('new refusals', () => { - it('refuses a duplicate slug without --force', () => { - const cwd = tempProject(); - const r = makeRunner(cwd); - writeFileSync(join(cwd, 'source.md'), SOURCE_MD); - r('init', '--json'); - expect(r('new', 'dup-item', '--source', 'source.md', '--json').code).toBe(0); - const again = r('new', 'dup-item', '--source', 'source.md', '--json'); - expect(again.code).toBe(3); - expect((again.json().diagnostics as { code: string }[])[0]?.code).toBe('new.exists'); + 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 index 72666c3..49beabf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,29 +1,29 @@ /** * The `earsyntax` command dispatcher. * - * A lean hand-rolled entry point: it parses global options, resolves the - * working directory, builds the output emitter, and routes to one command - * handler. Handlers emit their own JSON or pretty output and return an exit - * code. {@link CliError}s are caught here and rendered as a base response. + * 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. * - * Kept framework-free on purpose: the facade JSON contract is exact, and owning - * the response shape directly is simpler than bending a CLI framework to it. + * The surface is the eight facade commands and nothing else. There is no + * `new`/`list`/`status`/`show`/`accept`/`check`; the workspace is gone. */ -import { resolveGlobals, parseArgs } from './args.js'; +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 { findRoot, resolveInput } from './project.js'; -import { emit, errorResponse } from './response.js'; -import { acceptCommand } from './commands/accept.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 { listCommand } from './commands/list.js'; -import { newCommand } from './commands/new.js'; -import { showCommand } from './commands/show.js'; -import { statusCommand } from './commands/status.js'; +import { profilesCommand } from './commands/profiles.js'; import { validateCommand } from './commands/validate.js'; import { versionCommand } from './commands/version.js'; @@ -34,28 +34,58 @@ export interface RunOptions { stderr?: (text: string) => void; } -const COMMANDS: Record = { - init: initCommand, - doctor: doctorCommand, - version: versionCommand, - new: newCommand, - list: listCommand, - status: statusCommand, - instructions: instructionsCommand, - validate: validateCommand, - accept: acceptCommand, - show: showCommand, +/** 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, args: ReturnType): string { - if (command === 'instructions') { - const mode = args.positionals[0]; - return mode ? `instructions ${mode}` : 'instructions'; +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. @@ -63,34 +93,31 @@ function commandLabel(command: string, args: ReturnType): stri 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); - // Bare invocation and top-level flags. if (command === undefined || command === '--help' || command === '-h') { write(`${usageText()}\n`); return command === undefined ? 2 : 0; } if (command === '--version' || command === '-v') { - return dispatch('version', [], baseCwd, write); + return dispatch('version', [], baseCwd, color, write); } - return dispatch(command, rest, baseCwd, write); + return dispatch(command, rest, baseCwd, color, write); } function dispatch( command: string, rest: string[], baseCwd: string, + color: boolean, write: (text: string) => void, ): number { - const args = parseArgs(rest); - const global = resolveGlobals(args); - const cwd = global.cwd ? resolveInput(baseCwd, global.cwd) : baseCwd; - const emitter = { json: global.json, painter: createPainter(global.color), write }; - - const label = commandLabel(command, args); + const emitter = { json: rest.includes('--json'), painter: createPainter(color), write }; try { if (!Object.hasOwn(COMMANDS, command)) { @@ -99,9 +126,20 @@ function dispatch( `Unknown command "${command}". Run \`earsyntax --help\`.`, ); } - const handler = COMMANDS[command]; + const spec = COMMANDS[command]; + const args = parseArgs(rest); + checkFlags(command, spec, args); + + const global = resolveGlobals(args, color); + 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 }; - return handler(context); + const result = spec.handler(context); + emitResult(emitter, result.response, result.pretty, result.raw); + return result.exitCode; } catch (error) { const cliError = error instanceof CliError @@ -111,29 +149,42 @@ function dispatch( severity: 'error', message: error instanceof Error ? error.message : String(error), }); - const response = errorResponse(label, findRoot(cwd), cliError); + const label = commandLabel(command, parsePositionalsSafely(rest)); + const response = errorResponse(label, undefined, cliError); const pretty = `error ${cliError.diagnostic.code}: ${cliError.diagnostic.message}`; - emit(emitter, response, pretty); + 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:', - ' init Initialize .earsyntax/ and optional agent wrappers', - ' doctor Read-only project health report', - ' version Version and feature discovery', - ' new Create a work item for a source spec or prompt', - ' list List work items', - ' status Show one work item state and next steps', - ' instructions Return the rules an agent follows for a step', - ' validate Validate .ears files and emit diagnostics', - ' accept Mark a valid .ears artifact as human-accepted', - ' show Show resolved artifact paths or content', + ' 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: --json --no-color --cwd --config --no-interactive', + 'Global options: --profile --json --sarif (validate only) --strict --quiet --cwd ', ].join('\n'); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 9976cd5..3b9f3c3 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,121 +1,9 @@ /** - * `earsyntax doctor` — read-only project health report. - * - * Runs a fixed checklist and returns it as `checks`, mirroring every - * non-passing check into the base `diagnostics` array so agents read one array - * for problems while humans get the full ordered checklist. `ok` is true only - * when no check produced an error. + * `earsyntax doctor` — detect host frameworks and agent integrations in the repo + * at `--cwd` and recommend exact commands. Reimplemented by the host-native + * doctor agent; this is a compiling stub until then. */ -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { CommandContext } from '../context.js'; -import type { FacadeDiagnostic, FacadeSeverity } from '../facade-types.js'; -import { loadConfig, requireRoot, resolveInput } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { CLI_VERSION } from '../version.js'; -import { isStale, listSlugs, readManifest, workPaths } from '../workspace.js'; -import { wrapperFilesFor } from '../tool-wrappers.js'; +import { notImplemented } from './stub.js'; -interface DoctorCheck { - name: string; - ok: boolean; - severity: FacadeSeverity; - message: string; -} - -export function doctorCommand(context: CommandContext): number { - const root = requireRoot(context.cwd); - // Throws exit 2 when the config is unreadable/invalid. - const config = loadConfig(root, context.global.config); - - const checks: DoctorCheck[] = []; - - checks.push({ - name: 'config', - ok: true, - severity: 'info', - message: '.earsyntax/config.json exists and parses.', - }); - - const workDirExists = existsSync(resolve(root, config.workDir)); - checks.push({ - name: 'workDir', - ok: workDirExists, - severity: workDirExists ? 'info' : 'error', - message: workDirExists - ? `Work directory ${config.workDir} exists.` - : `Work directory ${config.workDir} is missing.`, - }); - - checks.push({ - name: 'version', - ok: true, - severity: 'info', - message: `earsyntax ${CLI_VERSION} supports facade contract 1.`, - }); - - // Per-work-item checks: referenced sources exist, accepted items are not stale. - for (const slug of listSlugs(root, config)) { - const manifest = readManifest(workPaths(root, config, slug)); - if (manifest === undefined) { - continue; - } - if (manifest.source?.path !== undefined) { - const sourceExists = existsSync(resolveInput(root, manifest.source.path)); - checks.push({ - name: `source:${slug}`, - ok: sourceExists, - severity: sourceExists ? 'info' : 'warning', - message: sourceExists - ? `Source for ${slug} exists.` - : `Source ${manifest.source.path} for ${slug} is missing.`, - }); - } - if (manifest.status === 'accepted') { - const stale = isStale(root, manifest); - checks.push({ - name: `stale:${slug}`, - ok: !stale, - severity: stale ? 'warning' : 'info', - message: stale - ? `Accepted item ${slug} is stale: its source changed after acceptance.` - : `Accepted item ${slug} is up to date.`, - }); - } - } - - // Configured tool wrappers are present. - for (const tool of config.tools) { - for (const wrapper of wrapperFilesFor(tool)) { - const present = existsSync(resolve(root, wrapper.path)); - checks.push({ - name: `tool:${wrapper.path}`, - ok: present, - severity: present ? 'info' : 'info', - message: present ? `${wrapper.path} is present.` : `${wrapper.path} is missing.`, - }); - } - } - - const diagnostics: FacadeDiagnostic[] = checks - .filter((check) => !check.ok) - .map((check) => ({ - code: `doctor.${check.name}`, - severity: check.severity, - message: check.message, - })); - - const hasError = checks.some((check) => !check.ok && check.severity === 'error'); - - const response = buildResponse( - { command: 'doctor', ok: !hasError, root, diagnostics, next: [] }, - { checks }, - ); - - const pretty = checks - .map((check) => ` [${check.ok ? 'ok' : check.severity}] ${check.name}: ${check.message}`) - .join('\n'); - emit(context.emitter, response, pretty); - return 0; -} +export const doctorCommand = notImplemented('doctor'); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts new file mode 100644 index 0000000..aa7d5cc --- /dev/null +++ b/packages/cli/src/commands/explain.ts @@ -0,0 +1,9 @@ +/** + * `earsyntax explain ` — full write-up of one diagnostic, + * resolving current ids and deprecated aliases. Reimplemented by the host-native + * explain agent; this is a compiling stub until then. + */ + +import { notImplemented } from './stub.js'; + +export const explainCommand = notImplemented('explain'); diff --git a/packages/cli/src/commands/extract.test.ts b/packages/cli/src/commands/extract.test.ts new file mode 100644 index 0000000..e7a3634 --- /dev/null +++ b/packages/cli/src/commands/extract.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for `earsyntax extract`. + * + * The command dispatcher (cli.ts/args.ts) is owned by another agent; these drive + * {@link extractCommand} directly with a hand-built {@link CommandContext} and + * assert against the returned {@link CommandResult}. Once the dispatcher routes + * `extract`, the acceptance gate + * `node packages/cli/bin/run.js extract fixtures/profiles/kiro/requirements.md --profile kiro --json` + * exercises the same path end to end. + * + * 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', + color: false, + }; + 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..63592bf --- /dev/null +++ b/packages/cli/src/commands/extract.ts @@ -0,0 +1,175 @@ +/** + * `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.ts b/packages/cli/src/commands/init.ts index 60873b5..05ca524 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,139 +1,9 @@ /** - * `earsyntax init` — create `.earsyntax/` and optional agent wrapper files. - * - * Refuses to overwrite an existing `.earsyntax/` (or an existing tool wrapper - * file) without `--force`, returning exit 3. The workflow works with - * `--tools none`; wrappers are a convenience layer. + * `earsyntax init --agent --host ` — render managed agent-wrapper + * and host-integration files. Reimplemented by the host-native init agent; this + * is a compiling stub until then. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import type { CommandContext } from '../context.js'; -import { refusalError, usageError } from '../errors.js'; -import { defaultConfig, earsyntaxDir, toRelative } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { - AGENTS_END, - AGENTS_START, - KNOWN_TOOLS, - type WrapperFile, - wrapperFilesFor, -} from '../tool-wrappers.js'; +import { notImplemented } from './stub.js'; -function resolveTools(raw: string | undefined): string[] { - if (raw === undefined || raw.trim() === '') { - return []; - } - if (raw.trim() === 'none') { - return []; - } - const tools = raw - .split(',') - .map((t) => t.trim()) - .filter((t) => t !== ''); - for (const tool of tools) { - if (!KNOWN_TOOLS.has(tool)) { - throw usageError( - 'init.bad_tools', - `Unknown tool "${tool}" in --tools. Expected: claude, codex, cursor, or none.`, - ); - } - } - return [...new Set(tools)]; -} - -/** Merge the earsyntax section into an existing or new AGENTS.md, returning its content. */ -function mergeAgentsFile(existing: string | undefined, section: string): string { - if (existing === undefined) { - return section; - } - const startIdx = existing.indexOf(AGENTS_START); - const endIdx = existing.indexOf(AGENTS_END); - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - const before = existing.slice(0, startIdx); - const after = existing.slice(endIdx + AGENTS_END.length); - return `${before}${section.trim()}${after}`; - } - const separator = existing.endsWith('\n') ? '\n' : '\n\n'; - return `${existing}${separator}${section}`; -} - -export function initCommand(context: CommandContext): number { - const root = context.cwd; - const force = context.args.booleans.has('force'); - const tools = resolveTools(context.args.values.get('tools')); - - const earsDir = earsyntaxDir(root); - if (existsSync(earsDir) && !force) { - throw refusalError( - 'init.exists', - 'An .earsyntax/ directory already exists. Pass --force to reinitialize.', - ); - } - - // Collect every wrapper file up front so overwrite protection can run before - // any write. AGENTS.md is merged, not clobbered, so it is exempt. - const wrappers: WrapperFile[] = tools.flatMap((tool) => wrapperFilesFor(tool)); - for (const wrapper of wrappers) { - if (wrapper.path === 'AGENTS.md') { - continue; - } - if (existsSync(resolve(root, wrapper.path)) && !force) { - throw refusalError( - 'init.wrapper_exists', - `Refusing to overwrite ${wrapper.path} without --force.`, - { path: wrapper.path }, - ); - } - } - - const written: string[] = []; - const writeFile = (relPath: string, content: string): void => { - const abs = resolve(root, relPath); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); - written.push(relPath); - }; - - // Core workspace. - mkdirSync(earsDir, { recursive: true }); - writeFile('.earsyntax/config.json', `${JSON.stringify(defaultConfig(tools), null, 2)}\n`); - writeFile('.earsyntax/work/.gitkeep', ''); - - // Tool wrappers. - for (const wrapper of wrappers) { - if (wrapper.path === 'AGENTS.md') { - const abs = resolve(root, 'AGENTS.md'); - const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : undefined; - writeFileSync(abs, mergeAgentsFile(existing, wrapper.content)); - written.push('AGENTS.md'); - } else { - writeFile(wrapper.path, wrapper.content); - } - } - - const response = buildResponse( - { - command: 'init', - ok: true, - root, - next: [ - { - command: 'earsyntax new --source specs/source.md --json', - reason: 'Create a work item for a source spec.', - forAgent: true, - }, - ], - }, - { written, tools }, - ); - - const pretty = [ - `Initialized earsyntax in ${toRelative(root, earsDir)}`, - ...written.map((p) => ` wrote ${p}`), - tools.length > 0 ? ` tools: ${tools.join(', ')}` : ' tools: none', - ].join('\n'); - - emit(context.emitter, response, pretty); - return 0; -} +export const initCommand = notImplemented('init'); diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts index 43671dd..6d01564 100644 --- a/packages/cli/src/commands/instructions.ts +++ b/packages/cli/src/commands/instructions.ts @@ -1,157 +1,10 @@ /** - * `earsyntax instructions --work ` — return the rules an agent - * follows for one loop step. Read-only. - * - * The four modes are author, convert, repair, and review. Convert (and repair - * and review when the item has a source) include a `source` block with - * excerpts; repair also includes the diagnostics the agent must address, read - * from the work item's last `validation.json`. + * `earsyntax instructions --file [--from ]` + * — return the deterministic rules an agent follows for one loop step against a + * host file. Reimplemented by the host-native instructions agent; this is a + * compiling stub until then. */ -import { existsSync, readFileSync } from 'node:fs'; -import type { CommandContext } from '../context.js'; -import type { - FacadeDiagnostic, - NextAction, - SourceExcerpt, - ValidationResult, -} from '../facade-types.js'; -import { usageError } from '../errors.js'; -import { requireInstructionMode } from '../mode.js'; -import { loadConfig, requireRoot, resolveInput } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { ALLOWED_PATTERNS, exampleLine, METADATA_PREFIXES, rulesFor } from '../rules.js'; -import { requireManifest, toWorkSummary } from '../workspace.js'; +import { notImplemented } from './stub.js'; -const MAX_EXCERPTS = 3; - -/** Best-effort source excerpts: the first few content lines with their numbers. */ -function buildExcerpts(root: string, sourcePath: string): SourceExcerpt[] { - const abs = resolveInput(root, sourcePath); - let content: string; - try { - content = readFileSync(abs, 'utf8'); - } catch { - return []; - } - const excerpts: SourceExcerpt[] = []; - const lines = content.split('\n'); - for (let i = 0; i < lines.length && excerpts.length < MAX_EXCERPTS; i++) { - const text = lines[i]?.trim() ?? ''; - if (text === '' || text.startsWith('#')) { - continue; - } - const lineNo = i + 1; - excerpts.push({ path: sourcePath, startLine: lineNo, endLine: lineNo, text }); - } - return excerpts; -} - -/** Read the last validation.json results and flatten them to facade diagnostics. */ -function readRepairDiagnostics(validationJsonAbs: string): FacadeDiagnostic[] { - if (!existsSync(validationJsonAbs)) { - return []; - } - let parsed: unknown; - try { - parsed = JSON.parse(readFileSync(validationJsonAbs, 'utf8')); - } catch { - return []; - } - const results = - parsed && typeof parsed === 'object' && 'results' in parsed - ? ((parsed as { results?: ValidationResult[] }).results ?? []) - : []; - return results.flatMap((result) => - result.diagnostics.map((diagnostic) => ({ - code: diagnostic.code, - severity: diagnostic.severity, - message: diagnostic.message, - path: result.file, - ...(result.line !== undefined ? { line: result.line } : {}), - })), - ); -} - -export function instructionsCommand(context: CommandContext): number { - const mode = requireInstructionMode(context.args.positionals[0]); - const workId = context.args.values.get('work'); - if (workId === undefined) { - throw usageError('instructions.missing_work', 'Provide the work item with --work .'); - } - - const root = requireRoot(context.cwd); - const config = loadConfig(root, context.global.config); - const { paths, manifest } = requireManifest(root, config, workId); - - const sourcePath = manifest.source?.path; - const output = manifest.output.path; - - // Convert mode carries the source block with excerpts; repair mode carries - // the diagnostics to fix. This mirrors the golden instruction fixtures. - const sourceBlock = - mode === 'convert' && sourcePath !== undefined - ? { - source: { - path: sourcePath, - hash: manifest.source?.hash, - excerpts: buildExcerpts(root, sourcePath), - }, - } - : {}; - const diagnosticsBlock = - mode === 'repair' ? { diagnostics: readRepairDiagnostics(paths.validationJson) } : {}; - - const extra: Record = { - mode, - work: toWorkSummary(root, manifest), - rules: rulesFor(mode), - format: { - line: exampleLine(mode, sourcePath), - allowedPatterns: ALLOWED_PATTERNS, - metadataPrefixes: METADATA_PREFIXES, - }, - ...sourceBlock, - ...diagnosticsBlock, - }; - - const sourceFlag = sourcePath ? ` --source ${sourcePath}` : ''; - const validateNext: NextAction = { - command: `earsyntax validate ${output}${sourceFlag} --json`, - reason: - mode === 'repair' - ? 'Re-validate after applying the repairs and repeat until validation is clean.' - : 'Validate the generated .ears file once it is written.', - forAgent: true, - }; - const reviewNext: NextAction[] = [ - { - command: `earsyntax show ${manifest.id} --artifact questions --json`, - reason: 'Read the open questions the human must resolve before acceptance.', - forAgent: true, - }, - { - command: `earsyntax accept ${manifest.id}`, - reason: 'Record human acceptance once the reviewer approves.', - blocking: true, - }, - ]; - - const response = buildResponse( - { - command: `instructions ${mode}`, - ok: true, - root, - next: mode === 'review' ? reviewNext : [validateNext], - }, - extra, - ); - - const pretty = [ - `instructions ${mode} for "${manifest.id}"`, - ...rulesFor(mode).map((r) => ` - ${r}`), - ].join('\n'); - - emit(context.emitter, response, pretty); - return 0; -} +export const instructionsCommand = notImplemented('instructions', true); diff --git a/packages/cli/src/commands/profiles.ts b/packages/cli/src/commands/profiles.ts new file mode 100644 index 0000000..8e320bf --- /dev/null +++ b/packages/cli/src/commands/profiles.ts @@ -0,0 +1,9 @@ +/** + * `earsyntax profiles` — list the built-in profiles, rendered from profile data. + * Reimplemented by the host-native profiles agent; this is a compiling stub + * until then. + */ + +import { notImplemented } from './stub.js'; + +export const profilesCommand = notImplemented('profiles'); diff --git a/packages/cli/src/commands/stub.ts b/packages/cli/src/commands/stub.ts new file mode 100644 index 0000000..f4fd42e --- /dev/null +++ b/packages/cli/src/commands/stub.ts @@ -0,0 +1,28 @@ +/** + * Compiling stubs for the commands whose bodies land in a later alpha. + * + * `instructions`, `init`, `doctor`, `explain`, and `profiles` are part of the + * closed command surface and appear in help, so the dispatcher routes them and + * help stays truthful. Their bodies are reimplemented by the host-native + * command agents; until then each returns a typed not-yet-reimplemented + * envelope with exit `2`. + */ + +import type { CommandContext, CommandHandler, CommandResult } from '../context.js'; +import { buildResponse } from '../response.js'; + +/** Build a handler that reports the command is not yet available in this alpha. */ +export function notImplemented(name: string, withMode = false): CommandHandler { + return (context: CommandContext): CommandResult => { + const mode = withMode ? context.args.positionals.at(0) : undefined; + const command = mode ? `${name} ${mode}` : name; + const message = `The ${command} command arrives in a later alpha of the host-native CLI.`; + const response = buildResponse({ + command, + ok: false, + diagnostics: [{ code: 'cli.not_implemented', severity: 'error', message }], + next: [], + }); + return { response, pretty: `error cli.not_implemented: ${message}`, exitCode: 2 }; + }; +} diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts new file mode 100644 index 0000000..bdcc8c1 --- /dev/null +++ b/packages/cli/src/commands/validate.test.ts @@ -0,0 +1,351 @@ +/** + * 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('stubs --sarif with exit 2 until the SARIF phase lands', () => { + 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(2); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.sarif_pending'); + }); + + 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.conflicting_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, + color: false, + }; + 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 index 5742377..ba4f0f4 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,310 +1,351 @@ /** - * `earsyntax validate ` — validate `.ears` files deterministically. + * `earsyntax validate ` — stateless EARS validation. * - * Uses `@earsyntax/extract` to read every supported format and - * `@earsyntax/core` `lintEarsBatch` to lint. Never writes source or asks - * questions. This is the only command that returns exit 1 (error diagnostics). - * When run against a work item it updates the manifest status and writes the - * `validation.json` / `validation.md` artifacts, and reports source staleness. + * 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, writeFileSync } from 'node:fs'; -import { relative, resolve } from 'node:path'; -import { type Catalog, lintEarsBatch, type Options } from '@earsyntax/core'; -import { extractFromFile } from '@earsyntax/extract'; -import type { CommandContext } from '../context.js'; -import type { - FacadeDiagnostic, - ValidationResult, - WorkManifest, - WorkStatus, -} from '../facade-types.js'; -import { usageError } from '../errors.js'; -import { hashContent } from '../hash.js'; -import { findRoot, loadConfig, requireRoot, resolveInput, toRelative } from '../project.js'; -import { buildResponse, emit } from '../response.js'; -import { currentSourceHash, requireManifest, toWorkSummary, type WorkPaths } from '../workspace.js'; +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 { canonicalizeFindings, type Findings } 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 = /[*?[\]{}]/; -/** Expand file arguments (literals and globs) into absolute paths, in order. */ -function expandFiles(cwd: string, patterns: string[]): string[] { - const files: string[] = []; - for (const pattern of patterns) { - if (GLOB_CHARS.test(pattern)) { - const matches = globSync(pattern, { cwd }); - for (const match of matches.sort((a, b) => a.localeCompare(b))) { - files.push(resolveInput(cwd, match)); - } - } else { - const abs = resolveInput(cwd, pattern); - if (!existsSync(abs)) { - throw usageError('validate.missing_file', `File not found: ${pattern}.`); - } - files.push(abs); - } - } - return files; +/** 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; } -/** Load and parse a catalog file, throwing a usage error on failure. */ -function loadCatalog(cwd: string, catalogArg: string | undefined): Catalog | undefined { - if (catalogArg === undefined) { - return undefined; - } - const abs = resolveInput(cwd, catalogArg); - let raw: string; - try { - raw = readFileSync(abs, 'utf8'); - } catch { - throw usageError('validate.catalog_unreadable', `Could not read catalog: ${catalogArg}.`); +/** 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`: SARIF output (a later-phase seam; see {@link runValidate}). */ + 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; +} + +/** 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 (cli.exclusive_flags); this in-band + // guard covers direct callers of runValidate so the invariant holds either way. + if (inputs.sarif && inputs.json) { + return usageResult('cli.conflicting_flags', 'The --json and --sarif flags are mutually exclusive.'); } - try { - return JSON.parse(raw) as Catalog; - } catch { - throw usageError('validate.catalog_invalid', `Catalog is not valid JSON: ${catalogArg}.`); + + // `--sarif` is a later-phase seam. The existing cli-contract SARIF builder + // consumes the legacy ReportInput shape, not the Findings model, so it is not + // wired here; Agent W5-sarif replaces this branch with a Findings projection + // returned as `CommandResult.raw`. + if (inputs.sarif) { + return usageResult('validate.sarif_pending', 'SARIF output lands in a later phase.'); } -} -/** Build core lint options from the parsed flags. */ -function buildOptions(context: CommandContext): Options { - const mode = context.args.values.get('mode'); - if (mode !== undefined && mode !== 'strict' && mode !== 'guided') { - throw usageError('validate.bad_mode', `Unknown --mode "${mode}". Expected strict or guided.`); + const resolved = resolveProfile(inputs.profileName); + if (!resolved.ok) { + return usageResult('cli.unknown_profile', resolved.error.message); } - return { - ...(mode === 'strict' || mode === 'guided' ? { mode } : {}), - ...(context.args.booleans.has('comma-as-and') ? { commaAsAnd: true } : {}), - }; -} + const profile = resolved.profile; -/** Find the work-item slug a validated path belongs to, if any. */ -function inferWorkSlug(root: string, workDir: string, absFiles: string[]): string | undefined { - const base = resolve(root, workDir); - for (const file of absFiles) { - const rel = relative(base, file); - if (!rel.startsWith('..') && rel !== '') { - const segment = rel.split(/[/\\]/).at(0); - if (segment !== undefined && segment !== '') { - return segment; - } - } + if (inputs.paths.length === 0) { + return usageResult('validate.no_files', 'Provide one or more files, globs, or - for stdin.'); } - return undefined; -} -/** Render a short human-readable validation.md artifact. */ -function renderValidationMarkdown( - results: ValidationResult[], - errors: number, - warnings: number, -): string { - const lines = ['# Validation', '', `errors: ${errors}, warnings: ${warnings}`, '']; - for (const result of results) { - for (const diagnostic of result.diagnostics) { - const at = result.line === undefined ? '' : `:${result.line}`; - lines.push( - `- ${diagnostic.severity} ${diagnostic.code} (${result.id ?? '?'}${at}) ${diagnostic.message}`, - ); - } + const outcome = resolveInputs(inputs.paths, profile, inputs.cwd, deps); + if (!outcome.ok) { + return outcome.result; } - lines.push(''); - return lines.join('\n'); + 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); } -export function validateCommand(context: CommandContext): number { - const patterns = context.args.positionals; - if (patterns.length === 0) { - throw usageError('validate.no_files', 'Provide one or more .ears files or globs to validate.'); - } +/** 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; - const workId = context.args.values.get('work'); - const root = - workId !== undefined ? requireRoot(context.cwd) : (findRoot(context.cwd) ?? context.cwd); - const config = existsSync(resolve(root, '.earsyntax')) - ? loadConfig(root, context.global.config) - : undefined; - - const absFiles = expandFiles(context.cwd, patterns); - const catalog = loadCatalog(context.cwd, context.args.values.get('catalog')); - const options = buildOptions(context); - - // Lint each file, grouping results in input order. - const results: ValidationResult[] = []; - let requirements = 0; - for (const abs of absFiles) { - const extracted = extractFromFile(abs); - if (extracted.items.length === 0 && extracted.errors.length > 0) { - throw usageError( - 'validate.unreadable', - extracted.errors.at(0)?.message ?? `Could not read ${abs}.`, - ); - } - const fileRel = toRelative(root, abs); - const lintResults = lintEarsBatch(extracted.items, catalog, options); - for (let i = 0; i < extracted.items.length; i++) { - const item = extracted.items.at(i); - const lint = lintResults.at(i); - if (item === undefined || lint === undefined) { - continue; + for (const path of paths) { + if (path === '-') { + if (readStdin) { + return { + ok: false, + result: usageResult('validate.duplicate_stdin', 'Read stdin (-) at most once.'), + }; } - requirements += 1; - results.push({ - ...(item.id !== undefined ? { id: item.id } : {}), - file: fileRel, - ...(item.source?.line !== undefined ? { line: item.source.line } : {}), - valid: lint.valid, - ...(lint.pattern !== undefined ? { pattern: lint.pattern } : {}), - ...(lint.ast !== undefined ? { ast: lint.ast } : {}), - references: lint.references, - diagnostics: lint.diagnostics, - }); + readStdin = true; + files.push({ path: '-', content: deps.readStdin(), kind: stdinKind(profile) }); + continue; } - } - // Facade-level stable-ID validation: duplicate IDs are error diagnostics. - const facadeDiagnostics: FacadeDiagnostic[] = []; - const seen = new Set(); - for (const result of results) { - if (result.id === undefined) { + 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; } - if (seen.has(result.id)) { - facadeDiagnostics.push({ - code: 'facade.duplicate_id', - severity: 'error', - message: `Duplicate requirement ID "${result.id}". Requirement IDs must be unique.`, - path: result.file, - ...(result.line !== undefined ? { line: result.line } : {}), - }); - } else { - seen.add(result.id); + + const resolvedFile = readResolved(path, cwd, deps); + if (!resolvedFile.ok) { + return { ok: false, result: resolvedFile.result }; } + files.push(resolvedFile.file.pipelineFile); } - let errors = facadeDiagnostics.length; - let warnings = 0; - let validCount = 0; - for (const result of results) { - if (result.valid) { - validCount += 1; - } - for (const diagnostic of result.diagnostics) { - if (diagnostic.severity === 'error') { - errors += 1; - } else if (diagnostic.severity === 'warning') { - warnings += 1; - } - } + 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('/'); +} - const summary = { files: absFiles.length, requirements, valid: validCount, errors, warnings }; - const ok = errors === 0; - - // Work-item integration: update the manifest and write artifacts, then expose - // the refreshed work summary and staleness. No target work item leaves it {}. - const slug = - config === undefined ? undefined : (workId ?? inferWorkSlug(root, config.workDir, absFiles)); - const workBlock = - config !== undefined && slug !== undefined - ? (() => { - const { paths, manifest } = requireManifest(root, config, slug); - const { manifest: updated, stale } = updateWorkItem( - root, - paths, - manifest, - errors, - results, - summary, - warnings, - ); - return { work: toWorkSummary(root, updated), stale }; - })() - : {}; - - const extra: Record = { summary, results, ...workBlock }; - - const next = ok - ? [] - : [ - { - command: `earsyntax instructions repair${workId ? ` --work ${workId}` : ''} --json`, - reason: 'Get targeted repair rules for the reported diagnostics.', - forAgent: true, - }, - ]; +/** 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. */ +function frame(findings: Findings, notices: PipelineNotice[], profile: Profile): ValidateResult { + const diagnostics = notices.map(noticeToDiagnostic); + const environmentError = notices.some((notice) => notice.severity === 'error'); + const ok = !environmentError && findings.summary.errors === 0; + const exitCode = environmentError ? 2 : findings.summary.errors > 0 ? 1 : 0; + + const next = buildNext(findings, profile); const response = buildResponse( - { command: 'validate', ok, root, diagnostics: facadeDiagnostics, next }, - extra, + { + command: 'validate', + ok, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + next, + }, + { findings }, ); - const pretty = renderPretty(results, summary, facadeDiagnostics); - emit(context.emitter, response, pretty); - return ok ? 0 : 1; + return { response, pretty: prettyFindings(findings, diagnostics), exitCode }; } -/** Update the work manifest after a validation and persist the artifacts. */ -function updateWorkItem( - root: string, - paths: WorkPaths, - manifest: WorkManifest, - errors: number, - results: ValidationResult[], - summary: { files: number; requirements: number; valid: number; errors: number; warnings: number }, - warnings: number, -): { manifest: WorkManifest; stale: boolean } { - const outputAbs = resolveInput(root, manifest.output.path); - let outputHash = manifest.output.hash; - try { - outputHash = hashContent(readFileSync(outputAbs, 'utf8')); - } catch { - // Leave the recorded hash if the output is unreadable. - } - - const nextStatus: WorkStatus = errors > 0 ? 'invalid' : 'valid'; - const updated: WorkManifest = { - ...manifest, - status: nextStatus, - output: { ...manifest.output, hash: outputHash }, - updatedAt: new Date().toISOString(), +/** 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 }), }; - writeFileSync(paths.manifest, `${JSON.stringify(updated, null, 2)}\n`); - writeFileSync(paths.validationJson, `${JSON.stringify({ summary, results }, null, 2)}\n`); - writeFileSync(paths.validationMarkdown, renderValidationMarkdown(results, errors, warnings)); - - const current = currentSourceHash(root, updated.source?.path); - const stale = - nextStatus === 'valid' && - current !== undefined && - updated.source?.hash !== undefined && - current !== updated.source.hash; - return { manifest: updated, stale }; } -/** Pretty output: one line per diagnostic plus a summary. */ -function renderPretty( - results: ValidationResult[], - summary: { requirements: number; valid: number; errors: number; warnings: number }, - facadeDiagnostics: FacadeDiagnostic[], -): string { - const lines: string[] = []; - for (const result of results) { - for (const diagnostic of result.diagnostics) { - const at = result.line === undefined ? '' : `:${result.line}`; - lines.push( - `${result.file}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, - ); - } +/** 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 []; } - for (const diagnostic of facadeDiagnostics) { - const at = diagnostic.line === undefined ? '' : `:${diagnostic.line}`; + 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.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, + `${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( - `\n${summary.valid}/${summary.requirements} valid, ${summary.errors} errors, ${summary.warnings} warnings`, + `${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.ts b/packages/cli/src/commands/version.ts index 9f9e94f..d85df59 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -1,31 +1,31 @@ /** * `earsyntax version` — version and feature discovery. * - * Never resolves a project, so `root` is absent. With `--features` (or `--json`) - * the response carries the {@link FEATURES} capability map agents branch on. + * Never resolves a repo, so `root` is absent. The response always carries the + * {@link FEATURES} capability map; `--features` only expands the pretty output. */ -import type { CommandContext } from '../context.js'; -import { buildResponse, emit } from '../response.js'; +import type { CommandContext, CommandResult } from '../context.js'; +import { buildResponse } from '../response.js'; import { CLI_VERSION, FEATURES } from '../version.js'; -export function versionCommand(context: CommandContext): number { - const response = buildResponse( - { command: 'version', ok: true, next: [] }, - { features: FEATURES }, - ); +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}`; - emit(context.emitter, response, pretty); - return 0; + return { response, pretty, exitCode: 0 }; } diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 1464134..fedff5a 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -1,12 +1,14 @@ /** - * The context object every command handler receives. + * The context every command handler receives, and the result it returns. * - * It bundles the parsed arguments, resolved global options, the resolved - * working directory, and the output emitter. Command handlers read their inputs - * from here and return an exit code after emitting their response. + * 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 { @@ -14,8 +16,24 @@ export interface CommandContext { 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; } -/** A command handler: does its work, emits output, and returns an exit code. */ -export type CommandHandler = (context: CommandContext) => number; +/** 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 index 4f48a62..cec6834 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -2,23 +2,20 @@ * The single error type command handlers throw to abort with a facade * diagnostic and a specific exit code. * - * `run` catches {@link CliError}, renders a base {@link FacadeResponse} carrying - * the diagnostic (JSON or pretty), and returns the carried exit code. Exit - * codes follow the contract: 2 for usage/resolution errors, 3 for refusals. + * 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'; +import type { FacadeDiagnostic, NextAction } from './facade-types.js'; export class CliError extends Error { readonly exitCode: number; readonly diagnostic: FacadeDiagnostic; - readonly next: { command: string; reason: string; forAgent?: boolean; blocking?: boolean }[]; + readonly next: NextAction[]; - constructor( - exitCode: number, - diagnostic: FacadeDiagnostic, - next: { command: string; reason: string; forAgent?: boolean; blocking?: boolean }[] = [], - ) { + constructor(exitCode: number, diagnostic: FacadeDiagnostic, next: NextAction[] = []) { super(diagnostic.message); this.name = 'CliError'; this.exitCode = exitCode; @@ -27,7 +24,7 @@ export class CliError extends Error { } } -/** Build a usage/resolution error (exit 2). */ +/** Build a usage or environment error (exit 2). */ export function usageError( code: string, message: string, @@ -35,12 +32,3 @@ export function usageError( ): CliError { return new CliError(2, { code, severity: 'error', message, ...extra }); } - -/** Build a refusal error (exit 3): overwrite protection, stale source, confirmation required. */ -export function refusalError( - code: string, - message: string, - extra?: Partial, -): CliError { - return new CliError(3, { code, severity: 'error', message, ...extra }); -} diff --git a/packages/cli/src/facade-types.ts b/packages/cli/src/facade-types.ts index 4b59ed4..17f420e 100644 --- a/packages/cli/src/facade-types.ts +++ b/packages/cli/src/facade-types.ts @@ -1,21 +1,21 @@ /** - * Facade JSON contracts for the `earsyntax` CLI. + * The facade envelope types for the `earsyntax` CLI. * - * These shapes are the normative wire format described in `docs/facade-api.md`. - * Every `--json` command returns a {@link FacadeResponse}; command-specific - * payloads are added as top-level convenience keys rather than nested under - * `data` (see the `[DECIDED] Convenience keys over data` note in the contract). - * - * Core linting types (`Pattern`, `Diagnostic`, `EarsAst`, `ReferenceMatch`) are - * imported from `@earsyntax/core`; this module only defines the facade layer. + * 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). */ -import type { Diagnostic, EarsAst, Pattern, ReferenceMatch } from '@earsyntax/core'; - -/** Severity of a facade-level notice. `info` is facade-only; core emits only error/warning. */ -export type FacadeSeverity = 'error' | 'warning' | 'info'; +/** 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 about config, paths, or work items. */ +/** + * 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; @@ -28,7 +28,6 @@ export interface FacadeDiagnostic { export interface NextAction { command: string; reason: string; - blocking?: boolean; forAgent?: boolean; } @@ -38,82 +37,8 @@ export interface FacadeResponse { command: string; ok: boolean; root?: string; - data?: unknown; diagnostics?: FacadeDiagnostic[]; next: NextAction[]; - /** Command-specific convenience keys (work, written, summary, results, ...). */ + /** Command-specific convenience keys (findings, summary, candidates, features, ...). */ [key: string]: unknown; } - -/** The two ways a work item can be created. */ -export type WorkMode = 'author' | 'convert'; - -/** The lifecycle state of a work item. */ -export type WorkStatus = - 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; - -/** How a source file was classified for extraction. */ -export type SourceKind = 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; - -/** The durable state of a work item, stored at `.earsyntax/work//manifest.json`. */ -export interface WorkManifest { - schemaVersion: 1; - id: string; - mode: WorkMode; - status: WorkStatus; - source?: { - path?: string; - hash?: string; - kind: SourceKind; - snapshotPath?: string; - }; - prompt?: string; - output: { - path: string; - hash?: string; - }; - artifacts: { - questions: string; - traceability: string; - validationJson: string; - validationMarkdown: string; - }; - createdAt: string; - updatedAt: string; - accepted?: { - at: string; - by?: string; - sourceHash?: string; - outputHash: string; - }; -} - -/** The compact work-item view returned by `instructions`, `list`, and `show`. */ -export interface WorkSummary { - id: string; - status: WorkStatus; - source?: string; - output: string; - questions: string; - traceability: string; -} - -/** One source excerpt returned in convert-mode instructions. */ -export interface SourceExcerpt { - path: string; - startLine: number; - endLine: number; - text: string; -} - -/** One validated requirement in a `validate` response. */ -export interface ValidationResult { - id?: string; - file: string; - line?: number; - valid: boolean; - pattern?: Pattern; - ast?: EarsAst; - references: ReferenceMatch[]; - diagnostics: Diagnostic[]; -} 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/response.ts b/packages/cli/src/response.ts index 3add432..bffe5f6 100644 --- a/packages/cli/src/response.ts +++ b/packages/cli/src/response.ts @@ -22,11 +22,11 @@ export interface BaseInit { /** * Build a {@link FacadeResponse} with the base fields in a stable order, - * then merge command-specific convenience keys on top. + * 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 { - // One immutable literal in the stable facade key order: base identity, root, - // command convenience keys, diagnostics, then next last (matching fixtures). const hasDiagnostics = init.diagnostics !== undefined && init.diagnostics.length > 0; return { version: CLI_VERSION, @@ -51,25 +51,31 @@ export interface Emitter { 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`); +} + /** Emit a response: JSON when `--json`, otherwise the pretty string. */ export function emit(emitter: Emitter, response: FacadeResponse, pretty: string): void { - emitter.write(`${emitter.json ? serialize(response) : pretty}\n`); + emitResult(emitter, response, pretty); } -/** Build the response for a {@link CliError} so `run` can emit it uniformly. */ +/** 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], - next: error.next, - }, - {}, - ); + return buildResponse({ + command, + ok: false, + root, + diagnostics: [error.diagnostic], + next: error.next, + }); } diff --git a/packages/cli/src/tool-wrappers.ts b/packages/cli/src/tool-wrappers.ts deleted file mode 100644 index 12594d4..0000000 --- a/packages/cli/src/tool-wrappers.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Thin per-agent wrapper files written by `init --tools`. - * - * Every wrapper is a pointer to the same CLI loop: call - * `earsyntax instructions --json`, write the `.ears` file, then call - * `earsyntax validate --json` and repair until it is clean. Wrappers never fork - * or paraphrase the rules; the CLI is the single source of truth. - */ - -/** A file a tool wrapper writes: a root-relative path and its content. */ -export interface WrapperFile { - path: string; - content: string; -} - -/** Marker wrapping the injected AGENTS.md section so it can be detected and replaced. */ -export const AGENTS_START = ''; -export const AGENTS_END = ''; - -function claudeCommand(mode: 'author' | 'convert' | 'repair'): string { - const verb = - mode === 'author' - ? 'Author EARS requirements from a prompt' - : mode === 'convert' - ? 'Convert a source spec into EARS requirements' - : 'Repair EARS requirements after validation diagnostics'; - return [ - `# /earsyntax-${mode}`, - '', - `${verb}. This command is a thin wrapper around the \`earsyntax\` facade; the CLI returns the rules to follow.`, - '', - '1. Run `earsyntax instructions ' + - mode + - ' --work --json` and follow the returned rules exactly.', - '2. Write the `.ears` file, and maintain `questions.md` and `traceability.json`.', - '3. Run `earsyntax validate .ears --json`.', - '4. If it reports error diagnostics, run `earsyntax instructions repair --work --json` and fix only what the diagnostics justify. Repeat until validation is clean.', - '', - 'Do not invent behavior the source does not state. Put ambiguity in `questions.md`.', - '', - ].join('\n'); -} - -const AGENTS_SECTION = [ - AGENTS_START, - '## EARS requirements (earsyntax)', - '', - 'When asked to write or repair EARS `.ears` requirements, drive the `earsyntax` facade:', - '', - '1. `earsyntax instructions --work --json` returns the rules. Follow them exactly.', - '2. Write the `.ears` file (one requirement per line, stable REQ IDs), then `earsyntax validate .ears --json`.', - '3. Repair by diagnostic code until validation is clean. A human runs `earsyntax accept`.', - '', - 'Do not invent behavior the source does not state; record ambiguity in `questions.md`.', - AGENTS_END, - '', -].join('\n'); - -const CURSOR_RULES = [ - '---', - 'description: Drive EARS requirement authoring through the earsyntax CLI facade', - 'globs: ["**/*.ears"]', - '---', - '', - '# EARS requirements via earsyntax', - '', - 'Author and repair `.ears` files only through the `earsyntax` facade:', - '', - '1. `earsyntax instructions --work --json` returns the rules; follow them exactly.', - '2. Write the `.ears` file, then `earsyntax validate .ears --json`.', - '3. Fix by diagnostic code until validation is clean. Acceptance is a human step (`earsyntax accept`).', - '', - 'Do not invent behavior the source does not state; put ambiguity in `questions.md`.', - '', -].join('\n'); - -/** The wrapper files for one tool, as root-relative paths and content. */ -export function wrapperFilesFor(tool: string): WrapperFile[] { - switch (tool) { - case 'claude': - return [ - { path: '.claude/commands/earsyntax-author.md', content: claudeCommand('author') }, - { path: '.claude/commands/earsyntax-convert.md', content: claudeCommand('convert') }, - { path: '.claude/commands/earsyntax-repair.md', content: claudeCommand('repair') }, - ]; - case 'cursor': - return [{ path: '.cursor/rules/earsyntax.mdc', content: CURSOR_RULES }]; - case 'codex': - // codex uses an AGENTS.md section, handled specially by init (append or - // replace the marked section rather than clobbering a user-owned file). - return [{ path: 'AGENTS.md', content: AGENTS_SECTION }]; - default: - return []; - } -} - -/** The set of tool names `init --tools` understands. */ -export const KNOWN_TOOLS = new Set(['claude', 'codex', 'cursor']); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 061917d..865a89a 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -3,7 +3,8 @@ * * 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 without guessing. + * {@link FEATURES} is the capability map agents branch on to discover the closed + * command surface without guessing. */ import { readFileSync } from 'node:fs'; @@ -39,8 +40,11 @@ export const FACADE_CONTRACT = 1; /** The capability map shape agents branch on. */ export interface Features { facade: number; - workItems: boolean; + commands: string[]; + profiles: string[]; instructions: string[]; + hosts: string[]; + agents: string[]; inputFormats: string[]; outputFormats: string[]; sarif: boolean; @@ -49,11 +53,12 @@ export interface Features { /** Capability map returned by `version --features`. */ export const FEATURES: Features = { facade: FACADE_CONTRACT, - workItems: true, + commands: ['validate', 'extract', 'instructions', 'explain', 'profiles', 'doctor', 'init', 'version'], + profiles: ['strict', 'ears-x', 'kiro', 'speckit', 'openspec'], instructions: ['author', 'convert', 'repair', 'review'], - inputFormats: ['.ears', 'markdown', 'yaml', 'json'], - outputFormats: ['pretty', 'json'], - // SARIF output exists in @earsyntax/cli-contract but is not wired into the - // CLI in this milestone; the flag is false so agents do not select it. - sarif: false, + hosts: ['kiro', 'speckit', 'openspec'], + agents: ['claude', 'codex', 'cursor', 'copilot', 'gemini', 'generic'], + inputFormats: ['ears', 'text', 'markdown', 'yaml', 'json'], + outputFormats: ['pretty', 'json', 'sarif'], + sarif: true, }; From eb26670e411f5b4cecbbd5da4cee597b0d0937d6 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:00:48 +0300 Subject: [PATCH 13/20] feat(cli): instructions, init, doctor, explain, profiles, sarif output Instructions command with author, convert, repair, review modes and the --from source flow; init renderers for six agents and three hosts with idempotent managed sections; stateless doctor with host and agent detection; explain rendered from the diagnostic registry with alias resolution; profiles command as a drift-proof projection; SARIF 2.1.0 emitter projected from the findings model. --- fixtures/host-repos/empty/.gitkeep | 0 .../kiro/.kiro/specs/checkout/requirements.md | 5 + fixtures/host-repos/multi/.cursor/rules.md | 3 + .../multi/.github/prompts/example.prompt.md | 3 + .../.kiro/specs/checkout/requirements.md | 5 + fixtures/host-repos/multi/.specify/config.yml | 1 + fixtures/host-repos/multi/AGENTS.md | 3 + fixtures/host-repos/multi/GEMINI.md | 3 + .../multi/openspec/specs/core/spec.md | 5 + .../openspec/changes/add-refunds/proposal.md | 5 + .../openspec/openspec/specs/checkout/spec.md | 5 + .../host-repos/speckit/specs/checkout/spec.md | 3 + packages/cli-contract/src/index.ts | 10 +- packages/cli-contract/src/sarif.test.ts | 326 +- packages/cli-contract/src/sarif.ts | 288 +- .../cli-contract/test/sarif-schema-2.1.0.json | 3370 +++++++++++++++++ packages/cli/src/cli.test.ts | 22 +- packages/cli/src/commands/doctor.test.ts | 284 ++ packages/cli/src/commands/doctor.ts | 250 +- packages/cli/src/commands/explain.test.ts | 429 +++ packages/cli/src/commands/explain.ts | 175 +- packages/cli/src/commands/init.test.ts | 334 ++ packages/cli/src/commands/init.ts | 278 +- .../cli/src/commands/instructions.test.ts | 399 ++ packages/cli/src/commands/instructions.ts | 244 +- packages/cli/src/commands/profiles.test.ts | 160 + packages/cli/src/commands/profiles.ts | 52 +- packages/cli/src/commands/validate.test.ts | 41 +- packages/cli/src/commands/validate.ts | 40 +- packages/cli/src/renderers/agents.ts | 136 + packages/cli/src/renderers/hosts.ts | 132 + packages/cli/src/renderers/index.ts | 12 + packages/cli/src/renderers/protocol.ts | 121 + packages/cli/src/rules.ts | 283 ++ 34 files changed, 7232 insertions(+), 195 deletions(-) create mode 100644 fixtures/host-repos/empty/.gitkeep create mode 100644 fixtures/host-repos/kiro/.kiro/specs/checkout/requirements.md create mode 100644 fixtures/host-repos/multi/.cursor/rules.md create mode 100644 fixtures/host-repos/multi/.github/prompts/example.prompt.md create mode 100644 fixtures/host-repos/multi/.kiro/specs/checkout/requirements.md create mode 100644 fixtures/host-repos/multi/.specify/config.yml create mode 100644 fixtures/host-repos/multi/AGENTS.md create mode 100644 fixtures/host-repos/multi/GEMINI.md create mode 100644 fixtures/host-repos/multi/openspec/specs/core/spec.md create mode 100644 fixtures/host-repos/openspec/openspec/changes/add-refunds/proposal.md create mode 100644 fixtures/host-repos/openspec/openspec/specs/checkout/spec.md create mode 100644 fixtures/host-repos/speckit/specs/checkout/spec.md create mode 100644 packages/cli-contract/test/sarif-schema-2.1.0.json create mode 100644 packages/cli/src/commands/doctor.test.ts create mode 100644 packages/cli/src/commands/explain.test.ts create mode 100644 packages/cli/src/commands/init.test.ts create mode 100644 packages/cli/src/commands/instructions.test.ts create mode 100644 packages/cli/src/commands/profiles.test.ts create mode 100644 packages/cli/src/renderers/agents.ts create mode 100644 packages/cli/src/renderers/hosts.ts create mode 100644 packages/cli/src/renderers/index.ts create mode 100644 packages/cli/src/renderers/protocol.ts create mode 100644 packages/cli/src/rules.ts 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/packages/cli-contract/src/index.ts b/packages/cli-contract/src/index.ts index e48dd0c..bbac6a9 100644 --- a/packages/cli-contract/src/index.ts +++ b/packages/cli-contract/src/index.ts @@ -7,9 +7,10 @@ * 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 and the - * pretty model are legacy projections kept compiling; they will be rebuilt from - * Findings by a later agent. + * (`toFindings`) and serialized here by {@link serializeFindings}. SARIF is a + * projection of that same Findings model ({@link buildSarifLog}); the pretty + * model is a legacy projection kept compiling, to be rebuilt from Findings by a + * later agent. * * Determinism contract: serializers construct object keys in a fixed order and * preserve input order, so identical input always produces identical output. @@ -26,6 +27,7 @@ export type { ReportInput, ReportInputFile, ReportInputItem } from './input.js'; export { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; export type { + BuildSarifOptions, SarifDriver, SarifLevel, SarifLocation, @@ -36,7 +38,7 @@ export type { SarifRule, SarifRun, } from './sarif.js'; -export { buildSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './sarif.js'; +export { buildSarifLog, serializeSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './sarif.js'; export type { PrettyModel, PrettyRecord, PrettySummary } from './pretty.js'; export { buildPrettyModel } from './pretty.js'; diff --git a/packages/cli-contract/src/sarif.test.ts b/packages/cli-contract/src/sarif.test.ts index f93ca76..2ea7c4b 100644 --- a/packages/cli-contract/src/sarif.test.ts +++ b/packages/cli-contract/src/sarif.test.ts @@ -1,68 +1,306 @@ +/** + * 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 } from './sarif.js'; -import { DIAGNOSTIC_CODES } from './diagnostic-registry.js'; -import { sampleRun } from './__fixtures__/sample-run.js'; +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; -describe('buildSarifLog', () => { - it('emits a single run with the earsyntax driver', () => { - const log = buildSarifLog(sampleRun); +/** 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('declares one sorted rule per registered diagnostic code', () => { - const rules = buildSarifLog(sampleRun).runs[0]?.tool.driver.rules ?? []; - expect(rules.map((r) => r.id)).toEqual(DIAGNOSTIC_CODES); - for (const rule of rules) { - expect(rule.shortDescription.text.length).toBeGreaterThan(0); - } + 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('maps severities to SARIF levels (error, warning, note)', () => { - const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; - const byCode = new Map(results.map((r) => [r.ruleId, r.level])); - expect(byCode.get('catalog.system_unresolved')).toBe('error'); - expect(byCode.get('lint.vague_response')).toBe('warning'); - expect(byCode.get('lint.suspicious_text_shape')).toBe('note'); + 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('produces one result per diagnostic in input order', () => { - const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; - expect(results.map((r) => r.ruleId)).toEqual([ - 'lint.vague_response', - 'catalog.system_unresolved', - 'ears.no_match', - 'lint.suspicious_text_shape', + 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('uses a line-based region when the source line is known', () => { - const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; - const loc = results[0]?.locations[0]?.physicalLocation; - expect(loc.artifactLocation.uri).toBe('specs/subscribe.ears'); - expect(loc.region).toEqual({ startLine: 7 }); + 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('falls back to a char-offset region when the line is unknown', () => { - const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; - const noMatch = results.find((r) => r.ruleId === 'ears.no_match'); - expect(noMatch?.locations[0]?.physicalLocation.region).toEqual({ - charOffset: 0, - charLength: 29, + 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('omits the region when neither line nor span is known', () => { - const results = buildSarifLog(sampleRun).runs[0]?.results ?? []; - const suspicious = results.find((r) => r.ruleId === 'lint.suspicious_text_shape'); - expect(suspicious?.locations[0]?.physicalLocation.region).toBeUndefined(); + it('conforms to the vendored SARIF 2.1.0 schema for a clean run', () => { + expect(schemaViolations(buildSarifLog(CLEAN))).toEqual([]); }); - it('serializes to valid JSON', () => { - const log = buildSarifLog(sampleRun); - const roundTrip = JSON.parse(JSON.stringify(log)); - expect(roundTrip).toEqual(log); + 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 index 4f97991..5d2defd 100644 --- a/packages/cli-contract/src/sarif.ts +++ b/packages/cli-contract/src/sarif.ts @@ -1,57 +1,88 @@ /** - * A minimal, valid subset of the SARIF 2.1.0 static analysis report format. + * 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 emits only the fields those consumers require: one run, - * a tool driver named `earsyntax`, a rule per diagnostic code, and one result - * per diagnostic. It does not attempt full-schema coverage. + * 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. * - * Schema reference: - * https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json + * 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 { Severity } from '@earsyntax/core'; -import type { ReportInput } from './input.js'; -import { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; +import { type FindingsDiagnostic, type Findings, getDiagnosticEntry } from '@earsyntax/core'; -/** The canonical SARIF 2.1.0 JSON schema URL. */ +/** + * 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://github.com/oasis-tcs/sarif-spec/blob/main/sarif-2.1/schema/sarif-external-property-file-schema-2.1.0.json'; + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json'; -/** The SARIF version string this builder targets. */ +/** 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 builder emits. */ -export type SarifLevel = 'error' | 'warning' | 'note'; +/** + * 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`). */ +/** A SARIF rule descriptor (`reportingDescriptor`) declared on the tool driver. */ export interface SarifRule { - /** The rule id, equal to the diagnostic code. */ + /** The rule id, equal to the diagnostic's `EARS-*` id. */ id: string; - /** A short, one-line description of the rule. */ + /** 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. Either line-based or offset-based. */ +/** + * 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, when the source line is known. */ - startLine?: number; - /** 0-based character offset into the requirement text, when the line is not. */ - charOffset?: number; - /** Character length from {@link SarifRegion.charOffset}. */ - charLength?: number; + /** 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. */ + /** The artifact (source file) the result points at, as a relative URI. */ artifactLocation: { uri: string }; - /** The region within the artifact, when known. */ - region?: SarifRegion; + /** The region within the artifact. */ + region: SarifRegion; } /** A SARIF result location. */ @@ -59,23 +90,29 @@ export interface SarifLocation { physicalLocation: SarifPhysicalLocation; } -/** A single SARIF result (one diagnostic). */ +/** A single SARIF result (one finding). */ export interface SarifResult { - /** The id of the rule this result violates. */ + /** The id of the rule this result violates (the `EARS-*` id). */ ruleId: string; - /** The mapped severity level. */ + /** 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. */ + /** 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 rules this tool can report. */ + /** 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[]; } @@ -83,85 +120,162 @@ export interface SarifDriver { export interface SarifRun { /** The tool that produced the run. */ tool: { driver: SarifDriver }; - /** The results the run produced. */ + /** The results the run produced, in findings order. */ results: SarifResult[]; } /** A complete SARIF log. */ export interface SarifLog { - /** The SARIF schema URL. */ + /** 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 builder always emits exactly one. */ + /** 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 an EARS {@link Severity} to its SARIF level. + * 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 `-`. * - * `error` maps to `error`, `warning` to `warning`, and `info` to `note` - * (SARIF has no `info` level). + * @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 toSarifLevel(severity: Severity): SarifLevel { - switch (severity) { - case 'error': - return 'error'; - case 'warning': - return 'warning'; - case 'info': - return 'note'; +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; } /** - * Build a minimal SARIF 2.1.0 log from a linting run. + * Project a {@link Findings} value to a SARIF 2.1.0 log. * - * The single run declares one rule per registered diagnostic code (sorted) and - * one result per diagnostic, in file then requirement then diagnostic order. - * Each result points at its source file; the region is line-based when the - * source line is known, otherwise character-offset-based from the diagnostic - * span, and omitted when neither is available. + * 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 input The linted requirements grouped by source file. + * @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(input: ReportInput): SarifLog { - const rules: SarifRule[] = DIAGNOSTIC_CODES.map((code) => ({ - id: code, - shortDescription: { text: DIAGNOSTIC_DESCRIPTIONS[code] }, - })); - - const results: SarifResult[] = []; - for (const file of input) { - for (const item of file.items) { - const line = item.input.source?.line; - for (const diagnostic of item.result.diagnostics) { - const physicalLocation: SarifPhysicalLocation = { - artifactLocation: { uri: file.path }, - }; - if (line !== undefined) { - physicalLocation.region = { startLine: line }; - } else if (diagnostic.span !== undefined) { - physicalLocation.region = { - charOffset: diagnostic.span.start, - charLength: diagnostic.span.end - diagnostic.span.start, - }; - } - - results.push({ - ruleId: diagnostic.code, - level: toSarifLevel(diagnostic.severity), - message: { text: diagnostic.message }, - locations: [{ physicalLocation }], - }); - } - } +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) => + // Every finding id is in distinctIds by construction, so the index is defined. + buildResult(diagnostic, ruleIndexById.get(diagnostic.id) ?? 0), + ); + + 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: { name: SARIF_TOOL_NAME, rules } }, results }], + 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..e0b6524 --- /dev/null +++ b/packages/cli-contract/test/sarif-schema-2.1.0.json @@ -0,0 +1,3370 @@ +{ + "$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/src/cli.test.ts b/packages/cli/src/cli.test.ts index 700a134..4a07ef8 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -3,8 +3,7 @@ * * These exercise the command surface `run()` owns: routing across the eight * commands, flag validation against the closed surface, help and version text, - * the response envelope, and the not-yet-reimplemented stubs. Command bodies - * (validate, extract, and the W4/W5 commands) are tested in their own suites; + * 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. */ @@ -149,25 +148,6 @@ describe('routing', () => { }); }); -describe('not-yet-reimplemented stubs', () => { - it('returns exit 2 with cli.not_implemented for the W4/W5 commands', () => { - for (const command of ['init', 'doctor', 'explain', 'profiles']) { - const res = runCli(command, '--json'); - expect(res.code).toBe(2); - const body = res.json(); - expect(body.command).toBe(command); - expect(body.ok).toBe(false); - expect((body.diagnostics as { code: string }[])[0]?.code).toBe('cli.not_implemented'); - } - }); - - it('carries the instructions mode into the stub command label', () => { - const res = runCli('instructions', 'repair', '--file', 'x.md', '--json'); - expect(res.code).toBe(2); - expect(res.json().command).toBe('instructions repair'); - }); -}); - describe('flag validation', () => { it('rejects an unknown flag with exit 2', () => { const res = runCli('version', '--bogus', '--json'); diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts new file mode 100644 index 0000000..a53de89 --- /dev/null +++ b/packages/cli/src/commands/doctor.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for `earsyntax doctor`. + * + * The dispatcher (cli.ts/args.ts) is owned by another agent; these drive + * {@link doctorCommand} directly with a hand-built {@link CommandContext} and + * assert against the returned {@link CommandResult}. Once the dispatcher routes + * `doctor`, the acceptance gate + * `node packages/cli/bin/run.js doctor --cwd fixtures/host-repos/kiro --json` + * exercises the same path end to end. + * + * 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', + color: false, + }; + 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 index 3b9f3c3..c5b4ae2 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,9 +1,251 @@ /** * `earsyntax doctor` — detect host frameworks and agent integrations in the repo - * at `--cwd` and recommend exact commands. Reimplemented by the host-native - * doctor agent; this is a compiling stub until then. + * 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 { notImplemented } from './stub.js'; +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'; -export const doctorCommand = notImplemented('doctor'); +/** 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..a94e89a --- /dev/null +++ b/packages/cli/src/commands/explain.test.ts @@ -0,0 +1,429 @@ +/** + * 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. Catalog + * diagnostics are executed with a minimal catalog derived from the example + * (the linter never emits catalog codes without a catalog). Three entries do + * not reproduce through `lintEars` as their example text is written; they are + * listed in {@link KNOWN_DIVERGENT} with the observed behavior and reported to + * main for arbitration rather than silently weakening the test. + * 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', + color: false, + }; + 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-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'] }], + }, + }, +}; + +/** + * Registry entries whose documented example does not reproduce its own id + * through `lintEars` as written. These are surfaced to main for arbitration of + * the registry (an append-only file this agent does not edit), not worked around + * by weakening the assertion. Each guard asserts the observed behavior so the + * divergence stays explicit: if the registry example is later fixed, the guard + * fails and forces the entry off this list. + */ +const KNOWN_DIVERGENT: Record void }> = { + 'EARS-E010': { + reason: + 'badExample "The quick brown fox." parses as a system + response missing shall, so lintEars emits EARS-E007 (missing_shall), not EARS-E010. A true no_match needs a bare fragment such as "quick brown fox".', + guard: () => { + const codes = codesFor('The quick brown fox.'); + expect(codes).not.toContain('EARS-E010'); + expect(codes).toContain('EARS-E007'); + }, + }, + 'EARS-W014': { + reason: + 'badExample "timer reset maybe when idle" emits EARS-E010 (no_match) under the default strict dialect; EARS-W014 (suspicious_text_shape) only appears under the removed `guided` mode, which the host-native facade no longer exposes.', + guard: () => { + const codes = codesFor('timer reset maybe when idle'); + expect(codes).not.toContain('EARS-W014'); + expect(codes).toContain('EARS-E010'); + }, + }, + 'EARS-W007': { + reason: + 'badExample is descriptive prose ("A payment-http entry that no requirement references."), not a requirement; catalog.term_unreferenced is emitted only by lintCatalogCoverage, never by lintEars, so it cannot be executed as a lintEars example.', + guard: () => { + // Not a requirement: lintEars does not surface the code either way. + expect(codesFor('A payment-http entry that no requirement references.')).not.toContain('EARS-W007'); + // Its real path does reach the code, proving the id is reachable. + const coverage = lintCatalogCoverage([{ text: 'The billing service shall retain the audit log.' }], { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-UNUSED', name: 'a payment webhook is received' }], + }); + expect(coverage.map((diagnostic) => idForCode(diagnostic.code))).toContain('EARS-W007'); + }, + }, +}; + +/** 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)); +} + +describe('explain — the registry examples execute as documented', () => { + it('partitions every registry id into an executable context or the divergent list', () => { + for (const entry of DIAGNOSTIC_REGISTRY) { + const known = entry.id in EXAMPLE_CONTEXT || entry.id in KNOWN_DIVERGENT; + expect(known, `id ${entry.id} has no executable-example context`).toBe(true); + // No id is both executable and divergent. + expect(entry.id in EXAMPLE_CONTEXT && entry.id in KNOWN_DIVERGENT).toBe(false); + } + }); + + 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 KNOWN_DIVERGENT) { + 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('documents the known-divergent entries with their observed behavior', () => { + // This list is visible and pending arbitration by main; it is not empty by + // accident. Each guard pins the current behavior so a later registry fix + // surfaces here. + expect(Object.keys(KNOWN_DIVERGENT).sort()).toEqual(['EARS-E010', 'EARS-W007', 'EARS-W014']); + for (const { guard } of Object.values(KNOWN_DIVERGENT)) { + 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 index aa7d5cc..b2409c6 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -1,9 +1,174 @@ /** - * `earsyntax explain ` — full write-up of one diagnostic, - * resolving current ids and deprecated aliases. Reimplemented by the host-native - * explain agent; this is a compiling stub until then. + * `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 { notImplemented } from './stub.js'; +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'; -export const explainCommand = notImplemented('explain'); +/** 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/init.test.ts b/packages/cli/src/commands/init.test.ts new file mode 100644 index 0000000..a90610c --- /dev/null +++ b/packages/cli/src/commands/init.test.ts @@ -0,0 +1,334 @@ +/** + * 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 index 05ca524..eb831f6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,9 +1,279 @@ /** * `earsyntax init --agent --host ` — render managed agent-wrapper - * and host-integration files. Reimplemented by the host-native init agent; this - * is a compiling stub until then. + * 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 { notImplemented } from './stub.js'; +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'; -export const initCommand = notImplemented('init'); +/** 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..08281e1 --- /dev/null +++ b/packages/cli/src/commands/instructions.test.ts @@ -0,0 +1,399 @@ +/** + * 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`. The dispatcher (cli.ts/args.ts) is covered + * separately; the acceptance gate + * `node packages/cli/bin/run.js instructions repair --file fixtures/profiles/kiro/requirements.md --profile kiro --json` + * exercises the same path end to end. + * + * 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', + color: false, + }; + 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 index 6d01564..c48ba8a 100644 --- a/packages/cli/src/commands/instructions.ts +++ b/packages/cli/src/commands/instructions.ts @@ -1,10 +1,246 @@ /** * `earsyntax instructions --file [--from ]` * — return the deterministic rules an agent follows for one loop step against a - * host file. Reimplemented by the host-native instructions agent; this is a - * compiling stub until then. + * 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 { notImplemented } from './stub.js'; +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'; -export const instructionsCommand = notImplemented('instructions', true); +/** 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..e9fef0b --- /dev/null +++ b/packages/cli/src/commands/profiles.test.ts @@ -0,0 +1,160 @@ +/** + * 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', + color: false, + }; + 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('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'); + }); +}); diff --git a/packages/cli/src/commands/profiles.ts b/packages/cli/src/commands/profiles.ts index 8e320bf..fb2d366 100644 --- a/packages/cli/src/commands/profiles.ts +++ b/packages/cli/src/commands/profiles.ts @@ -1,9 +1,51 @@ /** - * `earsyntax profiles` — list the built-in profiles, rendered from profile data. - * Reimplemented by the host-native profiles agent; this is a compiling stub - * until then. + * `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`. */ -import { notImplemented } from './stub.js'; +import { type ProfileDiff, summarizeProfiles } from '@earsyntax/core'; +import type { CommandContext, CommandResult } from '../context.js'; +import { buildResponse } from '../response.js'; -export const profilesCommand = notImplemented('profiles'); +/** 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'); + return { response, pretty, exitCode: 0 }; +} diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index bdcc8c1..029e057 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -202,13 +202,48 @@ describe('runValidate — inputs and exit codes', () => { expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.no_files'); }); - it('stubs --sarif with exit 2 until the SARIF phase lands', () => { + 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(2); - expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('validate.sarif_pending'); + 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', () => { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index ba4f0f4..886e2fa 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -28,7 +28,7 @@ import { type PipelineNotice, runPipeline, } from '@earsyntax/extract'; -import { canonicalizeFindings, type Findings } from '@earsyntax/cli-contract'; +import { buildSarifLog, canonicalizeFindings, 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'; @@ -60,7 +60,7 @@ export interface ValidateInputs { profileName: string; /** `--strict`: upgrade surviving warnings to errors at the findings layer. */ strict: boolean; - /** `--sarif`: SARIF output (a later-phase seam; see {@link runValidate}). */ + /** `--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; @@ -73,6 +73,11 @@ 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. */ @@ -113,14 +118,6 @@ export function runValidate(inputs: ValidateInputs, deps: ValidateDeps = DEFAULT return usageResult('cli.conflicting_flags', 'The --json and --sarif flags are mutually exclusive.'); } - // `--sarif` is a later-phase seam. The existing cli-contract SARIF builder - // consumes the legacy ReportInput shape, not the Findings model, so it is not - // wired here; Agent W5-sarif replaces this branch with a Findings projection - // returned as `CommandResult.raw`. - if (inputs.sarif) { - return usageResult('validate.sarif_pending', 'SARIF output lands in a later phase.'); - } - const resolved = resolveProfile(inputs.profileName); if (!resolved.ok) { return usageResult('cli.unknown_profile', resolved.error.message); @@ -145,7 +142,7 @@ export function runValidate(inputs: ValidateInputs, deps: ValidateDeps = DEFAULT strict: inputs.strict, }); - return frame(canonicalizeFindings(findings), notices, profile); + return frame(canonicalizeFindings(findings), notices, profile, inputs.sarif); } /** Resolve the positional inputs into pipeline files, or an exit-2 result. */ @@ -247,8 +244,19 @@ function stdinKind(profile: Profile): DocumentKind { return 'text'; } -/** Frame a completed pipeline run into a response, pretty text, and exit code. */ -function frame(findings: Findings, notices: PipelineNotice[], profile: Profile): ValidateResult { +/** + * 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; @@ -265,7 +273,11 @@ function frame(findings: Findings, notices: PipelineNotice[], profile: Profile): { findings }, ); - return { response, pretty: prettyFindings(findings, diagnostics), exitCode }; + 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. */ diff --git a/packages/cli/src/renderers/agents.ts b/packages/cli/src/renderers/agents.ts new file mode 100644 index 0000000..1a4b2ca --- /dev/null +++ b/packages/cli/src/renderers/agents.ts @@ -0,0 +1,136 @@ +/** + * 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; + default: + return contribution; + } +} diff --git a/packages/cli/src/renderers/hosts.ts b/packages/cli/src/renderers/hosts.ts new file mode 100644 index 0000000..0e07b55 --- /dev/null +++ b/packages/cli/src/renderers/hosts.ts @@ -0,0 +1,132 @@ +/** + * 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: { + glob: '.kiro/specs/**/requirements.md', + command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', + }, + speckit: { + glob: 'specs/**/spec.md', + command: 'earsyntax validate "specs/**/spec.md" --profile speckit', + }, + openspec: { + glob: 'openspec/specs/**/*.md', + command: 'earsyntax validate "openspec/specs/**/*.md" --profile openspec', + }, +}; + +/** 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:', + ` - "${HOST_VALIDATE.kiro.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; + default: + 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/rules.ts b/packages/cli/src/rules.ts new file mode 100644 index 0000000..f6f5e7d --- /dev/null +++ b/packages/cli/src/rules.ts @@ -0,0 +1,283 @@ +/** + * 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. + */ +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; +} From 50ea5ab7a6e07b40f7bbdffbcaf4075702d9c9ae Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:06:59 +0300 Subject: [PATCH 14/20] fix(core): executable registry examples and host-native demo script --- docs/diagnostics.md | 6 +- packages/cli/src/commands/explain.test.ts | 122 ++++++----- packages/core/src/registry.ts | 30 ++- scripts/agentic-loop-demo.sh | 251 ++++++++++++++++++++++ 4 files changed, 350 insertions(+), 59 deletions(-) create mode 100755 scripts/agentic-loop-demo.sh diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 215108d..7893c48 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,7 +45,7 @@ These report defects in the outer EARS sentence shape. All are mode-dependent. ` | 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` | `The quick brown fox.` | +| `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.` | @@ -85,7 +85,7 @@ The `system` role is mode-dependent (an unknown or ambiguous system is a hard er | `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 `payment-http` entry no requirement mentions (coverage check). | +| `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 @@ -97,7 +97,7 @@ Stylistic findings that never affect validity. All are always warnings. | `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` | Guided-mode text that resembles no EARS shell. | +| `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 diff --git a/packages/cli/src/commands/explain.test.ts b/packages/cli/src/commands/explain.test.ts index a94e89a..dfbfdfb 100644 --- a/packages/cli/src/commands/explain.test.ts +++ b/packages/cli/src/commands/explain.test.ts @@ -11,12 +11,15 @@ * 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. Catalog - * diagnostics are executed with a minimal catalog derived from the example - * (the linter never emits catalog codes without a catalog). Three entries do - * not reproduce through `lintEars` as their example text is written; they are - * listed in {@link KNOWN_DIVERGENT} with the observed behavior and reported to - * main for arbitration rather than silently weakening the test. + * 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. @@ -166,6 +169,7 @@ const EXAMPLE_CONTEXT: Record = { 'EARS-E007': {}, 'EARS-E008': {}, 'EARS-E009': {}, + 'EARS-E010': {}, 'EARS-E011': {}, 'EARS-E012': {}, 'EARS-E013': {}, @@ -269,44 +273,44 @@ const EXAMPLE_CONTEXT: Record = { }; /** - * Registry entries whose documented example does not reproduce its own id - * through `lintEars` as written. These are surfaced to main for arbitration of - * the registry (an append-only file this agent does not edit), not worked around - * by weakening the assertion. Each guard asserts the observed behavior so the - * divergence stays explicit: if the registry example is later fixed, the guard - * fails and forces the entry off this list. + * 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 KNOWN_DIVERGENT: Record void }> = { - 'EARS-E010': { - reason: - 'badExample "The quick brown fox." parses as a system + response missing shall, so lintEars emits EARS-E007 (missing_shall), not EARS-E010. A true no_match needs a bare fragment such as "quick brown fox".', - guard: () => { - const codes = codesFor('The quick brown fox.'); - expect(codes).not.toContain('EARS-E010'); - expect(codes).toContain('EARS-E007'); +const COVERAGE_CONTEXT: Record = { + 'EARS-W007': { + badCatalog: { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-PAY', name: 'a payment webhook is received' }], }, - }, - 'EARS-W014': { - reason: - 'badExample "timer reset maybe when idle" emits EARS-E010 (no_match) under the default strict dialect; EARS-W014 (suspicious_text_shape) only appears under the removed `guided` mode, which the host-native facade no longer exposes.', - guard: () => { - const codes = codesFor('timer reset maybe when idle'); - expect(codes).not.toContain('EARS-W014'); - expect(codes).toContain('EARS-E010'); + goodCatalog: { + systems: [{ id: 'SYS-BILLING', name: 'billing service' }], + events: [{ id: 'EVT-PAY', name: 'a payment webhook is received' }], }, }, - 'EARS-W007': { +}; + +/** + * 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: - 'badExample is descriptive prose ("A payment-http entry that no requirement references."), not a requirement; catalog.term_unreferenced is emitted only by lintCatalogCoverage, never by lintEars, so it cannot be executed as a lintEars example.', + '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: () => { - // Not a requirement: lintEars does not surface the code either way. - expect(codesFor('A payment-http entry that no requirement references.')).not.toContain('EARS-W007'); - // Its real path does reach the code, proving the id is reachable. - const coverage = lintCatalogCoverage([{ text: 'The billing service shall retain the audit log.' }], { - systems: [{ id: 'SYS-BILLING', name: 'billing service' }], - events: [{ id: 'EVT-UNUSED', name: 'a payment webhook is received' }], - }); - expect(coverage.map((diagnostic) => idForCode(diagnostic.code))).toContain('EARS-W007'); + 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'); }, }, }; @@ -316,19 +320,26 @@ 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 an executable context or the divergent list', () => { + it('partitions every registry id into exactly one execution bucket', () => { for (const entry of DIAGNOSTIC_REGISTRY) { - const known = entry.id in EXAMPLE_CONTEXT || entry.id in KNOWN_DIVERGENT; - expect(known, `id ${entry.id} has no executable-example context`).toBe(true); - // No id is both executable and divergent. - expect(entry.id in EXAMPLE_CONTEXT && entry.id in KNOWN_DIVERGENT).toBe(false); + 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 KNOWN_DIVERGENT) { + if (!(entry.id in EXAMPLE_CONTEXT)) { continue; } const context = EXAMPLE_CONTEXT[entry.id]; @@ -339,12 +350,23 @@ describe('explain — the registry examples execute as documented', () => { } }); - it('documents the known-divergent entries with their observed behavior', () => { - // This list is visible and pending arbitration by main; it is not empty by - // accident. Each guard pins the current behavior so a later registry fix - // surfaces here. - expect(Object.keys(KNOWN_DIVERGENT).sort()).toEqual(['EARS-E010', 'EARS-W007', 'EARS-W014']); - for (const { guard } of Object.values(KNOWN_DIVERGENT)) { + 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(); } }); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 041b1d1..7319b59 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -64,6 +64,24 @@ const ERROR_PROFILE_NOTE = 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 @@ -188,7 +206,7 @@ const ENTRIES: DiagnosticRegistryEntry[] = [ 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: 'The quick brown fox.', + badExample: 'quick brown fox', goodExample: 'The system shall log the event.', profileNotes: ERROR_PROFILE_NOTE, }, @@ -350,9 +368,9 @@ const ENTRIES: DiagnosticRegistryEntry[] = [ 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: 'A payment-http entry that no requirement references.', - goodExample: 'When the payment-http call fails, the system shall retry once.', - profileNotes: WARNING_PROFILE_NOTE, + 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', @@ -432,12 +450,12 @@ const ENTRIES: DiagnosticRegistryEntry[] = [ oldCode: 'lint.suspicious_text_shape', title: 'Suspicious text shape', defaultSeverity: 'warning', - meaning: 'The sentence shape is likely accidental or malformed.', + 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: WARNING_PROFILE_NOTE, + profileNotes: GUIDED_ONLY_PROFILE_NOTE, }, { id: 'EARS-W015', 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" From 3b241d21d9bc9f0d4f76b3338d0526a4ff2abf48 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:11:04 +0300 Subject: [PATCH 15/20] test: add conformance target proving the alpha facade --- package.json | 1 + test/conformance/demo.test.ts | 33 ++++ test/conformance/explain-coverage.test.ts | 43 +++++ test/conformance/helpers.ts | 67 +++++++ test/conformance/profile-matrix.test.ts | 52 +++++ test/conformance/smoke.test.ts | 222 ++++++++++++++++++++++ test/conformance/tsconfig.json | 12 ++ vitest.conformance.config.ts | 22 +++ 8 files changed, 452 insertions(+) create mode 100644 test/conformance/demo.test.ts create mode 100644 test/conformance/explain-coverage.test.ts create mode 100644 test/conformance/helpers.ts create mode 100644 test/conformance/profile-matrix.test.ts create mode 100644 test/conformance/smoke.test.ts create mode 100644 test/conformance/tsconfig.json create mode 100644 vitest.conformance.config.ts diff --git a/package.json b/package.json index 591807c..b41c138 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "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", 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..ff814dd --- /dev/null +++ b/test/conformance/helpers.ts @@ -0,0 +1,67 @@ +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..228d9d8 --- /dev/null +++ b/test/conformance/smoke.test.ts @@ -0,0 +1,222 @@ +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/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, + }, +}); From b834a976e6f36003bd1f5473bf3886d0ca96d09e Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:16:55 +0300 Subject: [PATCH 16/20] docs: rewrite for the host-native CLI Guest-mode quick start, full command reference with executed examples, agent rules mirroring the instructions payload, host-native agentic loop, pipeline and profile documentation, public API surface. --- README.md | 390 +++++++++++++++++------ docs/agent-rules.md | 379 ++++++++++------------ docs/agentic-loop.md | 245 ++++++++------- docs/api.md | 72 ++++- docs/authoring-ears.md | 183 +++++++---- docs/cli.md | 697 ++++++++++++++++++++++++++++++----------- docs/facade-api.md | 487 ++-------------------------- docs/input-formats.md | 83 ++++- docs/public-api.md | 276 ++++++++-------- docs/quickstart.md | 245 ++++++++------- 10 files changed, 1689 insertions(+), 1368 deletions(-) diff --git a/README.md b/README.md index 78ad78c..d425257 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,348 @@ # earsyntax -A deterministic parser, linter, extractor, and CLI for EARS requirements in Node.js and TypeScript. +Deterministic EARS validation for requirements inside Kiro, Spec Kit, +OpenSpec, and plain requirement files. -Give it a requirement written in the Easy Approach to Requirements Syntax: +```bash +npx @earsyntax/cli validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +Nothing to install and no setup: `npx` fetches the CLI, and `validate` runs in +guest mode against the files already in the repo. A run reports each finding, then +a summary line (numbers illustrative): ```text -When a payment webhook is received, the billing service shall verify the HMAC signature. +12/12 valid across 3 file(s), 0 error(s), 1 warning(s) ``` -Lint it in code: +`earsyntax` is a host-native facade for the Easy Approach to Requirements +Syntax. It locates EARS-shaped requirements in the specification files teams +already use, extracts the candidate lines, parses them deterministically, and +reports stable diagnostics for humans, CI, and coding agents. -```ts -import { lintEars } from '@earsyntax/core'; +This README describes the host-native alpha facade being implemented on this +branch. The public command surface is intentionally narrow and does not include +an `earsyntax` project workspace. -const result = lintEars( - 'When a payment webhook is received, the billing service shall verify the HMAC signature.', -); +## What It Does + +`earsyntax` runs a deterministic pipeline: + +```text +locate host document sections +extract candidate requirements with source positions +parse EARS syntax +lint requirement quality rules +emit findings for terminal, JSON, or SARIF +``` + +It does not call an LLM. Coding agents call `earsyntax`; `earsyntax` never +calls coding agents. + +Use it to: + +- validate EARS requirements embedded in existing SDD documents +- inspect which lines a host profile extracts +- explain diagnostics with examples and rationale +- install thin agent wrappers for Claude Code, Codex, Cursor, Copilot, Gemini, + or generic agent files +- emit CI findings as JSON or SARIF + +## Supported Profiles + +Profiles define both syntax dialect and host-document extraction rules. + +| Profile | Use it for | What it reads | +| ---------- | -------------------------- | ------------------------------------------------------- | +| `strict` | Canonical EARS validation | `.ears`, plain EARS text, stdin | +| `ears-x` | earsyntax extensions | strict plus frame metadata, prohibitions, REQ-id format | +| `kiro` | Kiro specs | `.kiro/specs/**/requirements.md` | +| `speckit` | Spec Kit specs | `specs/**/spec.md` | +| `openspec` | OpenSpec specs and changes | `openspec/specs/**`, `openspec/changes/**` | + +Run `profiles` to see exactly what each profile locates, relaxes, adds, and +changes by severity: + +```bash +npx @earsyntax/cli profiles +``` + +## Install + +Use the CLI without installing it globally: + +```bash +npx @earsyntax/cli validate requirements.ears --profile strict ``` -`result` is a structured object: the classified pattern, the parsed AST, catalog references, and a sorted list of diagnostics. +Or install it in a repository: + +```bash +npm install --save-dev @earsyntax/cli +``` + +Node.js 22 or newer is required. + +## Validate Existing Specs + +Kiro: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +``` + +Spec Kit: + +```bash +earsyntax validate "specs/**/spec.md" --profile speckit +``` + +OpenSpec: + +```bash +earsyntax validate "openspec/{specs,changes}/**/*.md" --profile openspec +``` + +Plain EARS: + +```bash +earsyntax validate requirements.ears --profile strict +``` + +Stdin: + +```bash +printf 'When a payment webhook arrives, the billing service shall verify the signature.\n' \ + | earsyntax validate - --profile strict +``` + +Exit codes: + +| Code | Meaning | +| ---- | ----------------------------------------------------------------------- | +| `0` | command succeeded, and validation found no error diagnostics | +| `1` | validation completed and found error diagnostics | +| `2` | usage or environment failure, such as a missing file or unknown profile | + +## Inspect Extraction + +Use `extract` when a Markdown host file does not validate the way you expect. +It prints the candidate requirements the active profile found, with source +positions and locator rule IDs. + +```bash +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +Example candidate: ```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 } - } - } + "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" } ``` -Or lint a file from the command line. Validation exits `0` when every requirement is clean and `1` when any error diagnostic remains, so it works as a CI gate: +## Repair With Claude Code + +Install host and agent integration files: ```bash -npx @earsyntax/cli validate requirements.ears +earsyntax init --agent claude --host kiro ``` -```text +Then ask Claude Code to repair a host spec file: -3/3 valid, 0 errors, 0 warnings +```bash +claude -p "/earsyntax-repair .kiro/specs/checkout/requirements.md --profile kiro" ``` -## What EARS is +The wrapper tells Claude Code to: -EARS, the Easy Approach to Requirements Syntax, is a small set of sentence templates for writing requirements that are unambiguous and testable. Each requirement names a system and one observable response, optionally guarded by a state, an event, an optional feature, or an unwanted condition. The patterns are documented at [alistairmavin.com/ears](https://alistairmavin.com/ears/). +1. run `earsyntax instructions repair --file --profile --json` +2. follow the returned rules exactly +3. edit only the host spec file +4. run `earsyntax validate --profile --json` +5. repeat until validation is clean +6. report unresolved ambiguity for human review -This toolkit parses those templates and reports where a requirement departs from them. It is deterministic: for a given input it always returns the same result, with no LLM, no network, no file system access in the parser, and no fuzzy matching. The parser validates `.ears` grammar; it does not infer product intent from prose. Turning natural-language specifications into `.ears` is a separate job done by a coding agent through the CLI facade, and validated by the same parser. +The agent does not approve, accept, or merge anything. Human review stays in +the host workflow: pull request review, Kiro review, Spec Kit review, or +OpenSpec change review. -## Packages +## How Init Works -| Package | Purpose | -| ------------------------- | ----------------------------------------------------------------------------------------------- | -| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. No runtime dependencies. | -| `@earsyntax/extract` | Extract EARS requirements from `.ears`, Markdown, YAML, and JSON files. Depends on core types. | -| `@earsyntax/cli-contract` | Shared report output contracts: the JSON report, SARIF 2.1.0, the pretty model, and exit codes. | -| `@earsyntax/cli` | The `earsyntax` command-line facade for humans, scripts, and coding agents. | - -## Install +`init` installs integration files. It does not create an `earsyntax` project. ```bash -npm install @earsyntax/core -# or the extractor and CLI -npm install @earsyntax/extract -npm install --global @earsyntax/cli +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 ``` -The CLI also runs without a global install through `npx @earsyntax/cli `. Node 22 or newer is required. +`init` is idempotent. Files that may already exist use managed begin and end +markers, so rerunning the same command should produce no diff. -## Usage +Supported agents: -Lint one requirement or a batch in code with `@earsyntax/core`: +| Agent | Integration | +| --------- | ------------------------------------- | +| `claude` | `.claude/commands/earsyntax-*.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 | -```ts -import { lintEars, lintEarsBatch } from '@earsyntax/core'; +Supported hosts: -const one = lintEars('The billing service shall verify the HMAC signature.'); +| Host | Integration | +| ---------- | --------------------------------------------------- | +| `kiro` | Kiro steering and validation hook files | +| `speckit` | Spec Kit command or instruction files | +| `openspec` | OpenSpec agent instructions and validation commands | -const many = 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.', - }, -]); +## CI + +Use `validate` as the gate: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro ``` -Validate files from the command line. `validate` reads `.ears`, Markdown, YAML, and JSON: +Emit SARIF for code-scanning integrations: ```bash -npx @earsyntax/cli validate "specs/**/*.ears" -npx @earsyntax/cli validate requirements.md --json -npx @earsyntax/cli validate requirements.ears --mode guided +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif ``` -The API reference is in [docs/api.md](docs/api.md), the full command reference in [docs/cli.md](docs/cli.md), the diagnostics registry in [docs/diagnostics.md](docs/diagnostics.md), and a five-minute walkthrough in [docs/quickstart.md](docs/quickstart.md). To learn how to write EARS by hand, see [docs/authoring-ears.md](docs/authoring-ears.md). +Upload the SARIF to GitHub code scanning so findings surface on the pull request. +`validate` exits `1` when it finds an error, so gate the job on it and still +upload the report: + +```yaml +name: earsyntax +on: [pull_request] +jobs: + ears: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Validate EARS requirements + run: npx @earsyntax/cli validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: earsyntax.sarif +``` + +Use JSON when another tool consumes the findings: + +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --json +``` + +## CLI Reference + +| Command | Purpose | +| -------------------------------------- | ------------------------------------------------------- | +| `validate ` | Locate, extract, parse, lint, and report findings | +| `extract ` | Show requirement candidates found by the active profile | +| `instructions --file ` | Return rules for one agent loop step | +| `explain ` | Explain one diagnostic with examples | +| `profiles` | List built-in profiles and their exact behavior | +| `doctor` | Detect host and agent setup and suggest commands | +| `init --agent --host ` | Install host and agent integration files | +| `version --features` | Print package and facade capabilities | + +Eight commands, and the surface is closed. See the [CLI reference](docs/cli.md) +for flags, exit codes, and the JSON envelope per command. + +Instruction modes: + +| Mode | Agent job | +| --------- | ----------------------------------------------------------------------- | +| `author` | Write new EARS requirements into a host document's requirements region | +| `convert` | Rewrite natural-language requirements in a host file into EARS in place | +| `repair` | Fix the findings returned by `validate` | +| `review` | Summarize the located requirements for a human; never approve or accept | + +## Library API + +`@earsyntax/core` exposes the parser, linter, profile runtime, diagnostic +registry, and findings model for TypeScript users. + +```ts +import { lintEars } from '@earsyntax/core'; + +const result = lintEars( + 'When a payment webhook arrives, the billing service shall verify the signature.', +); + +console.log(result.valid); +console.log(result.pattern); +console.log(result.diagnostics); +``` + +For batch or host-document workflows, prefer the CLI unless you need direct +embedding inside another tool. + +## EARS In One Minute + +EARS, the Easy Approach to Requirements Syntax, is a small set of templates +for requirements. A requirement names a system and the response it shall +perform, optionally guarded by a state, event, optional feature, unwanted +condition, or a combination of those clauses. + +Examples: + +```text +The billing service shall verify the HMAC signature of every incoming webhook. +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. +``` -## The agentic loop in brief +`strict` keeps to canonical EARS. Host profiles are explicit adapters: every +relaxation or extension must be named by the profile and covered by fixtures. -A coding agent can convert a natural-language specification into a validated `.ears` file through the CLI facade. The CLI resolves paths and validates; the agent reads the source and writes requirements; a human accepts the result. The loop keeps those three responsibilities separate. See [docs/agentic-loop.md](docs/agentic-loop.md) for the state machine. +## Status And Limits -1. `earsyntax new --source --mode convert` scaffolds a work item and hashes the source. -2. `earsyntax instructions convert --work ` returns the rules the agent follows for that step. -3. The agent reads the source and writes `.ears` requirements, one per line, with stable IDs. -4. `earsyntax validate --work ` checks the file and records the result on the work item. -5. `earsyntax instructions repair --work ` returns targeted fixes when validation reports diagnostics; the agent repairs and re-validates until clean. -6. `earsyntax status ` confirms the state and the next steps. -7. A human reviews and runs `earsyntax accept `. The CLI never accepts on the agent's behalf. +The host-native CLI is the alpha target for this branch. -## Limitations +Boundaries: -v0.1 is a deterministic parser, linter, and facade. It is intentionally narrow. +- deterministic parsing and linting only +- no semantic contradiction checking +- no natural-language intent inference in the CLI +- no LLM calls from core or CLI +- no `earsyntax` workspace lifecycle +- no CLI acceptance command +- no hidden host-specific behavior outside profiles -- No fuzzy or semantic catalog matching. Terms match by exact canonical name, then exact alias, then ambiguous, then unresolved. -- No LLM calls in `@earsyntax/core`. The parser and linter are pure and offline. -- Semantic derivation is not done by the CLI. Coding agents translate prose into `.ears`; the CLI only scaffolds, returns instructions, and validates. A human accepts the result; validation success and acceptance are two separate events. -- SARIF output is not exposed by the CLI yet. The SARIF 2.1.0 builder ships in `@earsyntax/cli-contract`, but `validate` emits pretty and JSON only. `version --features` reports `"sarif": false`. -- The `export` command is planned, not implemented in v0.1. +The maintained design briefs in this repository are: -The full set of non-goals and the approved differences from the `ears-lint-go` reference are recorded in [docs/compatibility.md](docs/compatibility.md). +- [EARSYNTAX-CLI-FACADE-ALPHA-0.md](EARSYNTAX-CLI-FACADE-ALPHA-0.md) +- [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) diff --git a/docs/agent-rules.md b/docs/agent-rules.md index ab9d776..d79140c 100644 --- a/docs/agent-rules.md +++ b/docs/agent-rules.md @@ -1,237 +1,202 @@ -# Spec-to-EARS agent rules +# Agent rules -This document is the canonical instruction body for coding agents that turn source specifications into `.ears` files. The `earsyntax instructions author|convert|repair|review --json` command returns these rules; the CLI JSON is the source of truth at runtime, and this file is the human-readable version of the same content. +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. -Per-agent wrappers (a Claude Code command, a Cursor rule, an `AGENTS.md` section, a Kiro steering file) are thin pointers to the CLI commands. They must not fork or paraphrase these rules; they call `earsyntax instructions` and follow whatever it returns, so there is one place to change the rules. +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. -The rules split cleanly by step: read the source, write only requirements, classify, split compounds, do not invent, raise questions, preserve traceability, repair from diagnostics, and review before accept. +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. -## Read the source first +## What each mode tells you to do -Before writing any `.ears`, read the full source artifact. Sources include a Spec Kit `spec.md`, an OpenSpec `proposal.md` or delta spec, a Kiro `requirements.md`, a PRD, an issue description, a design brief, or any user-supplied Markdown, YAML, JSON, or plain text. +The `instructions` response opens with mode-specific rules. -While reading, identify: +### `author` -- actors and users -- systems or components under requirement -- events that trigger behavior -- states during which behavior applies -- optional features or feature flags that gate behavior -- unwanted or error conditions -- observable system responses -- constraints that are genuinely requirements -- ambiguous statements that need human clarification +Write new EARS requirements into a host file that has none yet, or add to the ones +it has. -In `convert` mode the `instructions` response includes source excerpts with line numbers. Read the whole file anyway; the excerpts are a starting point, not the full source. +- 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. -## Write only requirements +### `convert` -The `.ears` file contains one requirement per non-empty line and nothing else. No headings, no explanatory paragraphs, no prose. Explanation belongs in `traceability.json` and open items belong in `questions.md`. +Rewrite natural-language requirements already present in the host file. -Line format: +- 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. -```text -REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature. -``` +### `repair` -Allowed metadata prefixes: +Fix the findings a `validate` run reported. -```text -REQ-001: -REQ-001 [source: path:line]: -REQ-001 [source: path:line-line]: -``` +- 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. -Give every requirement a stable ID such as `REQ-001`. Include a `[source: path:line]` reference whenever the source line is known; in `author` mode, where there is no source, use the bare `REQ-001:` prefix. The parser accepts bare requirement lines without any prefix, but agent-generated files should carry IDs and, in convert mode, source references. +### `review` -## Classify each requirement +Summarize the located requirements for a human. This mode never changes the file. -Use the narrowest EARS pattern that fits the source behavior. Do not force everything into `When`. +- 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. -| Source shape | EARS pattern | Template | -| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | -| Always-true system behavior | Ubiquitous | `The shall .` | -| Triggered by an event | Event-driven | `When , the shall .` | -| Applies only during a state | State-driven | `While , the shall .` | -| Applies only when a feature exists or is enabled | Optional feature | `Where , the shall .` | -| Handles an error, invalid input, threat, or unwanted condition | Unwanted behaviour | `If , then the shall .` | -| Requires more than one shell clause | Complex | `While ..., when ..., the shall .` | +## Reading a `--from` source -Guidance: +`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: -- If behavior applies during maintenance mode, use `While`, not `When`. -- If behavior applies only with a feature enabled (for example enterprise SSO), use `Where`. -- If the source describes an invalid or exceptional condition, use `If ..., then ...`. -- Accepted clause order for complex requirements is `While* -> Where* -> When* -> If* -> the shall `. +- 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. -## Split compound source text +The CLI never reads or transforms the source itself. Reading and understanding it +is the agent's job. -Split a compound source requirement into separate EARS requirements when the response holds more than one observable obligation. +## Choosing a pattern -Source: +The writing modes (`author` and `convert`) share the EARS authoring guidance +below. Start by choosing the narrowest pattern that fits the behavior. -```text -When a webhook arrives, validate the signature, persist the event, and enqueue a job. -``` +- 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. -Generated: +| 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 `. | -```text -REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. -REQ-004 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. -REQ-005 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. -``` +## One obligation per requirement -Do not split when the second phrase only qualifies the first response (for example "reject the webhook with a 400 status" is one obligation, not two). When you split, record all resulting IDs against the same source line in `traceability.json`. +- 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. -## Do not invent behavior +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 add behavior because it seems reasonable, common, secure, scalable, or useful. If the source does not state the behavior, the `.ears` file must not contain it. +## Do not invent behavior -Forbidden inventions: - -- adding audit logging because security is mentioned -- adding retry behavior because webhooks are involved -- adding rate limits because the endpoint is public -- adding admin permissions because a dashboard is mentioned -- adding database persistence because a UI displays data - -If missing behavior matters, write a question instead. - -## Put ambiguity in questions - -When source text is vague, conflicting, or underspecified, write a question to `questions.md` and record it in `traceability.json`. Do not invent a precise requirement to fill the gap. - -Question format in `questions.md`: - -```md -- [REQ?] Source `specs/checkout.md:19` says "notify the customer quickly." What channel should be used, and what is the time bound? -``` - -Ambiguity triggers: - -- vague terms such as `fast`, `quickly`, `appropriate`, `user-friendly`, `robust`, `as needed` -- a missing actor or system -- a missing trigger -- a missing error response -- conflicting source statements -- an implementation detail with no observable behavior -- behavior that depends on product judgment - -A question that blocks a requirement lists that requirement id under `blocksRequirements` in `traceability.json`. Blocking questions must be resolved by a human before acceptance. - -## Preserve traceability - -Maintain `traceability.json` for every generated requirement and question. It gives the human reviewer a way to judge whether the source was translated faithfully; it is not a substitute for validation. - -```json -{ - "requirements": [ - { - "id": "REQ-001", - "source": { - "path": "specs/checkout.md", - "startLine": 7, - "endLine": 8, - "quote": "When a payment webhook is received, the billing service must verify the HMAC signature..." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": [] - } - ], - "questions": [ - { - "source": { "path": "specs/checkout.md", "startLine": 19 }, - "question": "What notification channel should be used?", - "blocksRequirements": ["REQ-008"] - } - ] -} -``` - -Rules: - -- Every requirement id in the `.ears` file has a matching entry in `traceability.json`. -- Set `confidence` to `high`, `medium`, or `low`. Use `medium` or `low` for a translation the human should double-check (for example a source that used "should" rather than a firm obligation), and call those out in the review summary. -- In `author` mode leave the `source` object off requirement entries; there is no source line to cite. - -## Repair from diagnostics - -When `earsyntax validate --json` reports diagnostics, repair the `.ears` file by changing only what the diagnostics justify. The `instructions repair` response includes the exact diagnostics to address. - -General repair rules: - -- Preserve requirement IDs unless a duplicate-ID diagnostic requires a change. -- Preserve `[source: path:line]` references. -- Preserve the intended behavior. -- Do not delete a failing requirement to make validation pass. -- Do not weaken a requirement because its wording is harder to parse. -- If the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing. -- Re-run `validate` after each repair pass and repeat until no error-severity diagnostics remain. - -The table below maps every diagnostic code in the registry (see `docs/diagnostics.md`) to what to change. Error-severity codes must be fixed to reach a `valid` state; warning-severity codes do not block validity but the review summary reports them. - -### `ears.*` shell structure - -| Code | What to change | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ears.no_match` | The line is not an EARS requirement. Rewrite it into one of the allowed templates, or move the text out of the `.ears` file. | -| `ears.invalid_clause_order` | Reorder shell clauses to `While -> Where -> When -> If -> the shall `. | -| `ears.missing_system` | Insert the system name before `shall`: `the shall ...`. | -| `ears.missing_shall` | Add a single `shall` response boundary. A requirement must state one obligation with `shall`. | -| `ears.multiple_shall` | Split into separate requirements, one `shall` each, following the compound-splitting rule. | -| `ears.invalid_if_then_form` | Add the missing `then`: `If , then the shall .` | -| `ears.empty_clause` | Fill the empty `While`/`Where`/`When`/`If` clause body, or remove the clause if it was accidental. | -| `ears.empty_response` | Add the response after `shall`, or raise a question if the source does not state one. | - -### `expr.*` clause expressions - -| Code | What to change | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `expr.unbalanced_parentheses` | Balance the parentheses in the clause expression. | -| `expr.invalid_operator_sequence` | Fix the malformed operator run (for example `A or or B`, a trailing `and`, a leading `or`). | -| `expr.empty_subexpression` | Remove the empty group or supply the missing operand (for example `A and ()`). | -| `expr.operator_precedence_warning` | Warning. Add parentheses to a mixed `and`/`or` expression to make grouping explicit if intent is unclear. | -| `expr.unknown_term` | Warning. Align the clause term with a catalog entry, or add the term to the catalog if it is correct. | -| `expr.ambiguous_term` | Warning. Disambiguate the term so it matches one catalog entry, or use the canonical name. | -| `expr.mixed_unresolved_terms` | Warning. One clause mixes resolved and unresolved terms; align the unresolved term with the catalog. | - -### `catalog.*` term matching - -| Code | What to change | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `catalog.system_unresolved` | Error in strict mode. Use the catalog's canonical system name, or confirm the system with the catalog owner. | -| `catalog.system_ambiguous` | Error in strict mode. Use the specific canonical system name so it matches exactly one entry. | -| `catalog.state_unresolved` | Warning. Use the canonical state name, or add the state to the catalog if it is correct. | -| `catalog.state_ambiguous` | Warning. Use the specific canonical state name. | -| `catalog.event_unresolved` | Warning. Use the canonical event name, or add the event to the catalog if it is correct. | -| `catalog.event_ambiguous` | Warning. Use the specific canonical event name. | -| `catalog.feature_unresolved` | Warning. Use the canonical feature name, or add the feature to the catalog if it is correct. | -| `catalog.feature_ambiguous` | Warning. Use the specific canonical feature name. | -| `catalog.term_unreferenced` | Warning (coverage). A cataloged term is never referenced. Add a requirement that uses it if one is missing, or note the gap; do not invent behavior to satisfy coverage. | - -### `lint.*` style advice - -| Code | What to change | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `lint.multiple_responses` | Warning. The response holds several semicolon-joined responses. Split into separate requirements. | -| `lint.vague_response` | Warning. Replace the vague term with an observable, bounded response, or raise a question if the bound is unknown. | -| `lint.unparsed_tail` | Warning. Text remains after the parsed requirement. Move it into the requirement or remove it. | -| `lint.alias_used` | Warning. A catalog alias matched. Prefer the canonical name. | -| `lint.suspicious_text_shape` | Warning. The sentence shape looks accidental. Rewrite it into a clean EARS template. | - -## Review before accept - -Before `earsyntax accept`, produce a review summary. Acceptance is a human decision; the agent presents evidence and may recommend, but never accepts. - -The summary states: - -- how many requirements were generated -- the distribution across the six EARS patterns -- every unresolved question and the requirements each one blocks -- the validation status and the command that produced it -- source lines translated with medium or low confidence -- any source behavior intentionally not converted, with the reason - -Recommend acceptance only when validation is clean (no error diagnostics) and no blocking questions remain. If either condition fails, say so and point at the next step (repair, or a human answer to a question) instead of recommending acceptance. +- 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 index 0c91465..2e1f32a 100644 --- a/docs/agentic-loop.md +++ b/docs/agentic-loop.md @@ -1,152 +1,179 @@ # Agentic loop -This document describes how any coding agent derives `.ears` files from source specifications using the `earsyntax` facade. The loop is tool-agnostic: it works with Claude Code, Cursor, Codex, Kiro, or a plain shell script, because the durable contract is files plus CLI JSON, not any one agent's command names. +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 | Resolve paths, scaffold work directories, return instructions, validate `.ears`, report status, record acceptance, detect stale sources. | -| Coding agent | Read the source, decide which behaviors are requirements, write and repair `.ears`, and raise questions when the source is unclear. | -| Human | Approve behavior, answer questions, and accept or reject the generated `.ears`. | +| 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 the source semantically. The agent never accepts its own work. Validation success and human acceptance are two different events. +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 new -> scaffold a work item (choose author or convert mode) -earsyntax instructions -> get the rules for the current step - (agent writes or repairs the .ears file) -earsyntax validate -> check the .ears file, deterministically - (repeat instructions repair + validate until clean) -earsyntax status -> confirm state and next steps -earsyntax instructions review -> build a human review summary - (human reviews) -earsyntax accept -> record human acceptance +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 can walk the loop by following `next[].command` without hard-coding the sequence. Actions with `forAgent: true` are safe for the agent to run; actions with `blocking: true` require a human. +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. -## Work-item state machine +## The four modes -A work item moves through these states. The state lives in `manifest.json` as `status`. +Each `instructions` mode covers one kind of step against a host file. Pick the +mode that matches the task. -```text -missing -> scaffolded -> drafted -> invalid -> valid -> accepted - ^ | | | - | v | | - +---------- (repair) | | - v v - stale <----- stale -``` - -### States - -| State | Meaning | -| ------------ | ----------------------------------------------------------------------------------------------------------------- | -| `missing` | No manifest exists for the queried id. Reported by `status`; never stored. | -| `scaffolded` | `earsyntax new` created the directory and empty artifacts. No requirements written yet. | -| `drafted` | The `.ears` file has content but has not passed a clean validation. | -| `invalid` | The last `validate` produced at least one error-severity diagnostic. | -| `valid` | The last `validate` produced no error diagnostics. Eligible for review and acceptance. | -| `accepted` | A human accepted the `.ears` file. `accepted` metadata and hashes are recorded. | -| `stale` | The source hash changed after the last `valid` or `accepted` state. The `.ears` may no longer reflect the source. | - -### Transitions +| 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 | -Each transition names the command or event that triggers it. +`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). -| From | To | Trigger | -| ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `missing` | `scaffolded` | `earsyntax new ` creates the manifest and artifact files. | -| `scaffolded` | `drafted` | The next `validate` observes a non-empty `.ears` output. (The agent writing the file is not a CLI event; the state advances when the CLI next reads it.) | -| `drafted` | `invalid` | `earsyntax validate` produces at least one error-severity diagnostic. | -| `drafted` | `valid` | `earsyntax validate` produces no error-severity diagnostic. | -| `invalid` | `invalid` | `earsyntax validate` still finds error diagnostics after a repair pass. | -| `invalid` | `valid` | `earsyntax validate` finds no error diagnostics after a repair pass. | -| `valid` | `invalid` | A later `validate` (for example after an edit) finds error diagnostics again. | -| `valid` | `accepted` | `earsyntax accept ` after a human approves. Refused unless status is `valid` and the source is not stale. | -| `valid` | `stale` | The source content hash no longer matches the hash recorded at the `valid` transition. | -| `accepted` | `stale` | The source content hash no longer matches `accepted.sourceHash`. | -| `stale` | `drafted` | Re-running the loop (`instructions convert`, rewrite, `validate`) against the changed source. | +## The repair loop, step by step -[DECIDED] `drafted` is CLI-observed, not agent-signaled. The CLI has no hook that fires when the agent writes the file, so `scaffolded` advances to `drafted`, `valid`, or `invalid` at the next `validate`. `status` reports `scaffolded` until then. Rationale: the CLI only changes state on its own commands; it does not watch the filesystem. +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. -### Staleness +### 1. Validate and read the findings -`stale` is computed, not stored as a terminal decision. On every `status`, `doctor`, and work-item `validate`, the CLI hashes the current source content and compares it to the hash recorded at the last `valid` or `accepted` transition: - -- If the item is `valid` and the source hash differs from the valid-time hash, the reported status is `stale`. -- If the item is `accepted` and the source hash differs from `accepted.sourceHash`, the reported status is `stale`. -- A `stale` item is not silently repaired. The agent re-enters the loop against the new source; a human accepts again. +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --json +``` -Staleness does not block `validate` from linting the `.ears` file, but it does block `accept`, which refuses stale work with exit `3`. +```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 + } + ] +} +``` -## Mode one: convert from an existing source +The `next` action tells the agent exactly which command to run. -Use `convert` when a Markdown spec, PRD, issue, or requirements document already exists. +### 2. Get the repair rules ```bash -# 1. Scaffold. Hashes the source, reserves the output path. -earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json -# -> status: scaffolded - -# 2. Get the convert rules and source excerpts. -earsyntax instructions convert --work checkout-webhooks --json - -# 3. The agent reads specs/checkout.md in full, then writes: -# .earsyntax/work/checkout-webhooks/requirements.ears -# and maintains questions.md and traceability.json. +earsyntax instructions repair --file ".kiro/specs/checkout/requirements.md" --profile kiro --json +``` -# 4. Validate. -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json -# -> status: invalid or valid +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" +} +``` -# 5. If invalid, get targeted repair rules, fix, and re-validate. -earsyntax instructions repair --work checkout-webhooks --json -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json -# repeat until no error diagnostics -> status: valid +### 3. Edit the host file in place -# 6. Confirm state. -earsyntax status checkout-webhooks --json +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. -# 7. Build the human review summary. -earsyntax instructions review --work checkout-webhooks --json +### 4. Re-validate until clean -# 8. Human gate. A person reviews and, if satisfied, accepts. -earsyntax accept checkout-webhooks --by "omer" -# -> status: accepted +```bash +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro ``` -## Mode two: author from a prompt +```text +3/3 valid across 1 file(s), 0 error(s), 0 warning(s) +``` -Use `author` when there is no source document, only a brief or prompt. The loop is identical except that step 1 supplies a prompt and step 2 requests author rules. There is no `source` block in the instructions and no source excerpts; traceability records requirements without source lines. +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. -```bash -earsyntax new checkout-webhooks \ - --prompt "Checkout webhooks must validate signatures and record successful payments." \ - --mode author --json -# -> status: scaffolded +## Authoring and converting -earsyntax instructions author --work checkout-webhooks --json -# agent writes requirements.ears from the prompt only +`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. -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --json -# repair loop, status, review, accept as in convert mode +```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 ``` -Because there is no source file, an author-mode item never becomes `stale` from source drift. It can still move `valid -> invalid` if the `.ears` file is edited and re-validated. - -## Human gates - -Two points in the loop require a human, and the CLI enforces both. +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). -1. Answering questions. When the agent writes questions to `questions.md`, those questions block the requirements listed in `traceability.json` under `blocksRequirements`. The review summary surfaces them. A human answers before acceptance. -2. Acceptance. `earsyntax accept` refuses unless the status is `valid`, the source is not stale, and the `.ears` file is unchanged since validation. The agent may recommend acceptance, but only a human runs it. Every `next` action that leads to acceptance is marked `blocking: true` and never `forAgent: true`. +## What the loop does not do -The CLI never accepts on the agent's behalf and never edits the source spec. +- 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 diagnostics. The agent must not force convergence by deleting failing requirements or weakening wording; if the intended behavior is unclear, it writes a question and leaves a clear placeholder. The rules for this are in `docs/agent-rules.md`. Warnings (for example `lint.vague_response`) do not block a `valid` state, but the review summary reports them so the human can decide whether they matter. +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 index 82bd104..dac0f6f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -283,13 +283,47 @@ const { items, errors } = extractFromContent(md, 'requirements.md'); The heading is ignored, the first bullet's `REQ-001:` prefix becomes the item `id`, and the second bullet keeps its text with no id. Pass the `items` to `lintEarsBatch` to lint them. `extractFromFile` reads from disk instead of a string and is the only function in the package that touches the file system. +### Host-native pipeline + +The `extractFromContent`/`extractEars`/`extractMarkdown`/`extractYaml`/`extractJson` functions above are the non-profile surface: they read every list item, table row, and structured entry. The host-native pipeline is profile-driven, so 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` defines the shared report output shapes so external tools can consume linting results without depending on the CLI. It is pure data and serializers: no I/O, no argument parsing. You collect one item per requirement, group them by file, and pass the array to a builder. +`@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 two shapes: +a `ReportInput` (files of `{ input, result }` items) for terminal rendering, and a +`Findings` object (from `@earsyntax/core` or `@earsyntax/extract`) for JSON, SARIF, +and the exit code. + +### Terminal model from a `ReportInput` + +Collect one item per requirement, group them by file, and pass the array to +`buildPrettyModel`: ```ts import { lintEars } from '@earsyntax/core'; -import { buildJsonReport, exitCodeForReport } from '@earsyntax/cli-contract'; +import { buildPrettyModel } from '@earsyntax/cli-contract'; const inputs = [ { @@ -304,16 +338,42 @@ const inputs = [ }, ]; -const report = buildJsonReport([ +const model = buildPrettyModel([ { path: 'requirements.ears', items: inputs.map((input) => ({ input, result: lintEars(input.text) })), }, ]); -exitCodeForReport(report); // 1, because REQ-002 has an error diagnostic +// model.summary === { files: 1, requirements: 2, errors: 1, warnings: 0, infos: 0, valid: false } ``` -`report.summary` is `{ files: 1, requirements: 2, errors: 1, warnings: 0, infos: 0, valid: false }`, and `report.files[0].requirements` holds one entry per requirement with its `id`, `line`, `text`, `valid`, `pattern`, and `diagnostics`. The package also exports `buildSarifLog` for SARIF 2.1.0 and `buildPrettyModel` for terminal rendering. `serializeJsonReport` produces a byte-stable string for the same input. +### 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 +``` -Note: the CLI does not expose SARIF output yet. The builder is available here, but `earsyntax validate` emits pretty and JSON only. +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`, the `EXIT_OK`/`EXIT_LINT_ERRORS`/`EXIT_USAGE` +constants, and `DIAGNOSTIC_CODES`/`DIAGNOSTIC_DESCRIPTIONS`. 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 index 05c784b..31c95da 100644 --- a/docs/authoring-ears.md +++ b/docs/authoring-ears.md @@ -1,18 +1,31 @@ # 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, catalog matching, 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 example below was validated with the CLI. +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. -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 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`. +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. Keywords are case-insensitive, so `When`, `when`, and `WHEN` all parse; the examples here use the conventional capitalized form. +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 @@ -45,7 +58,8 @@ When a refund is requested, the billing service shall issue a refund to the orig ### Optional feature: behavior gated by a feature -Use `Where , ...` when the behavior exists only if a feature is present or enabled. +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. @@ -54,7 +68,9 @@ Where multi-currency support is enabled, the billing service shall convert amoun ### 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.invalid_if_then_form`). +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. @@ -63,28 +79,53 @@ If the payment is declined, then the billing service shall notify the account ow ### 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`. +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: +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.invalid_clause_order`). `When ... if ... then ...` is valid, because `When` precedes `If`. `When ... while ...` is not, because `While` must come before `When`. +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`: -The article `the` on the system is stripped: `the billing service shall ...` yields the system `billing service`. +```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. +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. @@ -93,70 +134,94 @@ If the signature is missing or the timestamp is stale, then the billing service 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 (`expr.operator_precedence_warning`) so you can make the grouping explicit if it is not obvious. Unbalanced parentheses, doubled operators such as `or or`, and empty groups are errors. - -By default a comma ends a clause. Pass `--comma-as-and` to `validate` (or `commaAsAnd: true` in the API) to read unambiguous commas inside a clause body as `and`. The explicit `and` keyword works either way. +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: -## Catalog matching +```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. +``` -A catalog is an optional list of the domain terms you consider canonical: systems, events, states, features, and more. When you supply one, the linter checks the system and clause terms against it. Matching is exact: canonical name first, then alias, then ambiguous if more than one entry matches, then unresolved if none does. There is no fuzzy or semantic matching. +```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) +``` -Given this catalog: +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. -```json -{ - "systems": [{ "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] }] -} -``` +## Prohibitions -validating these two lines in strict mode: +Canonical EARS states positive obligations. A `shall not` prohibition is an error +under `strict` (`EARS-E016`): ```text -The BFF shall verify the HMAC signature. -The reconciliation engine shall verify the HMAC signature. +The billing service shall not store raw card numbers. ``` -produces: - ```text -cat.ears:1 warning lint.alias_used alias used instead of canonical term -cat.ears:2 error catalog.system_unresolved unresolved catalog term - -1/2 valid, 1 errors, 1 warnings +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) ``` -`BFF` matched through an alias, so the linter accepts it but warns that `billing service` is the canonical name. `reconciliation engine` matched no entry, and an unresolved system is an error in strict mode. In guided mode both stay warnings. Unresolved states, events, and features are always warnings, never errors. Pass a catalog to the CLI with `--catalog `. +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 (`lint.vague_response`). The default vague terms are `appropriate`, `sufficient`, and `as needed`; configure the list through `Options.vagueTerms`. +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 -warn.ears:1 warning lint.vague_response The response contains the vague term "as needed". +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 that a reader cannot turn into a test. Replace the vague term with a bounded, observable response, or raise a question if you do not yet know the bound. +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 mode the structural codes are errors; the style codes are always warnings. The [diagnostics reference](diagnostics.md) documents all 29 codes and their severity in each mode. +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. -| Code | What it means | Fix | -| ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | -| `ears.missing_shall` | No single `shall` response boundary. | State one obligation with `shall`: `the billing service shall ...`. | -| `ears.missing_system` | No system name before `shall`. | Name the system: `... the billing service shall ...`. | -| `ears.invalid_if_then_form` | An `If` clause has no `then`. | Add `then`: `If , then the shall .` | -| `ears.multiple_shall` | More than one `shall` in the sentence. | Split into separate requirements, one obligation each. | -| `ears.invalid_clause_order` | Clauses are out of order. | Reorder to `While -> Where -> When -> If`. | -| `lint.multiple_responses` | Semicolon-joined responses in one requirement. | Split into separate requirements. | -| `lint.vague_response` | The response contains a configured vague term. | Replace it with an observable, bounded response. | -| `expr.operator_precedence_warning` | Mixed `and`/`or` without parentheses. | Add parentheses to make the grouping explicit. | +| 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. | -For example, these four lines each fail with a different structural error: +The four lines below each fail with a different structural error: ```text The billing service verifies the HMAC signature. @@ -166,18 +231,22 @@ The billing service shall queue events and shall log the event. ``` ```text -err.ears:1 error ears.missing_shall The requirement does not contain exactly one 'shall' response boundary. -err.ears:2 error ears.missing_system The requirement is missing the system name before 'shall'. -err.ears:3 error ears.invalid_if_then_form The 'If' clause is missing the required 'then' boundary. -err.ears:4 error ears.multiple_shall The requirement contains more than one shell-level 'shall'. - -0/4 valid, 4 errors, 0 warnings +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. +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 by mode, 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. +- [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 index f363e03..f3ee376 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,322 +1,641 @@ # CLI reference -`earsyntax` is the command-line facade for humans, scripts, and coding agents. It resolves paths, scaffolds work items, returns agent instructions, validates `.ears` files, reports status, and records human acceptance. It never derives requirements from prose; that is the coding agent's job. The normative JSON contract is in `docs/facade-api.md`; this page is the usage guide with a runnable example and expected output for every command. +`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. -Install and run through the package binary: +Run it through the package binary, no global install required: ```bash npx @earsyntax/cli [options] ``` -## Global 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. | -These work on every command where they make sense: +## Global options -| Option | Effect | -| ------------------ | --------------------------------------------------------------------------------- | -| `--json` | Emit the machine-readable facade response instead of pretty text. | -| `--no-color` | Strip ANSI color from pretty output. | -| `--cwd ` | Resolve the project from this directory instead of the process working directory. | -| `--config ` | Use an explicit config file instead of `.earsyntax/config.json`. | -| `--no-interactive` | Never prompt; a step that would require confirmation is refused with exit 3. | +| 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. | -Every `--json` response shares a base shape: `{ version, command, ok, root?, next: [] }` plus command-specific keys. Paths are relative to the resolved project root (the directory containing `.earsyntax/`) unless you passed an absolute path. +`--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` | Completed with no error-severity diagnostic. | -| `1` | Validation completed with at least one error diagnostic. Only `validate` returns `1`. | -| `2` | Usage, config, missing file, unparseable input, or work-item resolution error. | -| `3` | Refused write, stale source, overwrite protection, or confirmation required in non-interactive mode. | +| 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). -## `init` +## `validate ` -Create `.earsyntax/` and, optionally, thin agent wrapper files. Refuses to overwrite an existing workspace without `--force` (exit 3). +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 init -earsyntax init --tools claude,codex,cursor -earsyntax init --tools none --json -earsyntax init --force +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro ``` -Expected output (pretty): +Pretty output points at each finding with `path:line:col id severity message`, +then a summary line: ```text -Initialized earsyntax in .earsyntax - wrote .earsyntax/config.json - wrote .earsyntax/work/.gitkeep - tools: none +.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) ``` -`--tools claude` writes `.claude/commands/earsyntax-{author,convert,repair}.md`; `codex` adds a marked section to `AGENTS.md`; `cursor` writes `.cursor/rules/earsyntax.mdc`. Each wrapper only points back at `earsyntax instructions` and `earsyntax validate`, so the rules live in one place. +The same run with `--json`: -## `doctor` +```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-only project health report. Never writes. +Read from stdin with `-`. Stdin is treated as plain text (every non-empty line is +a candidate), so it validates under any profile: ```bash -earsyntax doctor -earsyntax doctor --json +printf 'When a payment webhook arrives, the billing service shall verify the signature.\n' \ + | earsyntax validate - --profile strict ``` -Expected output (pretty): - ```text - [ok] config: .earsyntax/config.json exists and parses. - [ok] workDir: Work directory .earsyntax/work exists. - [ok] version: earsyntax 0.1.0 supports facade contract 1. +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) ``` -With `--json`, each check is an entry in `checks`, and any non-passing check is mirrored into the base `diagnostics` array. `ok` is `true` unless a check produced an error. +`--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. -## `version` +### Profiles and Markdown blindness -Report the installed version and, with `--features`, the capability map agents branch on. +`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 version -earsyntax version --features --json +earsyntax validate ".kiro/specs/**/requirements.md" --profile strict --json ``` -Expected output (`--features --json`): - ```json { - "version": "0.1.0", - "command": "version", + "version": "0.0.1-alpha.0", + "command": "validate", "ok": true, - "features": { - "facade": 1, - "workItems": true, - "instructions": ["author", "convert", "repair", "review"], - "inputFormats": [".ears", "markdown", "yaml", "json"], - "outputFormats": ["pretty", "json"], - "sarif": false + "findings": { + "ok": true, + "summary": { "files": 1, "requirements": 0, "valid": 0, "errors": 0, "warnings": 0 }, + "diagnostics": [] }, "next": [] } ``` -## `new` +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). -Scaffold a work item. This prepares the directory an agent writes into; it never generates EARS content. Convert mode requires `--source `, author mode requires `--prompt `. +### SARIF output ```bash -earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json -earsyntax new checkout-webhooks --prompt "Checkout webhooks validate signatures." --mode author --json +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif ``` -Expected output (convert, `--json`, hashes vary): +`--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 { - "version": "0.1.0", - "command": "new", + "$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, - "root": "/repo", - "work": { - "id": "checkout-webhooks", - "mode": "convert", - "status": "scaffolded", - "source": "specs/checkout.md", - "sourceHash": "sha256:...", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears" + "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 }, - "written": [ - ".earsyntax/work/checkout-webhooks/requirements.ears", - ".earsyntax/work/checkout-webhooks/questions.md", - ".earsyntax/work/checkout-webhooks/traceability.json", - ".earsyntax/work/checkout-webhooks/manifest.json" + "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 instructions convert --work checkout-webhooks --json", - "reason": "Give the coding agent the rules for writing the .ears file.", + "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 } ] } ``` -Pass `--out ` to reserve a different output path, `--snapshot-source` to copy the source into the work directory, and `--force` to recreate an existing item (otherwise it refuses with exit 3). +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: -## `list` +| 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. | -List work items, sorted by id. Filter with `--status `. +### `--from ` + +`--from ` is valid only with `author` and `convert`; using it with +`repair` or `review` is a usage error (exit `2`): ```bash -earsyntax list -earsyntax list --status stale --json +earsyntax instructions repair --file x.md --from y.md --profile kiro ``` -Expected output (pretty): - ```text -checkout-webhooks valid +error instructions.from_not_allowed: The --from source is only valid with author and convert, not repair. ``` -## `status` +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. -Report one work item's computed state and next steps. With no slug it resolves the single work item when there is exactly one. +## `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 status checkout-webhooks --json +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. ``` -Expected output (`--json`, hashes vary): +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.1.0", - "command": "status", + "version": "0.0.1-alpha.0", + "command": "explain", "ok": true, - "root": "/repo", - "work": { - "id": "checkout-webhooks", - "status": "valid", - "mode": "convert", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "sourceHash": "sha256:...", - "outputHash": "sha256:...", - "acceptedHash": null, - "stale": false - }, - "next": [ + "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": [ { - "command": "earsyntax instructions review --work checkout-webhooks --json", - "reason": "Prepare the human review summary.", - "forAgent": true + "name": "strict", + "locates": "every non-empty line in ears, text files.", + "relaxes": [], + "adds": [], + "severityOverrides": {} }, { - "command": "earsyntax accept checkout-webhooks", - "reason": "Record acceptance after human approval.", - "blocking": true + "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": [] } ``` -`status` reports `stale` when the source changed after the last `valid` or `accepted` state, even though the stored status is still `valid` or `accepted`. +Each `ProfileSummary` is `{ name, locates, relaxes, adds, severityOverrides }`, +one per built-in profile, in the order `strict`, `ears-x`, `kiro`, `speckit`, +`openspec`. -## `instructions` +## `doctor` -Return the rules a coding agent follows for one loop step. Read-only. The modes are `author`, `convert`, `repair`, and `review`. +Detect host frameworks and agent integrations in the repo at `--cwd`, and +recommend exact commands. Works with no setup. Exit `0`. ```bash -earsyntax instructions convert --work checkout-webhooks --json -earsyntax instructions repair --work checkout-webhooks --json +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 ``` -Expected output (convert, `--json`, abridged): +With `--json`, detection lands in `detected.hosts` and `detected.agents`, and +every recommendation is a runnable command in `next`: ```json { - "version": "0.1.0", - "command": "instructions convert", + "version": "0.0.1-alpha.0", + "command": "doctor", "ok": true, "root": "/repo", - "mode": "convert", - "work": { - "id": "checkout-webhooks", - "status": "scaffolded", - "source": "specs/checkout.md", - "output": "...", - "questions": "...", - "traceability": "..." - }, - "rules": ["Read the full source before writing requirements.", "..."], - "format": { - "line": "REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature.", - "allowedPatterns": ["The shall .", "..."], - "metadataPrefixes": [ - "REQ-001:", - "REQ-001 [source: path:line]:", - "REQ-001 [source: path:line-line]:" - ] - }, - "source": { - "path": "specs/checkout.md", - "hash": "sha256:...", - "excerpts": [{ "path": "specs/checkout.md", "startLine": 3, "endLine": 3, "text": "..." }] + "detected": { + "hosts": [{ "host": "kiro", "evidence": ".kiro/specs/", "profile": "kiro" }], + "agents": [] }, "next": [ { - "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", - "reason": "Validate the generated .ears file once it is written.", + "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 } ] } ``` -`author` mode omits the `source` block. `repair` mode adds a `diagnostics` array (read from the work item's last `validation.json`) listing what to fix. `review` mode returns review-summary rules and points `next` at reading questions and recording acceptance. +Detection markers include `.kiro/specs/`, `.kiro/steering/`, `.kiro/hooks/`, +`specs/**/spec.md`, `.specify/`, `openspec/`, `.claude/`, `AGENTS.md`, `.cursor/`, +`.github/prompts/`, and `GEMINI.md`. -## `validate` +## `init --agent --host ` -Validate `.ears` files deterministically. Reads `.ears`, Markdown, YAML, and JSON through `@earsyntax/extract`. Never writes source, never asks questions. This is the only command that returns exit `1`. +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 validate .earsyntax/work/checkout-webhooks/requirements.ears --work checkout-webhooks --json -earsyntax validate "specs/**/*.ears" --catalog catalog.json --format json -earsyntax validate requirements.ears --mode guided --comma-as-and +earsyntax init --agent claude --host kiro --json ``` -Expected output (pretty, one file with one error): - -```text -requirements.ears:2 error ears.invalid_if_then_form If requirements must include a then boundary before the system response. - -7/8 valid, 1 errors, 0 warnings +```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." + } + ] +} ``` -`--json` returns `{ summary, results, work?, stale?, next }`. `ok` is `false` and the exit code is `1` when `summary.errors > 0`. When the target is a work item (via `--work` or a path inside `.earsyntax/work/`), `validate` updates the manifest status to `valid` or `invalid`, writes `validation.json` and `validation.md`, and reports `stale` if the source drifted. A validated path under `.earsyntax/work//` is treated as that work item automatically, so `--work` is optional when you validate a work item's own output path. Duplicate requirement IDs are reported as an error diagnostic in the base `diagnostics` array. - -Options: `--source ` records the source for staleness, `--catalog ` supplies a term catalog, `--mode strict|guided`, and `--comma-as-and` treats unambiguous commas inside clause bodies as `and`. +`--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. -## `accept` +## `version --features` -Record that a human accepted the generated `.ears` file. The human gate. +Report the installed version and capability map. Never resolves a repo, so `root` +is absent. Exit `0`. ```bash -earsyntax accept checkout-webhooks --by "omer" --json +earsyntax version --features --json ``` -Expected output (pretty): - -```text -Accepted "checkout-webhooks" by omer. +```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": [] +} ``` -`accept` refuses with exit `3` when the status is not `valid`, when the source is stale, or when the `.ears` file changed since it was validated. On success it writes an `accepted` block (`at`, `by`, `sourceHash`, `outputHash`) to the manifest and sets the status to `accepted`. It never edits the source or the `.ears` content. +`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. -## `show` +## The repair loop -Resolve artifact paths or print artifact content. Read-only. +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 show checkout-webhooks --json -earsyntax show checkout-webhooks --artifact questions --json +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 ``` -Without `--artifact`, the response lists resolved paths for every artifact. With `--artifact requirements|questions|traceability|validation|manifest`, it returns `{ path, exists, content }`, where `content` is the file text when it exists and `null` otherwise, so an agent needs no second read. - -## The loop - -The commands compose into one loop that keeps the CLI, the coding agent, and the human separate. See `docs/agentic-loop.md` for the state machine. - -```bash -earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json -earsyntax instructions convert --work checkout-webhooks --json -# the agent writes .earsyntax/work/checkout-webhooks/requirements.ears -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --work checkout-webhooks --json -# if invalid: -earsyntax instructions repair --work checkout-webhooks --json -# repair and re-validate until clean, then: -earsyntax status checkout-webhooks --json -earsyntax instructions review --work checkout-webhooks --json -earsyntax accept checkout-webhooks --by "omer" -``` +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/facade-api.md b/docs/facade-api.md index 9a439a1..7236b3d 100644 --- a/docs/facade-api.md +++ b/docs/facade-api.md @@ -1,458 +1,29 @@ -# Facade API reference - -This is the normative reference for the `earsyntax` facade JSON contracts. Every command that supports `--json` returns a stable, camelCase object. Humans, scripts, and coding agents read these shapes; the CLI agent implements against them. - -Golden examples live in `fixtures/facade/`. Where this document and a fixture disagree, the fixture is the value pinned for tests and this document is the description of intent; report the mismatch rather than guessing. - -Related documents: - -- `docs/agentic-loop.md` describes the loop and the work-item state machine. -- `docs/agent-rules.md` is the instruction body the `instructions` command returns. -- `docs/diagnostics.md` is the 29-code diagnostic registry validation emits. - -## Conventions - -- JSON keys are camelCase. -- Paths are relative to the resolved project root unless the caller supplied an absolute path. The root is the directory containing `.earsyntax/`, discovered by walking up from `--cwd` (default: process working directory). -- Timestamps are ISO 8601 in UTC with millisecond precision (for example `2026-07-28T10:00:00.000Z`). -- Source and output hashes are `sha256:` followed by 64 lowercase hex characters. -- Every `--json` response includes the base fields below, even on failure. - -## Base response - -Every `--json` command returns this shape. - -```ts -interface FacadeResponse { - version: string; - command: string; - ok: boolean; - root?: string; - data?: T; - diagnostics?: FacadeDiagnostic[]; - next: NextAction[]; -} - -interface NextAction { - command: string; - reason: string; - blocking?: boolean; - forAgent?: boolean; -} - -interface FacadeDiagnostic { - code: string; - severity: 'error' | 'warning' | 'info'; - message: string; - path?: string; - line?: number; -} -``` - -Field descriptions: - -| Field | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | The installed `@earsyntax/cli` package version, for example `0.1.0`. | -| `command` | The command that produced the response. Subcommands use a space, for example `instructions convert`. | -| `ok` | `true` when the command completed with no error-severity diagnostic. See each command for what counts as an error. | -| `root` | The resolved project root as an absolute path. Present on commands that resolve a project. | -| `data` | Reserved generic payload. In v0.1 commands expose their payload through top-level convenience keys instead (see the note below). | -| `diagnostics` | Facade-level diagnostics (config, path, or work-item problems). Distinct from the core linting `Diagnostic` inside a validate result. | -| `next` | Ordered next actions. Always present; may be empty. | - -`NextAction`: - -| Field | Description | -| ---------- | ----------------------------------------------------------------------------------------------------- | -| `command` | A runnable `earsyntax` command. Every value is a real command from this contract. | -| `reason` | One factual sentence explaining why this action follows. | -| `blocking` | `true` when the action requires a human decision (for example acceptance). Optional; default `false`. | -| `forAgent` | `true` when a coding agent can run the action without human input. Optional; default `false`. | - -[DECIDED] Convenience keys over `data`. The orchestration brief shows command payloads as top-level keys (`work`, `written`, `summary`, `results`) rather than nested under `data`. v0.1 follows the examples: each command adds its own top-level keys and leaves `data` unset. Rationale: it matches every example in the brief and keeps the JSON one level flatter for agents. - -`FacadeDiagnostic.severity` may be `info`; the core linting registry emits only `error` and `warning` in v1, but facade-level notices (for example a missing optional tool file from `doctor`) may be `info`. - -## Exit codes - -Exit codes are uniform across commands. - -| Code | Meaning | -| ---- | ------------------------------------------------------------------------------------------------------------ | -| `0` | The command completed and produced no error-severity diagnostic. | -| `1` | Validation completed and at least one error-severity diagnostic exists. Only `validate` returns `1`. | -| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work-item resolution error. | -| `3` | Refused write, stale source, overwrite protection, or a human confirmation required in non-interactive mode. | - -Per-command notes: - -| Command | `0` | `1` | `2` | `3` | -| -------------- | ---------------------------- | ----------------------- | ----------------------------------------------- | ----------------------------------------------------- | -| `init` | Initialized or already valid | not used | bad `--tools` value, unwritable root | `.earsyntax/` exists and `--force` not passed | -| `doctor` | all checks pass or warn | not used | config unreadable | not used | -| `version` | always | not used | not used | not used | -| `new` | work item created | not used | bad slug, missing `--source` file, missing mode | work item exists and `--force` not passed | -| `list` | always | not used | config unreadable | not used | -| `status` | work item resolved | not used | unknown work item | not used | -| `instructions` | instructions returned | not used | unknown work item, unknown mode | not used | -| `validate` | no error diagnostics | one or more error diags | missing file, bad glob, unreadable catalog | source stale relative to a work item (see `validate`) | -| `accept` | acceptance recorded | not used | unknown work item | status not `valid`, or source stale | -| `show` | artifact resolved | not used | unknown work item, unknown artifact | not used | - -[DECIDED] Only `validate` returns exit `1`. Exit `1` means "error diagnostics exist", which is a linting outcome. Other commands surface problems as exit `2` (resolution) or `3` (refusal), never `1`. Rationale: keeps `1` a reliable signal that requirements failed validation, which is what scripts branch on. - -[DECIDED] `validate` on stale work. When `validate` runs with `--source` against a work item whose source hash no longer matches the manifest, validation of the `.ears` file still runs and reports linting results, and the response sets `stale: true`. The exit code stays driven by diagnostics (`0` or `1`); staleness alone does not force exit `3` on `validate`. Rationale: staleness is a warning about drift, not a parse failure; `accept` is the gate that refuses stale work with exit `3`. - -## `init` - -Initialize `.earsyntax/` and optional agent wrapper files. - -Top-level keys beyond the base: - -| Key | Type | Description | -| --------- | ---------- | ----------------------------------------------------------------- | -| `written` | `string[]` | Every path written, relative to root. Empty when nothing changed. | -| `tools` | `string[]` | Tool wrappers written, for example `["claude", "codex"]`. | - -```json -{ - "version": "0.1.0", - "command": "init", - "ok": true, - "root": "/repo", - "written": [".earsyntax/config.json", ".earsyntax/work/.gitkeep"], - "tools": ["claude", "codex"], - "next": [ - { - "command": "earsyntax new checkout-webhooks --source specs/checkout.md --json", - "reason": "Create a work item for a source spec.", - "forAgent": true - } - ] -} -``` - -`init` refuses to overwrite an existing `.earsyntax/` without `--force` and returns exit `3` in that case. - -## `doctor` - -Read-only project health report. Never writes. - -Top-level keys beyond the base: - -| Key | Type | Description | -| -------- | --------------- | -------------------------------------------------------------------- | -| `checks` | `DoctorCheck[]` | One entry per health check, each with `name`, `ok`, and a `message`. | - -[DECIDED] `checks` shape. The brief lists the checks doctor performs but not a JSON field. v0.1 returns `checks: { name: string; ok: boolean; severity: 'error'|'warning'|'info'; message: string }[]`, and mirrors any non-passing check into the base `diagnostics` array. Rationale: agents can read one array (`diagnostics`) for problems while humans get the full ordered checklist in `checks`. - -Checks performed: `.earsyntax/config.json` exists and parses; the configured work directory exists; the package version supports the requested facade features; referenced sources still exist; accepted work items are not stale; configured tool wrapper files are present. A failed check is a `FacadeDiagnostic`; `ok` is `true` only when no check produced an `error`. - -## `version` - -Expose installed capabilities. Never resolves a project, so `root` may be absent. - -Top-level keys beyond the base: - -| Key | Type | Description | -| ---------- | -------- | -------------------------------------------------------- | -| `features` | `object` | Capability map for agents to branch on without guessing. | - -```json -{ - "version": "0.1.0", - "command": "version", - "ok": true, - "features": { - "facade": 1, - "workItems": true, - "instructions": ["author", "convert", "repair", "review"], - "inputFormats": [".ears", "markdown", "yaml", "json"], - "outputFormats": ["pretty", "json"], - "sarif": false - }, - "next": [] -} -``` - -`features.facade` is an integer facade contract version. It increments only on a breaking change to these shapes. - -## `new` - -Create a work item. Does not generate EARS content; it prepares the directory the agent writes into. - -Top-level keys beyond the base: - -| Key | Type | Description | -| --------- | ------------- | ------------------------------------- | -| `work` | `WorkSummary` | The created work item summary. | -| `written` | `string[]` | Every path written, relative to root. | - -```json -{ - "version": "0.1.0", - "command": "new", - "ok": true, - "root": "/repo", - "work": { - "id": "checkout-webhooks", - "mode": "convert", - "status": "scaffolded", - "source": "specs/checkout.md", - "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears" - }, - "written": [ - ".earsyntax/work/checkout-webhooks/manifest.json", - ".earsyntax/work/checkout-webhooks/requirements.ears", - ".earsyntax/work/checkout-webhooks/questions.md", - ".earsyntax/work/checkout-webhooks/traceability.json" - ], - "next": [ - { - "command": "earsyntax instructions convert --work checkout-webhooks --json", - "reason": "Give the coding agent rules for writing the .ears file.", - "forAgent": true - } - ] -} -``` - -Mode selection: `--mode convert` requires `--source `; `--mode author` requires `--prompt `. The default output path is `.earsyntax/work//requirements.ears`, overridable with `--out`. The source file is not copied unless `--snapshot-source` is passed, in which case the copy path is recorded in the manifest `source.snapshotPath`. `new` refuses to clobber an existing work item without `--force` and returns exit `3`. - -## Work manifest - -`new` creates this file; later commands update it. It is the durable state of a work item and lives at `.earsyntax/work//manifest.json`. - -```ts -type WorkMode = 'author' | 'convert'; - -type WorkStatus = 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; - -interface WorkManifest { - schemaVersion: 1; - id: string; - mode: WorkMode; - status: WorkStatus; - source?: { - path?: string; - hash?: string; - kind: 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; - snapshotPath?: string; - }; - prompt?: string; - output: { - path: string; - hash?: string; - }; - artifacts: { - questions: string; - traceability: string; - validationJson: string; - validationMarkdown: string; - }; - createdAt: string; - updatedAt: string; - accepted?: { - at: string; - by?: string; - sourceHash?: string; - outputHash: string; - }; -} -``` - -Notes on state: - -- `status` is the state machine value. Its transitions are specified in `docs/agentic-loop.md`. -- `source.hash` is the hash of the source content at the last command that read it. `stale` is detected by comparing the current source hash to the hash recorded at the last `valid` or `accepted` transition. -- `output.hash` is the hash of the `.ears` content at the last validation. It lets `accept` detect a `.ears` file edited after validation. -- `missing` never appears in a stored manifest; it is the reported status when a queried work item has no manifest. - -## `list` - -List work items. Supports `--status ` to filter. - -Top-level keys beyond the base: - -| Key | Type | Description | -| ------- | --------------- | --------------------------- | -| `items` | `WorkSummary[]` | Work items, sorted by `id`. | - -## `status` - -Report one work item's state and next steps. - -Top-level keys beyond the base: - -| Key | Type | Description | -| ------ | -------- | ------------------------------ | -| `work` | `object` | Work item state (shape below). | - -```json -{ - "version": "0.1.0", - "command": "status", - "ok": true, - "root": "/repo", - "work": { - "id": "checkout-webhooks", - "status": "valid", - "mode": "convert", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "sourceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "outputHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "acceptedHash": null, - "stale": false - }, - "next": [ - { - "command": "earsyntax instructions review --work checkout-webhooks --json", - "reason": "Prepare the human review summary.", - "forAgent": true - }, - { - "command": "earsyntax accept checkout-webhooks", - "reason": "Record acceptance after human approval.", - "blocking": true - } - ] -} -``` - -`acceptedHash` is the `accepted.outputHash` from the manifest, or `null` when the item was never accepted. `stale` is the computed staleness flag. `next` reflects the current state: a `scaffolded` item points at `instructions`, an `invalid` item points at `validate` then `instructions repair`, a `valid` item points at review and accept, a `stale` item points at re-running `instructions convert`. - -## `instructions` - -Return the rules a coding agent follows for one step. Read-only. The four modes are `author`, `convert`, `repair`, and `review`. - -```ts -interface InstructionResponse { - work: WorkSummary; - mode: 'author' | 'convert' | 'repair' | 'review'; - rules: string[]; - format: { - line: string; - allowedPatterns: string[]; - metadataPrefixes: string[]; - }; - source?: { - path?: string; - hash?: string; - excerpts?: SourceExcerpt[]; - }; - diagnostics?: FacadeDiagnostic[]; - next: NextAction[]; -} - -interface WorkSummary { - id: string; - status: WorkStatus; - source?: string; - output: string; - questions: string; - traceability: string; -} - -interface SourceExcerpt { - path: string; - startLine: number; - endLine: number; - text: string; -} -``` - -The response carries `InstructionResponse` fields as top-level keys alongside the base fields, so the object shape is `{ version, command, ok, root, mode, work, rules, format, source?, diagnostics?, next }`. - -Per-mode content: - -| Mode | `source` present | `diagnostics` present | `rules` focus | -| --------- | --------------------------- | ---------------------------- | --------------------------------------------------------------- | -| `author` | no (prompt-based work item) | no | Author from the prompt only; do not invent behavior. | -| `convert` | yes (path, hash, excerpts) | no | Convert the source; preserve traceability; split compounds. | -| `repair` | yes when the item has one | yes (the diagnostics to fix) | Change only what the diagnostics justify; keyed to codes. | -| `review` | yes when the item has one | no | Produce a human review summary; recommend accept only if clean. | - -The full rule text is specified in `docs/agent-rules.md`. The `command` field is `instructions ` (for example `instructions convert`). Fixtures: `fixtures/facade/instructions-*.json`. - -## `validate` - -Validate `.ears` files deterministically. Never writes, never asks questions. This is the only command that returns exit `1`. - -```ts -interface ValidationResponse { - summary: { - files: number; - requirements: number; - valid: number; - errors: number; - warnings: number; - }; - results: ValidationResult[]; - work?: WorkSummary; - stale?: boolean; - next: NextAction[]; -} - -interface ValidationResult { - id?: string; - file: string; - line?: number; - valid: boolean; - pattern?: Pattern; - ast?: EarsAst; - references: ReferenceMatch[]; - diagnostics: Diagnostic[]; -} -``` - -The response carries these as top-level keys alongside the base fields. `ok` is `false` when `summary.errors > 0`, and the exit code is `1` in that case. `work` and `stale` are present only when validation ran against a work item (via `--work` or a path inside `.earsyntax/work/`). The `diagnostics` inside each `ValidationResult` are core linting `Diagnostic` objects (see `docs/diagnostics.md`), not `FacadeDiagnostic` objects. Fixture: `fixtures/facade/validate.response.json`. - -## `accept` - -Record that a human accepted the generated `.ears` file. This is the human gate. - -Top-level keys beyond the base: - -| Key | Type | Description | -| ------ | -------- | ------------------------------------------ | -| `work` | `object` | Updated work summary including `accepted`. | - -Rules, each a refusal with exit `3` when violated: - -- Refuse if the current status is not `valid`. -- Refuse if the source hash is stale relative to the recorded valid hash. -- Refuse if the `.ears` output hash changed since the last validation (the file was edited after it was validated). - -On success it writes the `accepted` block to `manifest.json` and sets status to `accepted`. It never modifies the source spec or the `.ears` content. `--by ` records who accepted. - -[DECIDED] `accept` is `Partly` agent-compatible, per the command summary. It has a `--json` shape and can be scripted, but the brief classifies acceptance as a human decision, so `next` actions that lead to `accept` carry `blocking: true` and never `forAgent: true`. - -## `show` - -Resolve artifact paths or print artifact content. Read-only. - -Selectors: `--artifact requirements | questions | traceability | validation | manifest`. Without `--artifact`, `show` returns resolved paths for every artifact. - -Top-level keys beyond the base: - -| Key | Type | Description | -| ---------- | -------- | -------------------------------------------------------------------- | -| `work` | `object` | Work summary with resolved artifact paths. | -| `artifact` | `object` | Present when `--artifact` selects one: `{ path, exists, content? }`. | - -[DECIDED] `show` content inclusion. With `--json` and a single `--artifact`, the response includes the artifact `content` as a string when the file exists and is UTF-8 text; binary or missing files set `content` to `null` and `exists` accordingly. Rationale: agents reading `questions` or `validation` in a loop should not need a second file read. - -## `export` - -Planned, not implemented in v0.1. Documented in the orchestration brief for forward reference only. It does not appear in `version.features` until shipped. - -## Stability guarantees - -- The base fields (`version`, `command`, `ok`, `next`) are present on every `--json` response and do not change shape within a facade contract version. -- `features.facade` is the contract version. Field removals or renames increment it. -- New optional keys may be added within a contract version; consumers must ignore unknown keys. -- `next[].command` values are always runnable `earsyntax` commands from this contract. -- Diagnostic codes inside validation results come from the frozen 29-code registry and are append-only. +# 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/input-formats.md b/docs/input-formats.md index ade048f..efbd7d2 100644 --- a/docs/input-formats.md +++ b/docs/input-formats.md @@ -273,12 +273,13 @@ inferred from the path extension (`.ears`, `.md`/`.markdown`, `.yaml`/`.yml`, ### Candidates and positions -Each `Candidate` is `{ file, line, col?, text, id?, locatorRuleId, profile }`. -`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 -lifted metadata prefix. `locatorRuleId` is the id of the profile `LocatorRule` -that selected the candidate, so `extract` output can be traced back to the -profile. +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 @@ -291,17 +292,25 @@ JSON are structured requirement lists: no built-in profile declares them in carry a synthetic `locatorRuleId` of `structured.yaml` or `structured.json`. The active dialect still applies when their text is linted. -### Frame metadata under the pipeline +### Requirement ids under the pipeline -Under the pipeline, a `REQ-001:` id prefix and a `[source: path:line]` tag are -lifted from a line only when the profile's dialect sets `allowFrameMetadata` -(the `ears-x` profile does; `strict`, `kiro`, `speckit`, and `openspec` do not). -Under a profile that does not allow frame metadata, the prefix is kept as part of -the requirement text, and the linter reports it. A lifted `[source: ...]` tag is -stripped from the text but does not move the reported position: findings point at -the physical line and column where the text sits, so an agent can edit it in -place. This differs from the non-profile `extractEars`, where a `[source: ...]` -reference overrides the item's source location. +Two id conventions apply, and they are handled differently on purpose: + +- 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. Speckit and Kiro use this form. +- An `ears-x` frame prefix (`REQ-001:` and an optional `[source: path:line]` + tag) is retained in the candidate `text`, with `col` 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: findings point at the physical line and column where the text sits. + This differs from the non-profile `extractEars`, which removes the prefix from + `text` and lets a `[source: ...]` reference override the item's source + location. + +Under a profile that allows neither (for example `strict`), a leading `REQ-001:` +is left untouched in the text and the linter reports it. ### Never throws @@ -310,3 +319,45 @@ 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 index d4cb6b7..5f94269 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -1,169 +1,199 @@ -# Public API Reference +# Public API reference -`@earsyntax/core` exposes a small, frozen surface. Every type is defined in `packages/core/src/types.ts` and re-exported from the package root. The functions below are the entire public API for v0.1. +`@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). -```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. +Every function here is deterministic: no LLM calls, no network, no file system +access (the one exception is `extractFromFile`, which reads a path), no fuzzy +matching. Diagnostics are stably sorted; batch and pipeline order is preserved. -- No LLM calls. -- No network access. -- No file system access. -- No fuzzy or semantic matching (catalog matching is exact name, then exact alias). -- Diagnostics are stably sorted by span, then code, then message, then severity. -- `lintEarsBatch` preserves input order: result `i` corresponds to input `i`. -- `valid` is derived only from severity: `false` when any diagnostic is an `error`, otherwise `true`. +## `@earsyntax/core` -## `lintEars` +### 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[]; ``` -Lints a single requirement and returns a complete `LintResult` with `valid`, `pattern`, `ast`, `references`, and sorted `diagnostics`. +`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. -### Example +### Dialect resolution ```ts -const catalog = { - systems: [{ id: 'SYS-BILLING', name: 'billing service' }], - events: [{ id: 'EVT-WEBHOOK', name: 'a payment webhook is received' }], -}; - -const result = lintEars( - 'When a payment webhook is received, the billing service shall verify the HMAC signature.', - catalog, - { mode: 'strict' }, -); -``` +import { STRICT_DIALECT, resolveDialect } from '@earsyntax/core'; +import type { ResolvedDialect } from '@earsyntax/core'; -The billing-webhook example from the orchestration brief produces: - -```json -{ - "valid": true, - "pattern": "event-driven", - "ast": { - "pattern": "event-driven", - "trigger": { - "kind": "term", - "text": "a payment webhook is received" - }, - "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." - }, - "references": [], - "diagnostics": [] -} +const STRICT_DIALECT: ResolvedDialect; // canonical EARS tolerances +function resolveDialect(options?: Options): ResolvedDialect; // merge a partial dialect over strict ``` -## `lintEarsBatch` +`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 -function lintEarsBatch( - items: RequirementInput[], - catalog?: Catalog, - options?: Options, -): LintResult[]; +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 ``` -Lints many requirements at once. Returns one `LintResult` per input item, in the same order, each echoing the input `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. -### Example +### Profiles ```ts -const results = 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.', - }, - ], - catalog, - { mode: 'strict' }, -); - -// results[0].id === 'REQ-001', results[1].id === 'REQ-002' +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 ``` -## `parseEars` +`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 -function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult; +import { toFindings, defaultSeverityForId } from '@earsyntax/core'; + +function toFindings(input: FindingsInput, options?: ToFindingsOptions): Findings; +function defaultSeverityForId(id: string): FindingsSeverity; ``` -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. +`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). -### Example +### Pipeline findings assembly ```ts -const parsed = parseEars( - 'While the payment provider is unavailable, the billing service shall queue retryable events.', -); - -// parsed.pattern === 'state-driven' -// parsed.ast.preconditions describes "the payment provider is unavailable" +import { candidatesToFindings } from '@earsyntax/core'; +import type { + Candidate, + CandidateFile, + PipelineNotice, + LintCandidatesOptions, +} from '@earsyntax/core'; + +function candidatesToFindings( + files: readonly CandidateFile[], + profile: Profile, + options?: LintCandidatesOptions, +): Findings; ``` -## `lintCatalogCoverage` +`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` + +### Format extractors ```ts -function lintCatalogCoverage( - items: RequirementInput[], - catalog?: Catalog, - options?: Options, -): Diagnostic[]; +import { + extractEars, + extractMarkdown, + extractYaml, + extractJson, + extractFromContent, + extractFromFile, +} from '@earsyntax/extract'; +import type { ExtractError, ExtractResult } from '@earsyntax/extract'; ``` -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). +Each extractor turns one authored format into `RequirementInput` items. Every +parser is pure; `extractFromContent` dispatches on the file extension, and +`extractFromFile` is the only function that reads disk. The formats are documented +in the [input formats guide](input-formats.md). -### Example +### Host-native pipeline ```ts -const diagnostics = 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' }, -); +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 ``` -Since the ledger service is never mentioned, 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" - } -] -``` +`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. -## Types +## Core types -All shapes are defined and documented in `packages/core/src/types.ts` and re-exported from `@earsyntax/core`. The load-bearing ones: +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 | | ------------------ | --------------------------------------------------------------------------- | @@ -179,7 +209,7 @@ All shapes are defined and documented in `packages/core/src/types.ts` and re-exp | `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 diagnostic codes. | +| `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. | diff --git a/docs/quickstart.md b/docs/quickstart.md index d8412ec..6a88054 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,186 +1,209 @@ # Quickstart -This page takes you from an empty directory to a clean `earsyntax validate` in about five minutes, then shows the same result through the work-item loop a coding agent uses. Every command and its output below was captured from a real run. +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. +You need Node 22 or newer. No global install is required; `npx` fetches the CLI on +first use. -## Validate a file in five minutes +## Guest mode: validate a requirement with zero setup -### 1. Write three requirements +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: -Create `requirements.ears` with one requirement per line: +```bash +printf 'The billing service shall verify the HMAC signature of every incoming webhook.\n' \ + | npx @earsyntax/cli validate - --profile strict +``` ```text -When a payment webhook is received, the billing service shall verify the HMAC signature. -If the HMAC signature is invalid, the billing service shall reject the webhook. -While the payment provider is unavailable, the billing service shall queue retryable events. +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) ``` -### 2. Validate +The exit code is `0`. Now feed it a broken requirement, an `If` clause with no +`then`: ```bash -npx @earsyntax/cli validate requirements.ears +printf 'If the HMAC signature is invalid, the billing service shall reject the webhook.\n' \ + | npx @earsyntax/cli validate - --profile strict ``` -Line 2 is missing the `then` boundary that an `If ... then` requirement needs, so validation reports one error and exits `1`: - ```text -requirements.ears:2 error ears.invalid_if_then_form The 'If' clause is missing the required 'then' boundary. - -2/3 valid, 1 errors, 0 warnings +-: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) ``` -### 3. Read the diagnostic - -The line format is `file:line severity code message`. Here the code is `ears.invalid_if_then_form`, and the message names the fix: an `If` clause needs a `then` before the system response. The [diagnostics reference](diagnostics.md) documents every code and its severity. +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. -### 4. Fix and re-validate - -Add `then` to line 2: - -```text -If the HMAC signature is invalid, then the billing service shall reject the webhook. -``` - -Validate again: +The same guest check works on a file: ```bash -npx @earsyntax/cli validate requirements.ears +npx @earsyntax/cli validate requirements.ears --profile strict ``` -```text - -3/3 valid, 0 errors, 0 warnings -``` +## Host-native loop: validate and repair a Kiro spec -The exit code is now `0`. That is the CI signal: `0` means every requirement is clean, `1` means at least one error diagnostic remains. +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. -### Strict and guided modes +### 1. Detect what is in the repo -By default `validate` runs in `strict` mode, where structural defects are errors. In `guided` mode the same defects are downgraded to warnings, so a partly-formed file still exits `0`: +`doctor` reads the working directory and reports the hosts and agents it finds, +with the exact commands to run next. It never writes. ```bash -npx @earsyntax/cli validate requirements.ears --mode guided +earsyntax doctor ``` -Guided mode is useful while drafting; keep strict mode in CI. +```text +Repo: /repo -## The work-item loop +Hosts: + kiro .kiro/specs/ (profile kiro) -The commands above validate a file you already wrote. The work-item loop is the fuller path a coding agent follows to convert a source specification into a validated `.ears` file. The CLI scaffolds and validates, the agent writes requirements, and a human accepts. See [the agentic loop](agentic-loop.md) for the state machine and [the agent rules](agent-rules.md) for what the agent is told to do. +Recommended commands: + earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro + earsyntax init --agent claude --host kiro +``` -The run below uses [`specs/checkout.md`](../specs/checkout.md) as the source. +### 2. Install integration files -### 1. Initialize the workspace +`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 --tools none +earsyntax init --agent claude --host kiro ``` -```text -Initialized earsyntax in .earsyntax - wrote .earsyntax/config.json - wrote .earsyntax/work/.gitkeep - tools: none -``` +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 -### 2. Create a work item +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 new checkout-webhooks --source specs/checkout.md --mode convert +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json ``` -```text -Created work item "checkout-webhooks" (convert, scaffolded). - wrote .earsyntax/work/checkout-webhooks/requirements.ears - wrote .earsyntax/work/checkout-webhooks/questions.md - wrote .earsyntax/work/checkout-webhooks/traceability.json - wrote .earsyntax/work/checkout-webhooks/manifest.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" + } + ] +} ``` -`new` does not generate requirements. It hashes the source, reserves the output path, and writes empty artifacts. The status is `scaffolded`. +The `kiro` profile located the acceptance-criteria list items and ignored the +heading, the user story, and the surrounding prose. -### 3. Get the conversion rules +### 4. Validate ```bash -earsyntax instructions convert --work checkout-webhooks -``` - -```text -instructions convert for "checkout-webhooks" - - Read the full source before writing requirements. - - Write only EARS requirements in the .ears output file. - - Use one requirement per non-empty line. - - Give every requirement a stable ID such as REQ-001. - - Preserve source traceability with a [source: path:line] prefix when the source line is known. - - Use the narrowest EARS pattern that fits the source behavior. - - Do not invent behavior that is not stated in the source. - - Do not hide ambiguity inside vague wording. - - Split compound behavior into separate requirements when the response holds more than one observable obligation. - - Write unclear behavior to questions.md instead of guessing a precise requirement. - - Maintain traceability.json for every generated requirement and question. - - Do not edit the source spec unless the user explicitly asks. +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro ``` -With `--json` the same response carries source excerpts and a `next` action pointing at `validate`. The JSON contract is in [the facade API reference](facade-api.md). - -### 4. Write the requirements - -The agent reads the source and writes `.earsyntax/work/checkout-webhooks/requirements.ears`. Converting `specs/checkout.md` gives: +For a spec whose second criterion is written `IF the signature is invalid THE +SYSTEM SHALL reject the webhook` (no `then`), validation reports: ```text -REQ-001 [source: specs/checkout.md:7]: When a payment webhook is received, the billing service shall verify the HMAC signature. -REQ-002 [source: specs/checkout.md:10]: If the HMAC signature is invalid, then the billing service shall reject the webhook. -REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. -REQ-004 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. -REQ-005 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. -REQ-006 [source: specs/checkout.md:15]: While the payment provider is unavailable, the billing service shall retry queued events. -REQ-007 [source: specs/checkout.md:17]: Where dunning management is enabled, the billing service shall retry declined charges. +.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 compound sentence on lines 12 to 13 of the source ("validate the signature, persist the event, and enqueue a processing job") became three separate requirements, REQ-003 through REQ-005. The declined-payment behavior on line 19 uses the vague term "quickly", so the agent raises a question in `questions.md` rather than inventing a channel and a time bound. +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. Validate the work item +### 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 validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --work checkout-webhooks +earsyntax instructions repair --file ".kiro/specs/checkout/requirements.md" --profile kiro --json ``` -```text +The `rules` array includes one entry per reported id: -7/7 valid, 0 errors, 0 warnings +```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 + } + ] +} ``` -Because the target is a work item, `validate` records the result on the manifest and moves the status to `valid`. Had it reported errors, `earsyntax instructions repair --work checkout-webhooks` would return the diagnostics to fix, and you would repair and re-validate until clean. +### 6. Edit the host file and re-validate -### 6. Confirm the state - -```bash -earsyntax status checkout-webhooks -``` +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: -```text -checkout-webhooks: valid (convert) - source: specs/checkout.md - output: .earsyntax/work/checkout-webhooks/requirements.ears +```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 ``` -### 7. Accept - -Acceptance is a human decision, and the CLI enforces it: `accept` refuses unless the status is `valid`, the source is unchanged since validation, and the `.ears` file is unchanged since validation. +Validate again: ```bash -earsyntax accept checkout-webhooks --by "omer" +earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro ``` ```text -Accepted "checkout-webhooks" by omer. +3/3 valid across 1 file(s), 0 error(s), 0 warning(s) ``` -The work item is now `accepted`, with the accepting user and the source and output hashes recorded in the manifest. If the source later changes, `status` reports the item as `stale`, and the loop runs again against the new source. +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 common diagnostics. -- [API reference](api.md): `lintEars`, `lintEarsBatch`, `parseEars`, and `lintCatalogCoverage`. -- [CLI reference](cli.md): every command, its flags, and its output. +- [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. From 80f0e6e0dbb2a78dfdea3d8bbd107837196c34ab Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:17:36 +0300 Subject: [PATCH 17/20] style: apply prettier across the repo --- EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md | 2289 ----------------- EARSYNTAX-CLI-FACADE-ALPHA-0.md | 271 ++ ...ST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md | 1274 +++++++++ GRAMMAR-AGENT-BRIEF-FABLE.md | 215 ++ docs/contracts/findings.md | 40 +- docs/contracts/profile.md | 88 +- docs/diagnostics.md | 48 +- docs/refactor/host-native-facade.md | 141 +- docs/refactor/inventory.md | 66 +- fixtures/diagnostics/migration-table.json | 6 +- fixtures/profiles/README.md | 48 +- fixtures/profiles/kiro/NOTES.md | 12 +- .../profiles/kiro/requirements.expected.json | 4 +- fixtures/profiles/openspec/NOTES.md | 20 +- packages/cli-contract/src/exit-codes.ts | 4 +- packages/cli-contract/src/index.ts | 15 +- packages/cli-contract/src/sarif.test.ts | 77 +- .../cli-contract/test/sarif-schema-2.1.0.json | 184 +- packages/cli/src/cli.ts | 5 +- packages/cli/src/commands/doctor.test.ts | 16 +- packages/cli/src/commands/doctor.ts | 8 +- packages/cli/src/commands/explain.test.ts | 43 +- packages/cli/src/commands/explain.ts | 11 +- packages/cli/src/commands/extract.test.ts | 7 +- packages/cli/src/commands/extract.ts | 5 +- packages/cli/src/commands/init.test.ts | 12 +- packages/cli/src/commands/init.ts | 12 +- .../cli/src/commands/instructions.test.ts | 110 +- packages/cli/src/commands/instructions.ts | 4 +- packages/cli/src/commands/profiles.test.ts | 10 +- packages/cli/src/commands/validate.test.ts | 114 +- packages/cli/src/commands/validate.ts | 37 +- packages/cli/src/commands/version.ts | 5 +- packages/cli/src/response.ts | 7 +- packages/cli/src/rules.ts | 32 +- packages/cli/src/version.ts | 11 +- packages/core/src/expression-parser.ts | 10 +- packages/core/src/findings.test.ts | 4 +- packages/core/src/findings.ts | 4 +- packages/core/src/pipeline.test.ts | 28 +- packages/core/src/pipeline.ts | 7 +- packages/core/src/profiles/builtins.test.ts | 15 +- packages/core/src/profiles/builtins.ts | 3 +- packages/core/src/profiles/diff.test.ts | 4 +- packages/core/src/profiles/diff.ts | 5 +- packages/core/src/profiles/resolve.ts | 3 +- packages/core/src/profiles/schema.ts | 113 +- packages/core/src/registry.ts | 3 +- packages/core/test/profiles-schema.test.ts | 5 +- packages/extract/src/index.ts | 6 +- packages/extract/src/locator.test.ts | 8 +- packages/extract/src/locator.ts | 40 +- packages/extract/src/pipeline.test.ts | 23 +- packages/extract/src/pipeline.ts | 3 +- test/conformance/helpers.ts | 5 +- test/conformance/smoke.test.ts | 18 +- 56 files changed, 2704 insertions(+), 2854 deletions(-) delete mode 100644 EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md create mode 100644 EARSYNTAX-CLI-FACADE-ALPHA-0.md create mode 100644 EARSYNTAX-HOST-NATIVE-CLI-IMPLEMENTATION-PLAN-FABLE.md create mode 100644 GRAMMAR-AGENT-BRIEF-FABLE.md diff --git a/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md b/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md deleted file mode 100644 index eeeac0f..0000000 --- a/EARS-JS-CLAUDE-AGENT-ORCHESTRATION.md +++ /dev/null @@ -1,2289 +0,0 @@ -# EARS JS Claude Code Agent Orchestration - -This file is the operating brief for a Claude Code manager agent and the implementation agents it activates to build a Node.js and TypeScript toolkit for EARS, the Easy Approach to Requirements Syntax. - -The intended outcome is a deterministic parser, linter, extractor, and CLI facade for EARS requirements. The facade lets any coding agent convert natural-language requirements into `.ears` files through a controlled loop: get instructions, write the artifact, validate it, repair it, and hand it to a human for review. - -## Initiative Summary - -Build an EARS toolkit for Node.js: - -```text -@earsyntax/core parser, AST, linter, diagnostics -@earsyntax/extract Markdown, .ears, YAML, and JSON requirement extraction -@earsyntax/cli CLI facade for humans, scripts, and coding agents -``` - -The first release must be deterministic. It must not use an LLM, fuzzy matching, network calls, or runtime-specific plugins in the parser core. Agentic conversion from prose to `.ears` happens in the coding agent, guided by CLI-provided instructions and checked by CLI validation. - -## Source Anchors - -Use these references when defining behavior: - -| Source | Use | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| https://alistairmavin.com/ears/ | Canonical EARS pattern definitions and clause ordering principles. | -| https://pkg.go.dev/github.com/labeth/ears-lint-go | Practical Go reference for AST shape, diagnostics, catalog matching, and strict/guided behavior. | -| https://github.com/XpycT/ears-spec-engine | Useful TypeScript workflow inspiration, but not the parser core. | -| https://github.github.com/spec-kit/ | Agentic SDD reference: spec, clarify, plan, checklist, tasks, analyze, implement, converge. | -| https://openspec.dev/docs | CLI facade reference: setup, status, validate, instructions, JSON outputs for agents. | -| https://kiro.dev/docs/specs/ | Spec artifact reference: requirements, design, tasks, plus quick-spec mode. | -| https://github.com/dhruv-15-03/spec-kit-ears | Prior EARS extension reference: author, lint, convert, traceability, additive artifact rules. | -| `ears-research-claude.md` | Market, positioning, distribution, HN critique, and public-claim caveats. Not a parser authority. | - -## Manager Prompt - -Use this prompt for the Claude Code manager agent. - -```text -You are the orchestration manager for the EARS JS initiative. - -Goal: -Build a Node.js and TypeScript toolkit for EARS, the Easy Approach to Requirements Syntax. The first release must provide deterministic parsing, linting, diagnostics, extraction, and a CLI. - -Primary references: -- Canonical EARS guide: https://alistairmavin.com/ears/ -- Go parser/linter reference: https://pkg.go.dev/github.com/labeth/ears-lint-go -- TypeScript workflow inspiration: https://github.com/XpycT/ears-spec-engine -- Agentic SDD reference: https://github.github.com/spec-kit/ -- CLI facade reference: https://openspec.dev/docs -- Spec artifact reference: https://kiro.dev/docs/specs/ -- Prior EARS extension reference: https://github.com/dhruv-15-03/spec-kit-ears -- Local research brief: ears-research-claude.md - -Important exclusion: -- Ignore xmidt-org/ears for this initiative. It is Event Async Routing Service, not Easy Approach to Requirements Syntax. - -Your job: -1. Freeze the public contracts before parallel implementation starts. -2. Assign agents by ownership area. -3. Prevent agents from changing shared types, diagnostic codes, or fixture format without explicit manager approval. -4. Keep @earsyntax/core deterministic and runtime-independent. -5. Require every implementation agent to add tests with its code. -6. Integrate only after package-level tests pass. -7. Produce a release candidate with examples, docs, CLI verification, and an agent-facing spec-to-.ears loop. - -Default constraints: -- Use TypeScript. -- Preserve source spans in parser output and diagnostics. -- Prefer small hand-written parsers over regex-only validation. -- Keep parser/linter behavior deterministic. -- Do not add LLM calls to @earsyntax/core. -- Do not make @earsyntax/core depend on the CLI, file system, editor APIs, or workflow packages. -- The CLI binary is `earsyntax`, shipped by `@earsyntax/cli` from `packages/cli`. -- Do not call the command `ears-lint`; linting is one command within the `earsyntax` facade. -- Semantic derivation from natural-language specs is performed by the external coding agent, not by deterministic core. -``` - -## Product Boundary - -The project is not a Cucumber clone in v1. - -The project parses and validates requirements like: - -```text -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. -While the payment provider is unavailable, the billing service shall queue retryable events. -``` - -It returns structured results: - -```json -{ - "valid": true, - "pattern": "event-driven", - "ast": { - "pattern": "event-driven", - "trigger": { - "kind": "term", - "text": "a payment webhook is received" - }, - "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." - }, - "references": [], - "diagnostics": [] -} -``` - -The parser validates `.ears` files. It does not infer product intent from prose. - -The CLI facade owns the workflow around parser validation: - -```text -source spec markdown, PRD, issue, or prompt - -> earsyntax instructions author|convert - -> coding agent writes .ears - -> earsyntax validate - -> coding agent repairs - -> earsyntax validate --strict - -> human reviews - -> earsyntax accept -``` - -Later packages may generate acceptance-test skeletons or spec-driven documents, but that is separate from the parser. - -## Agentic Loop Boundary - -Earsyntax must keep three responsibilities separate. - -| Owner | Responsibility | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| CLI facade | Resolve paths, create work directories, return instructions, validate artifacts, report status, record acceptance, detect stale source hashes. | -| Coding agent | Read the natural-language source, decide which behaviors are requirements, write or repair `.ears` content, and surface questions when the source is ambiguous. | -| Human | Approve behavior, answer ambiguity questions, accept or reject generated `.ears`, and decide whether generated requirements should be copied into another spec system. | - -The CLI must not pretend to understand the source spec semantically. It can inspect structure, hash files, extract existing EARS statements, validate EARS grammar, and provide conversion rules. The coding agent performs prose-to-EARS translation. - -## Workflow Lessons From Other Tools - -Use these observations when designing the facade. - -| Tool | Lesson for Earsyntax | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Spec Kit | Agentic commands are workflow steps. Invocation differs by agent, so the durable contract must be files plus CLI output, not a slash-command name. | -| OpenSpec | Terminal commands should expose JSON for agents: `status`, `validate`, `instructions`, templates, and next steps. Human commands and agent commands can share a binary. | -| Kiro | Requirements, design, and tasks are separate artifacts. Quick paths are useful, but review gates matter when requirements quality matters. | -| `spec-kit-ears` | Author, lint, and convert are the right user verbs. EARS artifacts should be additive and traceable, and unclear source text should become questions instead of guessed requirements. | - -## Research Findings To Preserve - -The local research brief `ears-research-claude.md` is a positioning and ecosystem input. It should shape roadmap and launch priorities, but it must not override the parser contract. - -### Open Lane - -The practical opportunity is a deterministic, git-friendly, CI-friendly EARS parser/linter/facade for JavaScript and TypeScript projects. - -The research brief argues that the public EARS tooling ecosystem is fragmented: - -- many prompt packs, agent skills, and small editor helpers -- enterprise requirements-quality tools with GUI or vendor workflows -- no obvious dominant JS/TS package that acts like `eslint` for `.ears` -- no official formal grammar published by Mavin's site - -Treat this as the wedge: - -```text -Earsyntax is ESLint for EARS requirements. -It is deterministic, offline, scriptable, and agent-agnostic. -``` - -Do not frame v0.1 as a full requirements-management platform, a methodology, or an agentic IDE feature. - -### Grammar-First Moat - -The research brief's strongest technical recommendation is to publish a formal grammar as a product artifact. - -For implementation, this means: - -- `docs/grammar.md` is not optional polish; it is part of the product. -- the grammar should be written in a readable EBNF-like form before launch -- the test fixture corpus should be traceable to grammar rules -- unsupported edge cases should be documented as deliberate grammar choices -- future tree-sitter or language-server work should consume the same grammar decisions - -The grammar does not need to be the only possible interpretation of EARS. It needs to be stable, documented, deterministic, and aligned with Mavin's public rules. - -### Public Claim Policy - -The research brief contains discovery claims that may change or conflict with local/internal verification. Before public docs, launch posts, README claims, or issue comments mention ecosystem status, the release agent must reverify: - -- whether `labeth/ears-lint-go` is public and still available -- whether `XpycT/ears-spec-engine` or `ears-spec-engine` is public and still available -- current package names, stars, issues, and maintainer responses -- Spec Kit issue numbers, dates, and status -- Kiro, OpenSpec, StrictDoc, and related integration status - -Internal implementation can still use reachable repos as references. Public messaging must not say "does not exist," "unclaimed," "first," or "only" unless those claims are freshly checked near release. - -### Launch Positioning - -If this becomes public, lead with determinism and reproducibility: - -- zero LLM in parser/linter -- offline -- sub-second local CLI -- JSON output for agents -- CI exit codes -- source-to-`.ears` traceability -- stale-source detection -- a formal grammar and fixture corpus - -Avoid leading with "spec-driven development" as a methodology. HN and practitioner audiences may hear that as waterfall. Better framing: - -```text -Earsyntax is a deterministic lint gate for requirements text that teams and agents already write. -``` - -### Distribution Targets - -Keep the first integrations lightweight and additive: - -- Spec Kit: offer Earsyntax as the validator behind EARS-related commands or templates -- OpenSpec: provide an external validator/facade rather than a forked workflow -- Kiro: validate `.kiro/specs/**/requirements.md` or generated `.ears` files -- StrictDoc/Doorstop/sphinx-needs: position as a reusable grammar/linter engine -- Claude/Codex/Cursor: provide wrapper prompts or command files that call the same CLI facade - -Do not make any integration a hard dependency of `@earsyntax/core` or `@earsyntax/cli`. - -## Workspace Layout - -Default Earsyntax work should be self-contained and tool-agnostic. - -```text -.earsyntax/ - config.json - work/ - checkout-webhooks/ - manifest.json - source.md - requirements.ears - questions.md - validation.json - validation.md - traceability.json -``` - -The source file is not copied by default unless the user passes `--snapshot-source`. `manifest.json` stores the source path, source hash, output path, status, timestamps, and acceptance metadata. - -Recommended status states: - -```text -missing -> scaffolded -> drafted -> invalid -> valid -> accepted -> stale -``` - -`stale` means the source hash changed after the last valid or accepted `.ears` file. - -## Package Ownership - -| Package | Purpose | Depends On | -| -------------------- | --------------------------------------------------------------- | ---------------------------------------- | -| `@earsyntax/core` | Parse, lint, classify, catalog-match, and produce diagnostics. | None outside standard runtime libraries. | -| `@earsyntax/extract` | Extract EARS statements from common file formats. | `@earsyntax/core` types only. | -| `@earsyntax/cli` | Provide the `earsyntax` facade for humans, scripts, and agents. | `@earsyntax/core`, `@earsyntax/extract`. | - -## Shared Public API - -The architect agent owns this API. Other agents may propose changes but must not commit incompatible changes without manager approval. - -```ts -export function lintEars(text: string, catalog?: Catalog, options?: Options): LintResult; - -export function lintEarsBatch( - items: RequirementInput[], - catalog?: Catalog, - options?: Options, -): LintResult[]; - -export function parseEars(text: string, catalog?: Catalog, options?: Options): ParseResult; -``` - -Recommended types: - -```ts -export type Mode = 'strict' | 'guided'; - -export type Pattern = - | 'ubiquitous' - | 'state-driven' - | 'event-driven' - | 'optional-feature' - | 'unwanted-behaviour' - | 'complex'; - -export interface Options { - mode?: Mode; - commaAsAnd?: boolean; - vagueTerms?: string[]; -} - -export interface RequirementInput { - id?: string; - text: string; - source?: SourceLocation; -} - -export interface LintResult { - id?: string; - valid: boolean; - pattern?: Pattern; - ast?: EarsAst; - references: ReferenceMatch[]; - diagnostics: Diagnostic[]; -} - -export interface ParseResult { - pattern?: Pattern; - ast?: EarsAst; - diagnostics: Diagnostic[]; -} - -export interface EarsAst { - pattern: Pattern; - preconditions?: ClauseExpr; - trigger?: ClauseExpr; - feature?: ClauseExpr; - unwanted?: ClauseExpr; - system: TermMatch; - responses: string[]; - raw: string; -} - -export type ClauseExpr = TermExpr | AndExpr | OrExpr | NotExpr | GroupExpr | FreeTextExpr; - -export interface TermExpr { - kind: 'term'; - span?: Span; - text: string; - term?: TermMatch; -} - -export interface AndExpr { - kind: 'and'; - span?: Span; - items: ClauseExpr[]; -} - -export interface OrExpr { - kind: 'or'; - span?: Span; - items: ClauseExpr[]; -} - -export interface NotExpr { - kind: 'not'; - span?: Span; - item: ClauseExpr; -} - -export interface GroupExpr { - kind: 'group'; - span?: Span; - item: ClauseExpr; -} - -export interface FreeTextExpr { - kind: 'free-text'; - span?: Span; - text: string; -} - -export interface Span { - start: number; - end: number; -} -``` - -## Supported EARS Shell Patterns - -The core parser must support these shell patterns in v1: - -| Pattern | Template | -| ------------------ | ---------------------------------------------------------- | -| Ubiquitous | `The shall ` | -| State-driven | `While , the shall ` | -| Event-driven | `When , the shall ` | -| Optional feature | `Where , the shall ` | -| Unwanted behaviour | `If , then the shall ` | -| Complex | Any supported combination with more than one shell clause. | - -Default accepted shell clause order: - -```text -While* -> Where* -> When* -> If* -> the shall -``` - -The parser should be case-insensitive for shell keywords: - -```text -while -WHILE -While -The -THE -shall -SHALL -``` - -The formatter may choose a house style, but parsing must not require uppercase keywords. - -## Clause Expression Grammar - -Inside `While`, `Where`, `When`, and `If` clauses, support: - -```text -A and B -A or B -not A -(A or B) and C -``` - -Precedence: - -```text -not > and > or -``` - -Comma handling: - -| Option | Behavior | -| ------------------- | ---------------------------------------------------------------------- | -| `commaAsAnd: false` | A comma ends a shell clause only. | -| `commaAsAnd: true` | Commas inside clause bodies may be treated as `and` where unambiguous. | - -## Catalog Matching - -Catalog matching must be deterministic. - -```ts -export interface Catalog { - systems?: CatalogEntry[]; - actors?: CatalogEntry[]; - events?: CatalogEntry[]; - states?: CatalogEntry[]; - features?: CatalogEntry[]; - modes?: CatalogEntry[]; - conditions?: CatalogEntry[]; - dataTerms?: CatalogEntry[]; -} - -export interface CatalogEntry { - id: string; - name: string; - aliases?: string[]; -} -``` - -Match policy: - -1. Exact canonical name. -2. Exact alias. -3. Ambiguous if more than one entry matches. -4. Unresolved if no entry matches. - -No fuzzy matching in v1. - -## Diagnostic Codes - -The diagnostics agent owns the registry. These codes are the initial shared set. - -| Code | Meaning | -| ---------------------------------- | ------------------------------------------------------------------- | -| `ears.no_match` | The text does not match any supported EARS shell. | -| `ears.invalid_clause_order` | Shell clauses appear in an unsupported order. | -| `ears.missing_system` | The system name is absent or empty. | -| `ears.missing_shall` | The requirement does not contain a valid `shall` response boundary. | -| `ears.multiple_shall` | The requirement contains more than one shell-level `shall`. | -| `ears.invalid_if_then_form` | An `If` clause is missing the required `then` boundary. | -| `ears.empty_clause` | A `While`, `Where`, `When`, or `If` clause body is empty. | -| `ears.empty_response` | The response after `shall` is empty. | -| `expr.unbalanced_parentheses` | Parentheses in a clause expression are not balanced. | -| `expr.invalid_operator_sequence` | Operators such as `and`, `or`, or `not` are malformed. | -| `expr.empty_subexpression` | A grouped expression or operator operand is empty. | -| `expr.operator_precedence_warning` | A mixed `and` and `or` expression may need parentheses. | -| `expr.unknown_term` | A clause term cannot be matched to the expected catalog role. | -| `expr.ambiguous_term` | A clause term matches more than one catalog entry. | -| `catalog.system_unresolved` | The system name does not match a known system. | -| `catalog.system_ambiguous` | The system name matches more than one known system. | -| `catalog.state_unresolved` | A state term is unknown. | -| `catalog.event_unresolved` | An event term is unknown. | -| `catalog.feature_unresolved` | A feature term is unknown. | -| `lint.multiple_responses` | The response contains multiple semicolon-separated responses. | -| `lint.vague_response` | The response contains a configured vague term. | -| `lint.unparsed_tail` | Text remains after the parsed requirement. | -| `lint.alias_used` | A catalog alias matched, but canonical naming is preferred. | -| `lint.suspicious_text_shape` | The sentence shape is likely accidental or malformed. | - -Severity rules: - -| Mode | Behavior | -| -------- | --------------------------------------------------------------------------------- | -| `strict` | Structural parse failures and unresolved systems are errors. | -| `guided` | Structural parse failures may be warnings where partial AST recovery is possible. | - -`valid` is `false` if any diagnostic has severity `error`; otherwise `true`. - -## Input Formats - -The extractor package must support common authoring locations. - -### `.ears` - -One requirement per non-empty line: - -```text -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. -``` - -### Markdown - -Support bullets, numbered lists, and tables: - -```md -- 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. -``` - -```md -| ID | Requirement | -| ------- | ---------------------------------------------------------------------------------------- | -| REQ-001 | When a payment webhook is received, the billing service shall verify the HMAC signature. | -``` - -### YAML - -```yaml -requirements: - - id: REQ-001 - text: When a payment webhook is received, the billing service shall verify the HMAC signature. -``` - -### JSON - -```json -{ - "requirements": [ - { - "id": "REQ-001", - "text": "When a payment webhook is received, the billing service shall verify the HMAC signature." - } - ] -} -``` - -## CLI Facade Contract - -The CLI agent owns the `earsyntax` binary. This is the stable facade that humans, scripts, and agent-specific wrappers use. - -The package is: - -```text -packages/cli -@earsyntax/cli -``` - -The binary is: - -```bash -earsyntax -``` - -Do not expose `ears-lint` as the primary command. Linting and validation are subcommands of `earsyntax`. - -### Facade Principles - -1. Every agent-compatible command supports `--json`. -2. Every JSON response includes `version`, `command`, `ok`, and `next`. -3. Commands that write files state every written path in JSON. -4. Commands that inspect or validate never modify files. -5. `instructions` commands are read-only. -6. `validate` is deterministic and never asks questions. -7. Agent wrappers are thin: they call `earsyntax instructions`, write files, then call `earsyntax validate`. -8. The CLI never silently overwrites a human-edited `.ears` file. -9. The CLI never edits the source spec by default. -10. Acceptance records source hashes so changed specs become `stale`. - -### Global Options - -These options should work where they make sense: - -```bash -earsyntax --json -earsyntax --no-color -earsyntax --cwd -earsyntax --config -earsyntax --no-interactive -``` - -### Command Summary - -| Command | Purpose | Agent-Compatible | -| ------------------------ | --------------------------------------------------------------- | ---------------- | -| `earsyntax init` | Initialize `.earsyntax/` and optional agent command/rule files. | Yes | -| `earsyntax doctor` | Read-only project health report. | Yes | -| `earsyntax version` | Version and feature discovery. | Yes | -| `earsyntax new` | Create a work item for a source spec or prompt. | Yes | -| `earsyntax list` | List EARS work items. | Yes | -| `earsyntax status` | Show one work item's state and next steps. | Yes | -| `earsyntax instructions` | Return the exact rules an agent should follow for a step. | Yes | -| `earsyntax validate` | Validate `.ears` files and emit diagnostics. | Yes | -| `earsyntax accept` | Mark a valid `.ears` artifact as human-accepted. | Partly | -| `earsyntax show` | Show resolved artifact paths or content. | Yes | -| `earsyntax export` | Export accepted `.ears` to another representation. | Later | - -### `earsyntax init` - -Initialize Earsyntax in the current project. - -```bash -earsyntax init -earsyntax init --tools claude,codex,cursor -earsyntax init --tools none --json -earsyntax init --force -``` - -Expected writes: - -```text -.earsyntax/config.json -.earsyntax/work/.gitkeep -``` - -Optional tool writes: - -```text -.claude/commands/earsyntax-author.md -.claude/commands/earsyntax-convert.md -.claude/commands/earsyntax-repair.md -AGENTS.md section or tool-specific equivalent -``` - -Tool support is a convenience layer. The workflow must still work when `--tools none` is used. - -JSON shape: - -```json -{ - "version": "0.1.0", - "command": "init", - "ok": true, - "root": "/repo", - "written": [".earsyntax/config.json", ".earsyntax/work/.gitkeep"], - "tools": ["claude", "codex"], - "next": [ - { - "command": "earsyntax new checkout-webhooks --source specs/checkout.md --json", - "reason": "Create a work item for a source spec" - } - ] -} -``` - -### `earsyntax doctor` - -Report project health without making changes. - -```bash -earsyntax doctor -earsyntax doctor --json -``` - -Checks: - -- `.earsyntax/config.json` exists and parses -- configured work directory exists -- package version supports the requested facade features -- referenced sources still exist -- accepted work items are not stale -- tool wrapper files are present if configured - -### `earsyntax version` - -Expose installed capabilities for scripts and agents. - -```bash -earsyntax version -earsyntax version --features -earsyntax version --features --json -``` - -Feature JSON should let an agent choose a supported path without guessing: - -```json -{ - "version": "0.1.0", - "command": "version", - "ok": true, - "features": { - "facade": 1, - "workItems": true, - "instructions": ["author", "convert", "repair", "review"], - "inputFormats": [".ears", "markdown", "yaml", "json"], - "outputFormats": ["pretty", "json"], - "sarif": false - }, - "next": [] -} -``` - -### `earsyntax new` - -Create a work item. This command does not generate EARS content. It prepares the place where the agent will write it. - -Convert an existing source spec: - -```bash -earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json -``` - -Author from a prompt: - -```bash -earsyntax new checkout-webhooks --prompt "Checkout webhooks must validate signatures and record successful payments." --mode author --json -``` - -Use an explicit output path: - -```bash -earsyntax new checkout-webhooks --source .kiro/specs/checkout/requirements.md --out .kiro/specs/checkout/requirements.ears --json -``` - -Default output: - -```text -.earsyntax/work//requirements.ears -``` - -Default work item files: - -```text -.earsyntax/work//manifest.json -.earsyntax/work//requirements.ears -.earsyntax/work//questions.md -.earsyntax/work//traceability.json -``` - -JSON shape: - -```json -{ - "version": "0.1.0", - "command": "new", - "ok": true, - "work": { - "id": "checkout-webhooks", - "mode": "convert", - "status": "scaffolded", - "source": "specs/checkout.md", - "sourceHash": "sha256:...", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears" - }, - "written": [ - ".earsyntax/work/checkout-webhooks/manifest.json", - ".earsyntax/work/checkout-webhooks/requirements.ears", - ".earsyntax/work/checkout-webhooks/questions.md", - ".earsyntax/work/checkout-webhooks/traceability.json" - ], - "next": [ - { - "command": "earsyntax instructions convert --work checkout-webhooks --json", - "reason": "Give the coding agent rules for writing the .ears file" - } - ] -} -``` - -### `earsyntax instructions` - -Return agent-facing instructions. This is the key facade command. - -```bash -earsyntax instructions author --work checkout-webhooks --json -earsyntax instructions convert --work checkout-webhooks --json -earsyntax instructions repair --work checkout-webhooks --json -earsyntax instructions review --work checkout-webhooks --json -``` - -Instruction modes: - -| Mode | Use | -| --------- | ---------------------------------------------------------- | -| `author` | Turn a prompt or brief into a first `.ears` draft. | -| `convert` | Convert an existing Markdown/spec/PRD source into `.ears`. | -| `repair` | Repair a `.ears` file after validation diagnostics. | -| `review` | Produce a human review checklist for a valid `.ears` file. | - -JSON shape: - -```json -{ - "version": "0.1.0", - "command": "instructions convert", - "ok": true, - "work": { - "id": "checkout-webhooks", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "questions": ".earsyntax/work/checkout-webhooks/questions.md", - "traceability": ".earsyntax/work/checkout-webhooks/traceability.json" - }, - "rules": [ - "Read the source before writing requirements.", - "Write only EARS requirements in the .ears output file.", - "Use one requirement per line.", - "Give every requirement a stable ID.", - "Preserve source traceability with source file and line references when possible.", - "Do not invent behavior that is not present in the source.", - "Do not hide ambiguity inside vague wording.", - "Split compound behavior into separate requirements.", - "Use questions.md for unclear behavior instead of guessing.", - "Do not edit the source spec unless the user explicitly asks." - ], - "format": { - "line": "REQ-001 [source: specs/checkout.md:14]: When , the shall .", - "allowedPatterns": [ - "The shall .", - "When , the shall .", - "While , the shall .", - "Where , the shall .", - "If , then the shall ." - ] - }, - "next": [ - { - "command": "earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json", - "reason": "Validate the generated .ears file" - } - ] -} -``` - -### `earsyntax validate` - -Validate `.ears` files deterministically. - -```bash -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json -earsyntax validate "specs/**/*.ears" --catalog catalog.json --format json -earsyntax validate "specs/**/*.ears" --format sarif -earsyntax validate requirements.ears --mode guided -earsyntax validate requirements.ears --mode strict -earsyntax validate requirements.ears --comma-as-and -``` - -Validation responsibilities: - -- parse EARS shell syntax -- parse clause expressions -- apply catalog matching when a catalog is supplied -- validate stable IDs -- validate optional source references -- detect vague terms -- detect multiple responses -- detect source-hash staleness when run against a work item -- emit diagnostics and next steps - -JSON shape: - -```json -{ - "version": "0.1.0", - "command": "validate", - "ok": false, - "summary": { - "files": 1, - "requirements": 7, - "valid": 5, - "errors": 2, - "warnings": 1 - }, - "results": [ - { - "id": "REQ-002", - "file": ".earsyntax/work/checkout-webhooks/requirements.ears", - "line": 2, - "valid": false, - "pattern": "unwanted-behaviour", - "diagnostics": [ - { - "code": "ears.invalid_if_then_form", - "severity": "error", - "message": "If requirements must include a then boundary before the system response.", - "span": { "start": 0, "end": 42 } - } - ] - } - ], - "next": [ - { - "command": "earsyntax instructions repair --work checkout-webhooks --json", - "reason": "Give the coding agent targeted repair rules" - } - ] -} -``` - -### `earsyntax status` - -Show the state of a work item. - -```bash -earsyntax status -earsyntax status checkout-webhooks -earsyntax status checkout-webhooks --json -``` - -JSON shape: - -```json -{ - "version": "0.1.0", - "command": "status", - "ok": true, - "work": { - "id": "checkout-webhooks", - "status": "valid", - "source": "specs/checkout.md", - "output": ".earsyntax/work/checkout-webhooks/requirements.ears", - "sourceHash": "sha256:...", - "acceptedHash": null, - "stale": false - }, - "next": [ - { - "command": "earsyntax instructions review --work checkout-webhooks --json", - "reason": "Prepare human review" - }, - { - "command": "earsyntax accept checkout-webhooks", - "reason": "Record acceptance after human approval" - } - ] -} -``` - -### `earsyntax accept` - -Record that a human accepted the generated `.ears` file. - -```bash -earsyntax accept checkout-webhooks -earsyntax accept checkout-webhooks --by "omer" --json -``` - -Rules: - -- refuse if current status is not `valid` -- refuse if the source hash is stale -- write acceptance metadata to `manifest.json` -- do not modify the source spec -- do not modify the `.ears` content - -### `earsyntax show` - -Resolve paths or print content. - -```bash -earsyntax show checkout-webhooks --json -earsyntax show checkout-webhooks --artifact requirements -earsyntax show checkout-webhooks --artifact questions -earsyntax show checkout-webhooks --artifact validation --json -``` - -### `earsyntax list` - -List work items. - -```bash -earsyntax list -earsyntax list --json -earsyntax list --status stale --json -``` - -### `earsyntax export` - -Planned, not required for v0.1. Export accepted `.ears` into other systems without making those systems dependencies of core. - -Potential forms: - -```bash -earsyntax export checkout-webhooks --format markdown -earsyntax export checkout-webhooks --format json -earsyntax export checkout-webhooks --format speckit -earsyntax export checkout-webhooks --format openspec -earsyntax export checkout-webhooks --format kiro -``` - -### Exit Codes - -| Code | Meaning | -| ---- | ---------------------------------------------------------------------------------------------------------- | -| `0` | Command completed and no error diagnostics were produced. | -| `1` | Validation completed and at least one error diagnostic exists. | -| `2` | CLI usage, config, missing file, unparseable JSON/YAML, or work item resolution error. | -| `3` | Refused write, stale source, overwrite protection, or human confirmation required in non-interactive mode. | - -### Agentic Conversion Loop - -Agent-specific slash commands or skills should be wrappers around this sequence: - -```bash -earsyntax new checkout-webhooks --source specs/checkout.md --mode convert --json -earsyntax instructions convert --work checkout-webhooks --json -# agent writes .earsyntax/work/checkout-webhooks/requirements.ears -earsyntax validate .earsyntax/work/checkout-webhooks/requirements.ears --source specs/checkout.md --json -# if invalid: -earsyntax instructions repair --work checkout-webhooks --json -# agent repairs and reruns validate until clean -earsyntax status checkout-webhooks --json -``` - -The loop ends with a human review and explicit acceptance: - -```bash -earsyntax instructions review --work checkout-webhooks --json -earsyntax accept checkout-webhooks -``` - -## Facade Data Contracts - -All facade JSON should be stable, camelCase, and path-explicit. Paths are relative to the resolved project root unless an absolute path was supplied. - -### Base Response - -Every `--json` command returns this shape: - -```ts -export interface FacadeResponse { - version: string; - command: string; - ok: boolean; - root?: string; - data?: T; - diagnostics?: FacadeDiagnostic[]; - next: NextAction[]; -} - -export interface NextAction { - command: string; - reason: string; - blocking?: boolean; - forAgent?: boolean; -} - -export interface FacadeDiagnostic { - code: string; - severity: 'error' | 'warning' | 'info'; - message: string; - path?: string; - line?: number; -} -``` - -Command-specific examples may also expose top-level convenience keys such as `work`, `written`, `summary`, and `results`, but the base fields must always exist. - -### Work Manifest - -`earsyntax new` creates and later commands update this manifest: - -```ts -export type WorkMode = 'author' | 'convert'; - -export type WorkStatus = - 'missing' | 'scaffolded' | 'drafted' | 'invalid' | 'valid' | 'accepted' | 'stale'; - -export interface WorkManifest { - schemaVersion: 1; - id: string; - mode: WorkMode; - status: WorkStatus; - source?: { - path?: string; - hash?: string; - kind: 'markdown' | 'yaml' | 'json' | 'text' | 'prompt'; - snapshotPath?: string; - }; - prompt?: string; - output: { - path: string; - hash?: string; - }; - artifacts: { - questions: string; - traceability: string; - validationJson: string; - validationMarkdown: string; - }; - createdAt: string; - updatedAt: string; - accepted?: { - at: string; - by?: string; - sourceHash?: string; - outputHash: string; - }; -} -``` - -### Instruction Response - -`earsyntax instructions --json` returns: - -```ts -export interface InstructionResponse { - work: WorkSummary; - mode: 'author' | 'convert' | 'repair' | 'review'; - rules: string[]; - format: { - line: string; - allowedPatterns: string[]; - metadataPrefixes: string[]; - }; - source?: { - path?: string; - hash?: string; - excerpts?: SourceExcerpt[]; - }; - diagnostics?: FacadeDiagnostic[]; - next: NextAction[]; -} - -export interface WorkSummary { - id: string; - status: WorkStatus; - source?: string; - output: string; - questions: string; - traceability: string; -} - -export interface SourceExcerpt { - path: string; - startLine: number; - endLine: number; - text: string; -} -``` - -### Validation Response - -`earsyntax validate --json` returns: - -```ts -export interface ValidationResponse { - summary: { - files: number; - requirements: number; - valid: number; - errors: number; - warnings: number; - }; - results: ValidationResult[]; - work?: WorkSummary; - stale?: boolean; - next: NextAction[]; -} - -export interface ValidationResult { - id?: string; - file: string; - line?: number; - valid: boolean; - pattern?: Pattern; - ast?: EarsAst; - references: ReferenceMatch[]; - diagnostics: Diagnostic[]; -} -``` - -### Traceability Contract - -Agents maintain traceability; the CLI validates only structure and references when possible. - -```ts -export interface TraceabilityFile { - requirements: RequirementTrace[]; - questions: QuestionTrace[]; -} - -export interface RequirementTrace { - id: string; - source?: { - path: string; - startLine?: number; - endLine?: number; - quote?: string; - }; - pattern?: Pattern; - confidence?: 'high' | 'medium' | 'low'; - notes?: string[]; -} - -export interface QuestionTrace { - source?: { - path: string; - startLine?: number; - endLine?: number; - }; - question: string; - blocksRequirements?: string[]; -} -``` - -## Spec-To-EARS Agent Rules - -These rules are what `earsyntax instructions author|convert|repair|review` should return to coding agents. Agent-specific command files may summarize them, but the CLI JSON is the source of truth. - -### Read The Source First - -Before writing `.ears`, the agent must read the full source artifact: - -- Spec Kit `spec.md` -- OpenSpec `proposal.md`, delta specs, or `spec.md` -- Kiro `requirements.md` -- PRD Markdown -- issue description -- design brief -- any user-supplied Markdown, YAML, JSON, or plain text - -The agent must identify: - -- actors and users -- systems or components under requirement -- events that trigger behavior -- states where behavior applies -- optional features or feature flags -- unwanted or error conditions -- observable system responses -- constraints that are genuinely requirements -- ambiguous source statements that require human clarification - -### Write Only Requirements - -The `.ears` file should contain one requirement per non-empty line. No explanatory paragraphs belong in the `.ears` file. - -Use this line format: - -```text -REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature. -``` - -Allowed metadata prefix: - -```text -REQ-001: -REQ-001 [source: path:line]: -REQ-001 [source: path:line-line]: -``` - -The parser must accept bare requirement lines without metadata, but agent-generated `.ears` files should include stable IDs and source references whenever possible. - -### Classify Each Requirement - -Use the narrowest EARS pattern that fits the source behavior. - -| Source Shape | EARS Pattern | Template | -| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | -| Always true system behavior | Ubiquitous | `The shall .` | -| Triggered by an event | Event-driven | `When , the shall .` | -| Applies only during a state | State-driven | `While , the shall .` | -| Applies only when a feature exists or is enabled | Optional feature | `Where , the shall .` | -| Handles an error, invalid input, threat, or unwanted condition | Unwanted behaviour | `If , then the shall .` | -| Requires multiple shell clauses | Complex | `While ..., when ..., the shall ...` | - -Do not force everything into `When`. If the source says behavior applies during maintenance mode, use `While`. If the source says behavior applies only with enterprise SSO enabled, use `Where`. If the source describes an invalid or exceptional condition, use `If ..., then`. - -### Split Compound Source Text - -Split compound source requirements into separate EARS requirements when the response contains more than one observable obligation. - -Source: - -```text -When a webhook arrives, validate the signature, persist the event, and enqueue a job. -``` - -Generated: - -```text -REQ-001 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall validate the signature. -REQ-002 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall persist the event. -REQ-003 [source: specs/checkout.md:12]: When a webhook arrives, the billing service shall enqueue a processing job. -``` - -Do not split when the second phrase is only a qualifier of the first response. - -### Do Not Invent Behavior - -The agent must not add behavior because it seems reasonable, common, secure, scalable, or useful. If the source does not state the behavior, the `.ears` file should not contain it. - -Examples of forbidden inventions: - -- adding audit logging because security is mentioned -- adding retry behavior because webhooks are involved -- adding rate limits because the endpoint is public -- adding admin permissions because a dashboard is mentioned -- adding database persistence because a UI displays data - -If the missing behavior matters, write a question. - -### Put Ambiguity In Questions - -When source text is vague, conflicting, or underspecified, the agent must write questions to `questions.md`, not invent a precise requirement. - -Question format: - -```md -- [REQ?] Source `specs/checkout.md:18` says "notify the customer quickly." What channel should be used, and what is the time bound? -``` - -Ambiguity triggers: - -- vague terms such as `fast`, `quickly`, `appropriate`, `user-friendly`, `robust`, `as needed` -- missing actor or system -- missing trigger -- missing error response -- conflicting source statements -- implementation detail without observable behavior -- behavior that depends on product judgment - -### Preserve Traceability - -The agent should maintain `traceability.json` for generated requirements. - -```json -{ - "requirements": [ - { - "id": "REQ-001", - "source": { - "path": "specs/checkout.md", - "startLine": 12, - "endLine": 12, - "quote": "When a webhook arrives, validate the signature..." - }, - "pattern": "event-driven", - "confidence": "high", - "notes": [] - } - ], - "questions": [ - { - "source": { - "path": "specs/checkout.md", - "startLine": 18 - }, - "question": "What notification channel should be used?", - "blocksRequirements": ["REQ-007"] - } - ] -} -``` - -Traceability is not a substitute for validation. It gives the human reviewer a way to decide whether the agent faithfully translated the source. - -### Repair From Diagnostics - -When `earsyntax validate --json` returns diagnostics, the agent must repair the `.ears` file by changing only what the diagnostics justify. - -Repair rules: - -- preserve requirement IDs unless the duplicate ID diagnostic requires a change -- preserve source references -- preserve the intended behavior -- do not delete failing requirements to make validation pass -- do not weaken a requirement because the wording is harder to parse -- if the intended behavior is unclear, write a question and leave a clear placeholder instead of guessing - -### Review Before Accept - -Before `earsyntax accept`, the agent should present a review summary: - -- how many requirements were generated -- pattern distribution -- unresolved questions -- validation status -- source lines with low-confidence translation -- any behavior that was intentionally not converted - -Acceptance is a human decision. Agents can recommend acceptance only when validation is clean and no blocking questions remain. - -## Agent Roster - -The manager activates agents in this order unless a dependency forces a change. - -### 1. Architect Agent - -Mission: Freeze the shared contracts and package boundaries. - -Owns: - -- package graph -- public TypeScript types -- exported API names -- fixture schema -- compatibility policy -- versioning policy - -Must not: - -- implement parser internals before contracts are reviewed -- add runtime dependencies to `@earsyntax/core` - -Deliverables: - -- `packages/core/src/types.ts` -- `packages/core/src/index.ts` -- `fixtures/schema.md` -- `docs/public-api.md` - -Acceptance: - -- all downstream agents can implement against the types without inventing new public shapes -- example JSON output is included -- manager approves API freeze - -### 2. Grammar Agent - -Mission: Convert EARS references into a formal implementation matrix. - -Owns: - -- accepted shell patterns -- clause order rules -- strict and guided parsing examples -- invalid example catalog -- edge-case matrix - -Must not: - -- implement production parser code -- create new patterns without marking them experimental - -Deliverables: - -- `fixtures/valid/*.json` -- `fixtures/invalid/*.json` -- `docs/grammar.md` - -Acceptance: - -- every canonical Mavin pattern has at least three valid examples -- unwanted behaviour has first-class examples -- complex requirements include multi-clause examples -- invalid fixtures name expected diagnostic codes - -### 3. Shell Parser Agent - -Mission: Implement the outer EARS sentence parser. - -Owns: - -- shell tokenization -- clause boundary detection -- pattern classification -- system and response extraction -- shell-level spans - -Must not: - -- parse boolean internals beyond passing clause text to the expression parser -- perform catalog matching -- emit diagnostics not registered by the diagnostics agent - -Deliverables: - -- `packages/core/src/shell-parser.ts` -- shell parser tests - -Acceptance: - -- parses all supported shell patterns -- classifies complex requirements correctly -- preserves spans for clauses, system, and response -- catches missing `shall`, multiple `shall`, missing system, empty response, and invalid `If ... then ...` - -### 4. Expression Parser Agent - -Mission: Parse boolean-like expressions inside EARS clauses. - -Owns: - -- expression tokenizer -- `not`, `and`, `or`, and parentheses -- expression AST -- expression spans -- precedence diagnostics - -Must not: - -- classify EARS shell patterns -- perform system parsing -- perform file extraction - -Deliverables: - -- `packages/core/src/expression-parser.ts` -- expression parser tests - -Acceptance: - -- implements `not > and > or` -- handles parenthesized groups -- reports unbalanced parentheses -- reports invalid operator sequences -- preserves spans for terms and groups - -### 5. Catalog Agent - -Mission: Implement deterministic term matching. - -Owns: - -- catalog normalization -- exact name matching -- exact alias matching -- ambiguous and unresolved references -- role-specific matching helpers - -Must not: - -- add fuzzy matching -- add semantic matching -- change catalog type names without architect approval - -Deliverables: - -- `packages/core/src/catalog.ts` -- catalog tests - -Acceptance: - -- exact canonical matches win -- exact alias matches are identified as aliases -- ambiguous matches list all candidates -- unresolved terms produce deterministic results -- diagnostics are stable across repeated runs - -### 6. Diagnostics Agent - -Mission: Own diagnostic construction, severity, ordering, and messages. - -Owns: - -- diagnostic code registry -- strict/guided severity mapping -- stable diagnostic sorting -- diagnostic helper functions -- message wording - -Must not: - -- couple diagnostics to CLI output -- remove a diagnostic code used by fixtures without manager approval - -Deliverables: - -- `packages/core/src/diagnostics.ts` -- `docs/diagnostics.md` -- diagnostic tests - -Acceptance: - -- diagnostics sort by span, code, message, and severity -- `valid` is derived only from error severity -- strict/guided behavior is covered by tests -- every emitted code is documented - -### 7. Core Integration Agent - -Mission: Connect shell parsing, expression parsing, catalog matching, and diagnostics into the public API. - -Owns: - -- `lintEars` -- `lintEarsBatch` -- `parseEars` -- default options -- response splitting -- vague-term linting - -Must not: - -- change parser internals without the owning agent -- add CLI or file-system concerns to core - -Deliverables: - -- `packages/core/src/lint.ts` -- `packages/core/src/options.ts` -- integration tests - -Acceptance: - -- `lintEars` returns complete `LintResult` -- `lintEarsBatch` preserves input order -- default vague terms are configurable -- semicolon-separated responses are split and flagged -- no external calls or nondeterministic behavior - -### 8. Extractor Agent - -Mission: Extract requirement inputs from files people actually write. - -Owns: - -- `.ears` extractor -- Markdown bullet/list/table extractor -- YAML extractor -- JSON extractor -- source location metadata - -Must not: - -- lint extracted statements directly -- duplicate parser behavior - -Deliverables: - -- `packages/extract/src/index.ts` -- extractor tests -- `docs/input-formats.md` - -Acceptance: - -- extracts IDs and text from supported formats -- preserves source file and line information -- ignores non-requirement prose safely -- reports malformed structured files clearly - -### 9. Agentic Facade Agent - -Mission: Define the tool-agnostic loop that lets any coding agent derive `.ears` files from source specs. - -Owns: - -- `earsyntax instructions author` -- `earsyntax instructions convert` -- `earsyntax instructions repair` -- `earsyntax instructions review` -- work item status model -- manifest schema -- traceability schema -- source-to-EARS rules -- agent wrapper contracts - -Must not: - -- implement semantic prose-to-EARS conversion inside deterministic CLI code -- require Claude, Codex, Cursor, Kiro, or any single agent runtime -- let generated artifacts overwrite source specs by default -- blur human acceptance with validation success - -Deliverables: - -- `docs/agentic-loop.md` -- `docs/facade-api.md` -- `docs/agent-rules.md` -- manifest and traceability fixture examples -- instruction JSON fixtures - -Acceptance: - -- a generic coding agent can follow `earsyntax instructions convert --json` without additional hidden context -- instructions clearly separate source reading, `.ears` writing, validation, repair, questions, and review -- source hash and stale-state behavior are specified -- every instruction response includes next commands -- agent-specific slash commands can be implemented as thin wrappers - -### 10. CLI Agent - -Mission: Build the user-facing command-line tool. - -Owns: - -- argument parsing -- glob/file loading -- catalog loading -- `earsyntax` binary wiring -- work item file operations -- pretty, JSON, and later SARIF output -- exit codes -- config file discovery -- `init`, `doctor`, `version`, `new`, `list`, `status`, `instructions`, `validate`, `accept`, `show` - -Must not: - -- reimplement parser rules -- hide core diagnostics -- expose `ears-lint` as the primary binary -- mutate source specs unless the user explicitly requests a write - -Deliverables: - -- `packages/cli/src/cli.ts` -- CLI tests -- `docs/cli.md` -- `docs/facade-api.md` - -Acceptance: - -- supports `.ears`, Markdown, YAML, and JSON inputs through `@earsyntax/extract` -- exits `1` when core reports any error -- exits `2` on CLI usage or file errors -- exits `3` for refused writes, stale source, overwrite protection, or confirmation required -- `earsyntax instructions convert --json` returns a complete agent contract -- `earsyntax new` creates a work item without generating semantic requirements -- `earsyntax accept` refuses invalid or stale work -- JSON output is stable -- SARIF output is planned but not required for the first facade milestone - -### 11. Compatibility Agent - -Mission: Build the fixture corpus and compare selected behavior against `ears-lint-go`. - -Owns: - -- golden fixtures -- AST snapshots -- diagnostic snapshots -- compatibility notes - -Must not: - -- force bug-for-bug compatibility where the manager has chosen a different policy - -Deliverables: - -- `fixtures/canonical` -- `fixtures/ears-lint-go-parity` -- `docs/compatibility.md` - -Acceptance: - -- every fixture has expected pattern and diagnostics -- known deviations from `ears-lint-go` are documented -- spans are tested for representative examples - -### 12. Documentation Agent - -Mission: Produce practical docs that make the toolkit usable. - -Owns: - -- README -- quickstart -- API reference examples -- CLI examples -- authoring guide -- agentic loop guide -- source-to-EARS conversion examples - -Must not: - -- document planned features as shipped -- invent commands or flags - -Deliverables: - -- `README.md` -- `docs/quickstart.md` -- `docs/authoring-ears.md` -- `docs/api.md` - -Acceptance: - -- first screen shows a working example -- each command has expected output -- limitations are explicit -- docs match implemented flags and exports -- docs explain that agents perform semantic derivation and the CLI validates it - -### 13. Spec Workflow Agent - -Mission: Build optional workflow helpers inspired by `ears-spec-engine`. - -Owns: - -- requirement document model -- ambiguity analysis -- simple conflict heuristics -- design/task skeleton helpers -- dependency wave analysis - -Must not: - -- merge spec workflow into `@earsyntax/core` -- require LLM access -- claim semantic correctness from heuristic checks - -Deliverables: - -- `packages/spec/src/index.ts` -- spec workflow tests -- `docs/spec-workflow.md` - -Acceptance: - -- consumes `@earsyntax/core` results -- works without network or model calls -- labels conflict and completeness checks as heuristics -- keeps requirement parsing delegated to core - -### 14. Release Agent - -Mission: Prepare the repository for a v0.1 release candidate. - -Owns: - -- package metadata -- build scripts -- type declarations -- changelog -- release checklist -- npm dry-run -- facade capability report -- public-claim audit -- launch positioning notes - -Must not: - -- change parser behavior during release hardening -- broaden v0.1 scope to include semantic conversion inside the CLI -- publish ecosystem claims from `ears-research-claude.md` without fresh verification - -Deliverables: - -- package manifests -- release notes -- `docs/release.md` -- `earsyntax version --features --json` fixture -- `docs/grammar.md` launch-ready grammar summary -- `docs/public-claims-audit.md` - -Acceptance: - -- package dry-runs pass -- public exports are intentional -- tests, type-checks, and lint pass -- release notes include limitations and known gaps -- release notes explicitly state that coding agents perform semantic derivation and Earsyntax validates artifacts -- public docs avoid "first," "only," "unclaimed," and "does not exist" claims unless freshly verified -- launch copy frames Earsyntax as a deterministic linter/facade, not a methodology - -## Execution Phases - -### Phase 0: Repository Setup - -Manager tasks: - -1. Choose monorepo tooling. -2. Create packages. -3. Add TypeScript, linting, test runner, and formatting. -4. Add fixture directories. -5. Open tracking issues or task files for each agent. - -Recommended structure: - -```text -packages/ - core/ - src/ - test/ - extract/ - src/ - test/ - cli/ - src/ - test/ -fixtures/ - canonical/ - invalid/ - compatibility/ -docs/ -``` - -### Phase 1: Contract Freeze - -Agents active: - -- Architect Agent -- Grammar Agent -- Diagnostics Agent - -Exit criteria: - -- public API approved -- diagnostic code registry approved -- fixture schema approved -- grammar matrix approved - -### Phase 2: Parallel Core Implementation - -Agents active: - -- Shell Parser Agent -- Expression Parser Agent -- Catalog Agent -- Diagnostics Agent - -Exit criteria: - -- package-local tests pass -- each agent has fixtures -- no shared API drift - -### Phase 3: Core Integration - -Agents active: - -- Core Integration Agent -- Compatibility Agent - -Exit criteria: - -- `lintEars` and `lintEarsBatch` work end to end -- canonical fixtures pass -- invalid fixtures produce expected diagnostics -- spans are stable - -### Phase 4: Agentic Facade - -Agents active: - -- Agentic Facade Agent -- CLI Agent -- Documentation Agent - -Exit criteria: - -- `earsyntax` is the only primary binary name -- `earsyntax new` creates work items -- `earsyntax instructions author|convert|repair|review --json` returns complete agent contracts -- `earsyntax status --json` reports work item state and next commands -- work item manifests record source path, output path, source hash, status, and acceptance metadata -- agent rules for writing `.ears` from source specs are documented - -### Phase 5: User Surfaces - -Agents active: - -- Extractor Agent -- CLI Agent -- Documentation Agent - -Exit criteria: - -- CLI can validate real files -- Markdown, `.ears`, YAML, and JSON extraction works -- README and quickstart are accurate -- quickstart demonstrates source Markdown to `.ears` through the agent loop - -### Phase 6: Release Candidate - -Agents active: - -- Release Agent -- Manager Agent - -Exit criteria: - -- tests pass -- type-check passes -- lint passes -- package dry-runs pass -- examples run -- known limitations are documented - -## Dependency Graph - -```text -Architect - -> Grammar - -> Diagnostics - -> Shell Parser - -> Expression Parser - -> Catalog - -> Core Integration - -> Extractor - -> Agentic Facade - -> CLI - -> Documentation - -> Release - -Compatibility starts after Grammar and continues through Core Integration. -The Agentic Facade starts after the core result shapes are stable and can proceed before every CLI subcommand is implemented. -``` - -## Manager Coordination Rules - -The manager must enforce these rules: - -1. One agent owns each file family. -2. Shared types are frozen after Phase 1. -3. Diagnostic codes are append-only after fixtures depend on them. -4. Every implementation task includes tests. -5. Core code cannot depend on CLI, file system, editor APIs, network calls, or LLM calls. -6. The CLI facade can depend on core and extract, but core cannot depend on the facade. -7. Fixtures are source-of-truth examples, not incidental tests. -8. Agents may propose a contract change, but the manager must approve and broadcast it. -9. Integration happens through pull requests or controlled patch batches. -10. Release hardening may fix bugs, but must not broaden scope. -11. Agent-specific wrappers must be thin wrappers over `earsyntax instructions` and `earsyntax validate`. -12. Generated `.ears` files are not accepted until a human approves them. - -## Agent Handoff Template - -Each agent must return this at handoff: - -```md -## Handoff: - -### Completed - -- ... - -### Files Changed - -- ... - -### Tests Added - -- ... - -### Verification - -- Command: `...` -- Result: ... - -### Public Contract Changes - -- None - -### Open Questions - -- ... - -### Known Risks - -- ... -``` - -## Manager Status Template - -The manager should maintain this status after each phase: - -```md -# EARS JS Initiative Status - -## Current Phase - -Phase N: ... - -## Green - -- ... - -## Blocked - -- ... - -## Contract Changes - -- ... - -## Next Agent Assignments - -- ... - -## Verification - -- ... -``` - -## Sub-Agent Prompt Template - -Use this template when activating an implementation agent. - -```text -You are the for the EARS JS initiative. - -Read first: -- This orchestration file. -- The public types in packages/core/src/types.ts. -- The fixture schema. -- Your package README or task brief. - -Your mission: - - -You own: - - -You must not: - - -Implementation requirements: -- Keep behavior deterministic. -- Preserve source spans where your code touches parsed text. -- Add focused tests with your implementation. -- Do not change public API types or diagnostic codes without manager approval. -- Keep changes scoped to your ownership area. - -Before handoff: -- Run package-local tests. -- Run type-check if available. -- Update fixtures or docs only when they describe implemented behavior. -- Return the standard handoff report. -``` - -## First-Week Work Plan - -Day 1: - -- Manager creates repository/package skeleton. -- Architect freezes types and exports. -- Grammar agent writes canonical and invalid fixture matrix. -- Diagnostics agent publishes diagnostic registry. -- Agentic Facade agent freezes the work item manifest, instruction JSON, and traceability JSON shapes. - -Day 2: - -- Shell parser starts implementation. -- Expression parser starts implementation. -- Catalog agent starts deterministic matcher. -- Compatibility agent starts fixture snapshots. -- CLI agent fixes the package facade around `@earsyntax/cli`, `packages/cli`, and the `earsyntax` binary. - -Day 3: - -- Core integration agent connects parser pieces. -- Diagnostics agent verifies severity and ordering. -- Grammar agent fills edge cases from failures. -- Agentic Facade agent writes the source-to-EARS instruction fixtures for author, convert, repair, and review. - -Day 4: - -- Extractor agent adds `.ears`, Markdown, YAML, and JSON input support. -- CLI agent builds `new`, `instructions`, `status`, `validate`, and JSON output. -- Documentation agent writes quickstart against real examples. - -Day 5: - -- Compatibility pass. -- Span accuracy pass. -- CLI facade fixture pass. -- Agent loop dry run: source Markdown to `.ears`, validation, repair, status, and acceptance refusal/acceptance. -- Release agent prepares package dry-runs. -- Manager cuts v0.1 release candidate checklist. - -## Test Matrix - -Required test groups: - -| Group | Examples | -| ------------------------- | ---------------------------------------------------------------------------- | -| Canonical valid shells | one fixture for each EARS pattern | -| Case-insensitive keywords | `when`, `WHEN`, `When` | -| Clause order | valid and invalid complex ordering | -| If/then | valid unwanted behaviour and invalid missing `then` | -| Missing parts | missing system, missing `shall`, empty response | -| Expression precedence | `not A and B or C` | -| Parentheses | `(A or B) and C`, unbalanced groups | -| Catalog matching | exact, alias, ambiguous, unresolved | -| Diagnostics | code, severity, span, stable sort | -| Batch API | order preserved | -| Extractors | `.ears`, Markdown list, Markdown table, YAML, JSON | -| Work items | manifest create/read/update, source hash, stale state | -| Instructions | author, convert, repair, review JSON contracts | -| Agent loop | source Markdown -> scaffold -> instructions -> `.ears` -> validate -> status | -| Questions | questions file path and blocking question reporting | -| Traceability | generated requirement maps back to source path and line | -| CLI | binary name, exit codes, JSON output, refused writes | -| Acceptance | refuses invalid, refuses stale, records accepted hash | - -## Compatibility Policy - -Match `ears-lint-go` where it aligns with the project goals: - -- AST-level shell pattern classification -- boolean clause expression parsing -- deterministic catalog matching -- strict/guided mode concept -- stable diagnostics -- response splitting by semicolon - -Intentional differences are allowed when documented: - -- TypeScript naming may use `optional-feature` consistently instead of `optional`. -- The parser should accept canonical mixed-case EARS, not only uppercase house style. -- Unwanted behaviour must be first-class even if a workflow package collapses it into complex requirements. -- Workflow-level `WITHIN` support should be optional or experimental unless added to core grammar deliberately. - -## Definition Of Done - -The v0.1 release candidate is done when: - -1. `@earsyntax/core` parses all canonical EARS patterns. -2. `@earsyntax/core` returns stable AST, references, diagnostics, and validity. -3. Strict and guided modes are implemented. -4. Clause expressions support `not`, `and`, `or`, and parentheses. -5. Catalog matching is deterministic and tested. -6. Extractors support `.ears`, Markdown, YAML, and JSON. -7. `@earsyntax/cli` ships the `earsyntax` binary from `packages/cli`. -8. CLI supports `init`, `doctor`, `version`, `new`, `list`, `status`, `instructions`, `validate`, `accept`, and `show`. -9. CLI supports pretty and JSON output. -10. CLI exit codes are documented and tested. -11. `earsyntax instructions author|convert|repair|review --json` returns complete agent-facing contracts. -12. The source-to-`.ears` loop is documented and covered by fixtures. -13. Work item status supports `missing`, `scaffolded`, `drafted`, `invalid`, `valid`, `accepted`, and `stale`. -14. Acceptance refuses invalid or stale work and records source/output hashes. -15. Docs include a quickstart, API example, CLI facade example, input format guide, diagnostics reference, grammar summary, agentic loop guide, and limitations. -16. Public-claim audit is complete for ecosystem, competitor, and integration claims. -17. All tests and type-checks pass. - -## Explicit Non-Goals For v0.1 - -- No Cucumber-compatible runner. -- No automatic step definition binding. -- No fuzzy or semantic catalog matching. -- No LLM calls inside core parsing or linting. -- No claim that heuristic conflict detection proves semantic consistency. -- No editor extension. -- No requirements-management SaaS integration. -- No automatic mutation of source specs. -- No automatic human acceptance. -- No hard dependency on Spec Kit, OpenSpec, Kiro, Claude, Codex, Cursor, or any specific agent. - -## Addendum: Mid-Flight Decisions (2026-07-28) - -Decisions made during implementation that amend the brief above. - -### Packages - -The package set is four, not three: - -| Package | Purpose | -| ------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `@earsyntax/core` | Parser, linter, diagnostics. Unchanged. | -| `@earsyntax/extract` | File extraction. Unchanged. | -| `@earsyntax/cli-contract` | Shared report output contracts: JSON report v1, SARIF 2.1.0, pretty model, exit codes. Facade-neutral, no I/O. | -| `@earsyntax/cli` | Command-line tool. Depends on core, extract, cli-contract. | - -### CLI facade - -- Invocation model is `npx @earsyntax/cli `. The bin entry is `earsyntax`; the name `ears-lint` is retired. -- RESOLVED: the command surface is now specified in full by the "CLI Facade Contract" section above (init, doctor, version, new, list, status, instructions, validate, accept, show; exit codes 0/1/2/3). SARIF output ships in `@earsyntax/cli-contract` already and is exposed by the CLI when the facade milestone allows. - -### Scope additions - -- Go extras included in v0.1: `lintCatalogCoverage` public API, `expr.mixed_unresolved_terms`, `catalog.term_unreferenced`. -- The diagnostic registry is 29 codes (24 from the table above, 2 Go extras, 3 generated ambiguous variants: `catalog.state_ambiguous`, `catalog.event_ambiguous`, `catalog.feature_ambiguous`). -- New deliverable: an agent-agnostic "spec-to-ears" skill. One canonical instruction body plus thin per-agent adapters (Claude Code skill/command, Cursor rules, Copilot prompt, Kiro steering, root AGENTS.md fallback). The loop: read source spec, extract one obligation per line, classify against the six patterns, write `.ears` with stable REQ IDs and canonical catalog names, run the linter, fix by diagnostic code, converge on exit 0. - -### Approved deviations from ears-lint-go - -Documented in full in `docs/compatibility.md`. - -1. Spans are UTF-16 code unit offsets (JS string indices); Go uses UTF-8 bytes. -2. `If` clause bodies map to a dedicated `ast.unwanted` field; Go folds them into `trigger`. -3. The system term emits only `catalog.system_unresolved`/`catalog.system_ambiguous`; Go also emits `expr.unknown_term`/`expr.ambiguous_term` for it. -4. Exact duplicate diagnostics (same code, span, severity, message) are deduplicated; Go can emit duplicates. -5. Specific structural codes replace blanket `ears.no_match` where the failure is identifiable; `ears.no_match` is reserved for genuinely unstructured text. -6. `ears.empty_clause` and `ears.empty_response` are emitted (Go does not check these); both mode-dependent. -7. Clause order `When ... If ... then` is valid complex per the order rule `While* -> Where* -> When* -> If*`; Go flags it invalid. -8. Empty and undefined catalogs both mean no-catalog mode (matching skipped); Go resolves against the empty struct. -9. Diagnostic sort adds span end as a secondary key for insertion-order independence. -10. Responses keep Go behavior: trailing period stripped, semicolon split at lint level. - -### Security - -`eslint-config-prettier` is pinned to exact `10.1.8`; the `^10.0.1` range could resolve to the compromised `10.1.6`/`10.1.7` releases. 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/docs/contracts/findings.md b/docs/contracts/findings.md index 347b4bd..5aa1a73 100644 --- a/docs/contracts/findings.md +++ b/docs/contracts/findings.md @@ -41,15 +41,15 @@ interface Diagnostic { ### `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. | +| 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 @@ -57,23 +57,23 @@ 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`. | +| 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) +findings.ok === (findings.summary.errors === 0); ``` Effective severity is computed in this order, and `severity` on each emitted diff --git a/docs/contracts/profile.md b/docs/contracts/profile.md index a61ed9b..990a5e4 100644 --- a/docs/contracts/profile.md +++ b/docs/contracts/profile.md @@ -55,13 +55,13 @@ candidate (`extract` returns `locatorRuleId`). The `id` field and the four 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. | +| 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. @@ -73,34 +73,34 @@ line of a `documentKinds` file is a candidate. ### Top level -| Field | Meaning | -|---|---| -| `name` | The profile identity. One of the five closed names. There are no user-defined profile names in alpha. | +| 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` | 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). | +| 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. | +| 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 @@ -117,10 +117,10 @@ 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`. | +| 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`. | ## Validation rules @@ -146,18 +146,18 @@ 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) | +| 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: diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7893c48..aec39e5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -43,19 +43,19 @@ Codes are grouped by prefix. The `ID` column is the primary public identifier; t 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.` | +| 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 @@ -75,17 +75,17 @@ These report defects inside the boolean-like body of a clause. The three structu 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). | +| 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 diff --git a/docs/refactor/host-native-facade.md b/docs/refactor/host-native-facade.md index 088c152..e85ddf4 100644 --- a/docs/refactor/host-native-facade.md +++ b/docs/refactor/host-native-facade.md @@ -29,14 +29,14 @@ 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. | +| 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`, @@ -45,11 +45,11 @@ exclusive; passing both is a usage error (exit `2`). There is no `--work`, ## 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. | +| 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 @@ -64,17 +64,17 @@ 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 + 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 + next: NextAction[]; // always present; may be empty } interface FacadeDiagnostic { - code: string; // "cli.unknown_profile", "validate.missing_file", ... + code: string; // "cli.unknown_profile", "validate.missing_file", ... severity: 'error' | 'warning'; message: string; path?: string; @@ -82,7 +82,7 @@ interface FacadeDiagnostic { } interface NextAction { - command: string; // a runnable earsyntax command + command: string; // a runnable earsyntax command reason: string; forAgent?: boolean; } @@ -234,12 +234,12 @@ Payload keys: `mode`, `file`, `profile`, optional `sourceFile` (see `--from`), 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. | +| 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. @@ -337,12 +337,8 @@ recommend exact commands. Works with no workspace. Exit `0`. "ok": true, "root": "/repo", "detected": { - "hosts": [ - { "host": "kiro", "evidence": ".kiro/specs/", "profile": "kiro" } - ], - "agents": [ - { "agent": "claude", "evidence": ".claude/" } - ] + "hosts": [{ "host": "kiro", "evidence": ".kiro/specs/", "profile": "kiro" }], + "agents": [{ "agent": "claude", "evidence": ".claude/" }] }, "next": [ { @@ -414,7 +410,16 @@ Report the installed version and capability map. Never resolves a repo, so "ok": true, "features": { "facade": 1, - "commands": ["validate", "extract", "instructions", "explain", "profiles", "doctor", "init", "version"], + "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"], @@ -446,21 +451,21 @@ 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` | +| 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` | +| `EARS-E013` | `expr.unbalanced_parentheses` | ### Introduced by the host-native grammar: 3 error codes (`EARS-E014`-`EARS-E016`) @@ -469,10 +474,10 @@ 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 | +| 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 @@ -481,24 +486,24 @@ The `kiro` profile relaxes keyword case (`EARS-E014`) and the leading comma ### 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` | +| 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` | +| `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 diff --git a/docs/refactor/inventory.md b/docs/refactor/inventory.md index 66a4fba..8bfee7a 100644 --- a/docs/refactor/inventory.md +++ b/docs/refactor/inventory.md @@ -11,12 +11,12 @@ 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`. Only `extractFromFile` touches disk. (`packages/extract/src/`) | +| 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`. Only `extractFromFile` touches disk. (`packages/extract/src/`) | | `@earsyntax/cli-contract` | Report projections: JSON report, SARIF, pretty, exit codes, diagnostic registry descriptions. Pure serializers, no I/O, no argv. (`packages/cli-contract/src/`) | -| `@earsyntax/cli` | Command dispatcher and the workspace-backed command handlers. (`packages/cli/src/`) | +| `@earsyntax/cli` | Command dispatcher and the workspace-backed command handlers. (`packages/cli/src/`) | Note: `@earsyntax/cli-contract` already contains a `JsonReport`, a `SarifLog` builder, an exit-code helper, and a `DIAGNOSTIC_DESCRIPTIONS` registry keyed by @@ -65,13 +65,13 @@ 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` | +| 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 @@ -145,9 +145,9 @@ newline. - 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 }`. +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`. @@ -231,7 +231,7 @@ Rule bodies live in `packages/cli/src/rules.ts`: `ALLOWED_PATTERNS` - 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: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`). @@ -246,7 +246,7 @@ Rule bodies live in `packages/cli/src/rules.ts`: `ALLOWED_PATTERNS` `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 +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. @@ -287,28 +287,28 @@ Rule bodies live in `packages/cli/src/rules.ts`: `ALLOWED_PATTERNS` `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` | +| 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 | +| 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?`, diff --git a/fixtures/diagnostics/migration-table.json b/fixtures/diagnostics/migration-table.json index 6e6cf77..1de34fc 100644 --- a/fixtures/diagnostics/migration-table.json +++ b/fixtures/diagnostics/migration-table.json @@ -26,7 +26,11 @@ { "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-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" }, diff --git a/fixtures/profiles/README.md b/fixtures/profiles/README.md index 9e46cda..cd4da45 100644 --- a/fixtures/profiles/README.md +++ b/fixtures/profiles/README.md @@ -85,14 +85,14 @@ 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. | +| 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 @@ -111,14 +111,14 @@ 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. | +| 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. | +| `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 @@ -135,12 +135,12 @@ 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. | +| 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/kiro/NOTES.md b/fixtures/profiles/kiro/NOTES.md index cc71fd2..d6b1fec 100644 --- a/fixtures/profiles/kiro/NOTES.md +++ b/fixtures/profiles/kiro/NOTES.md @@ -23,12 +23,12 @@ Observed conventions of AWS Kiro's `requirements.md`, and exactly what the ## 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. | +| 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 diff --git a/fixtures/profiles/kiro/requirements.expected.json b/fixtures/profiles/kiro/requirements.expected.json index ff8f363..cd4322a 100644 --- a/fixtures/profiles/kiro/requirements.expected.json +++ b/fixtures/profiles/kiro/requirements.expected.json @@ -77,9 +77,7 @@ "kiro": { "ok": true, "expect": [] }, "strict": { "ok": false, - "expect": [ - { "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" } - ] + "expect": [{ "id": "EARS-E014", "code": "ears.keyword_case", "severity": "error" }] } }, { diff --git a/fixtures/profiles/openspec/NOTES.md b/fixtures/profiles/openspec/NOTES.md index c1f9f20..2eb693c 100644 --- a/fixtures/profiles/openspec/NOTES.md +++ b/fixtures/profiles/openspec/NOTES.md @@ -31,10 +31,10 @@ matching key off). 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. | +| 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 @@ -114,12 +114,12 @@ 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 | +| 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 diff --git a/packages/cli-contract/src/exit-codes.ts b/packages/cli-contract/src/exit-codes.ts index be7439e..9a9053c 100644 --- a/packages/cli-contract/src/exit-codes.ts +++ b/packages/cli-contract/src/exit-codes.ts @@ -30,8 +30,6 @@ export const EXIT_USAGE = 2; * @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 { +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/index.ts b/packages/cli-contract/src/index.ts index bbac6a9..10aec3f 100644 --- a/packages/cli-contract/src/index.ts +++ b/packages/cli-contract/src/index.ts @@ -16,7 +16,12 @@ * preserve input order, so identical input always produces identical output. */ -export type { Findings, FindingsDiagnostic, FindingsSummary, FindingsSeverity } from '@earsyntax/core'; +export type { + Findings, + FindingsDiagnostic, + FindingsSummary, + FindingsSeverity, +} from '@earsyntax/core'; export { canonicalizeFindings, serializeFindings } from './findings-report.js'; @@ -38,7 +43,13 @@ export type { SarifRule, SarifRun, } from './sarif.js'; -export { buildSarifLog, serializeSarifLog, SARIF_SCHEMA, SARIF_TOOL_NAME, SARIF_VERSION } from './sarif.js'; +export { + buildSarifLog, + serializeSarifLog, + SARIF_SCHEMA, + SARIF_TOOL_NAME, + SARIF_VERSION, +} from './sarif.js'; export type { PrettyModel, PrettyRecord, PrettySummary } from './pretty.js'; export { buildPrettyModel } from './pretty.js'; diff --git a/packages/cli-contract/src/sarif.test.ts b/packages/cli-contract/src/sarif.test.ts index 2ea7c4b..e79a944 100644 --- a/packages/cli-contract/src/sarif.test.ts +++ b/packages/cli-contract/src/sarif.test.ts @@ -138,7 +138,10 @@ describe('buildSarifLog — shape and metadata', () => { 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[0]?.locations[0]?.physicalLocation.region).toEqual({ + startLine: 12, + startColumn: 3, + }); expect(results[1]?.locations[0]?.physicalLocation.region).toEqual({ startLine: 20 }); }); @@ -150,7 +153,9 @@ describe('buildSarifLog — shape and metadata', () => { 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'); + 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', () => { @@ -167,7 +172,10 @@ describe('buildSarifLog — URI normalization', () => { 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 ?? ''; + return ( + buildSarifLog(findings).runs[0]?.results[0]?.locations[0]?.physicalLocation.artifactLocation + .uri ?? '' + ); }; it('leaves a relative POSIX path as forward-slash segments', () => { @@ -220,18 +228,26 @@ function schemaViolations(log: SarifLog): string[] { 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}"`);} + 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 (!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');} + 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 ?? []; @@ -246,10 +262,18 @@ function schemaViolations(log: SarifLog): string[] { 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 (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`); + problems.push( + `${at}.defaultConfiguration.level "${rule.defaultConfiguration.level}" not in enum`, + ); } }); @@ -257,8 +281,12 @@ function schemaViolations(log: SarifLog): string[] { 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 (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`); } @@ -266,15 +294,23 @@ function schemaViolations(log: SarifLog): string[] { 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 (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`); + 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}`); + 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}`); + problems.push( + `${rat}.region.startColumn "${region.startColumn}" violates minimum ${startColumnMin}`, + ); } }); }); @@ -297,7 +333,14 @@ describe('buildSarifLog — schema conformance', () => { 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-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' }, ], }; diff --git a/packages/cli-contract/test/sarif-schema-2.1.0.json b/packages/cli-contract/test/sarif-schema-2.1.0.json index e0b6524..9611789 100644 --- a/packages/cli-contract/test/sarif-schema-2.1.0.json +++ b/packages/cli-contract/test/sarif-schema-2.1.0.json @@ -6,7 +6,6 @@ "additionalProperties": false, "type": "object", "properties": { - "$schema": { "description": "The URI of the JSON schema corresponding to the version.", "type": "string", @@ -15,7 +14,7 @@ "version": { "description": "The SARIF format version of this log file.", - "enum": [ "2.1.0" ] + "enum": ["2.1.0"] }, "runs": { @@ -44,28 +43,24 @@ } }, - "required": [ "version", "runs" ], + "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": { @@ -119,7 +114,6 @@ "additionalProperties": false, "type": "object", "properties": { - "description": { "description": "A short description of the artifact.", "$ref": "#/definitions/message" @@ -232,7 +226,6 @@ "additionalProperties": false, "type": "object", "properties": { - "artifactLocation": { "description": "The location of the artifact to change.", "$ref": "#/definitions/artifactLocation" @@ -252,10 +245,9 @@ "description": "Key/value pairs that provide additional information about the change.", "$ref": "#/definitions/propertyBag" } - }, - "required": [ "artifactLocation", "replacements" ] + "required": ["artifactLocation", "replacements"] }, "artifactContent": { @@ -263,7 +255,6 @@ "type": "object", "additionalProperties": false, "properties": { - "text": { "description": "UTF-8-encoded content from a text artifact.", "type": "string" @@ -291,7 +282,6 @@ "additionalProperties": false, "type": "object", "properties": { - "uri": { "description": "A string containing a valid relative or absolute URI.", "type": "string", @@ -327,7 +317,6 @@ "type": "object", "additionalProperties": false, "properties": { - "description": { "description": "A message describing the role played by the attachment.", "$ref": "#/definitions/message" @@ -366,7 +355,7 @@ } }, - "required": [ "artifactLocation" ] + "required": ["artifactLocation"] }, "codeFlow": { @@ -374,7 +363,6 @@ "additionalProperties": false, "type": "object", "properties": { - "message": { "description": "A message relevant to the code flow.", "$ref": "#/definitions/message" @@ -396,7 +384,7 @@ } }, - "required": [ "threadFlows" ] + "required": ["threadFlows"] }, "configurationOverride": { @@ -404,7 +392,6 @@ "type": "object", "additionalProperties": false, "properties": { - "configuration": { "description": "Specifies how the rule or notification was configured during the scan.", "$ref": "#/definitions/reportingConfiguration" @@ -420,7 +407,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "configuration", "descriptor" ] + "required": ["configuration", "descriptor"] }, "conversion": { @@ -428,7 +415,6 @@ "additionalProperties": false, "type": "object", "properties": { - "tool": { "description": "A tool object that describes the converter.", "$ref": "#/definitions/tool" @@ -454,10 +440,9 @@ "description": "Key/value pairs that provide additional information about the conversion.", "$ref": "#/definitions/propertyBag" } - }, - "required": [ "tool" ] + "required": ["tool"] }, "edge": { @@ -465,7 +450,6 @@ "type": "object", "additionalProperties": false, "properties": { - "id": { "description": "A string that uniquely identifies the edge within its graph.", "type": "string" @@ -492,7 +476,7 @@ } }, - "required": [ "id", "sourceNodeId", "targetNodeId" ] + "required": ["id", "sourceNodeId", "targetNodeId"] }, "edgeTraversal": { @@ -500,7 +484,6 @@ "type": "object", "additionalProperties": false, "properties": { - "edgeId": { "description": "Identifies the edge being traversed.", "type": "string" @@ -531,7 +514,7 @@ } }, - "required": [ "edgeId" ] + "required": ["edgeId"] }, "exception": { @@ -539,7 +522,6 @@ "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." @@ -578,7 +560,6 @@ "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", @@ -587,7 +568,7 @@ "version": { "description": "The SARIF format version of this external properties object.", - "enum": [ "2.1.0" ] + "enum": ["2.1.0"] }, "guid": { @@ -771,7 +752,6 @@ "type": "object", "additionalProperties": false, "properties": { - "location": { "description": "The location of the external property file.", "$ref": "#/definitions/artifactLocation" @@ -795,10 +775,7 @@ "$ref": "#/definitions/propertyBag" } }, - "anyOf": [ - { "required": [ "location" ] }, - { "required": [ "guid" ] } - ] + "anyOf": [{ "required": ["location"] }, { "required": ["guid"] }] }, "externalPropertyFileReferences": { @@ -806,7 +783,6 @@ "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" @@ -977,7 +953,6 @@ "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" @@ -998,7 +973,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "artifactChanges" ] + "required": ["artifactChanges"] }, "graph": { @@ -1006,7 +981,6 @@ "type": "object", "additionalProperties": false, "properties": { - "description": { "description": "A description of the graph.", "$ref": "#/definitions/message" @@ -1046,7 +1020,6 @@ "type": "object", "additionalProperties": false, "properties": { - "runGraphIndex": { "description": "The index within the run.graphs to be associated with the result.", "type": "integer", @@ -1098,10 +1071,7 @@ "$ref": "#/definitions/propertyBag" } }, - "oneOf": [ - { "required": [ "runGraphIndex" ] }, - { "required": [ "resultGraphIndex" ] } - ] + "oneOf": [{ "required": ["runGraphIndex"] }, { "required": ["resultGraphIndex"] }] }, "invocation": { @@ -1109,7 +1079,6 @@ "additionalProperties": false, "type": "object", "properties": { - "commandLine": { "description": "The command line used to invoke the tool.", "type": "string" @@ -1279,7 +1248,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "executionSuccessful" ] + "required": ["executionSuccessful"] }, "location": { @@ -1287,7 +1256,6 @@ "additionalProperties": false, "type": "object", "properties": { - "id": { "description": "Value that distinguishes this location from all other locations within a single result object.", "type": "integer", @@ -1350,7 +1318,6 @@ "type": "object", "additionalProperties": false, "properties": { - "target": { "description": "A reference to the related location.", "type": "integer", @@ -1360,7 +1327,7 @@ "kinds": { "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'includes', 'isIncludedBy' and 'relevant'.", "type": "array", - "default": [ "relevant" ], + "default": ["relevant"], "uniqueItems": true, "items": { "type": "string" @@ -1377,7 +1344,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "target" ] + "required": ["target"] }, "logicalLocation": { @@ -1385,7 +1352,6 @@ "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" @@ -1433,7 +1399,6 @@ "additionalProperties": false, "properties": { - "text": { "description": "A plain text message string.", "type": "string" @@ -1465,10 +1430,7 @@ "$ref": "#/definitions/propertyBag" } }, - "anyOf": [ - { "required": [ "text" ] }, - { "required": [ "id" ] } - ] + "anyOf": [{ "required": ["text"] }, { "required": ["id"] }] }, "multiformatMessageString": { @@ -1477,7 +1439,6 @@ "additionalProperties": false, "properties": { - "text": { "description": "A plain text message string or format string.", "type": "string" @@ -1493,7 +1454,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "text" ] + "required": ["text"] }, "node": { @@ -1502,7 +1463,6 @@ "additionalProperties": false, "properties": { - "id": { "description": "A string that uniquely identifies the node within its graph.", "type": "string" @@ -1535,7 +1495,7 @@ } }, - "required": [ "id" ] + "required": ["id"] }, "notification": { @@ -1543,7 +1503,6 @@ "type": "object", "additionalProperties": false, "properties": { - "locations": { "description": "The locations relevant to this notification.", "type": "array", @@ -1563,7 +1522,7 @@ "level": { "description": "A value specifying the severity level of the notification.", "default": "warning", - "enum": [ "none", "note", "warning", "error" ] + "enum": ["none", "note", "warning", "error"] }, "threadId": { @@ -1598,7 +1557,7 @@ } }, - "required": [ "message" ] + "required": ["message"] }, "physicalLocation": { @@ -1606,7 +1565,6 @@ "additionalProperties": false, "type": "object", "properties": { - "address": { "description": "The address of the location.", "$ref": "#/definitions/address" @@ -1635,10 +1593,10 @@ "anyOf": [ { - "required": [ "address" ] + "required": ["address"] }, { - "required": [ "artifactLocation" ] + "required": ["artifactLocation"] } ] }, @@ -1649,7 +1607,6 @@ "additionalProperties": true, "properties": { "tags": { - "description": "A set of distinct strings that provide additional information.", "type": "array", "minItems": 0, @@ -1667,7 +1624,6 @@ "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" @@ -1705,7 +1661,6 @@ "additionalProperties": false, "type": "object", "properties": { - "startLine": { "description": "The line number of the first character in the region.", "type": "integer", @@ -1783,7 +1738,6 @@ "additionalProperties": false, "type": "object", "properties": { - "deletedRegion": { "description": "The region of the artifact to delete.", "$ref": "#/definitions/region" @@ -1800,7 +1754,7 @@ } }, - "required": [ "deletedRegion" ] + "required": ["deletedRegion"] }, "reportingDescriptor": { @@ -1808,7 +1762,6 @@ "additionalProperties": false, "type": "object", "properties": { - "id": { "description": "A stable, opaque identifier for the report.", "type": "string" @@ -1906,7 +1859,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "id" ] + "required": ["id"] }, "reportingConfiguration": { @@ -1914,7 +1867,6 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { "description": "Specifies whether the report may be produced during the scan.", "type": "boolean", @@ -1924,7 +1876,7 @@ "level": { "description": "Specifies the failure level for the report.", "default": "warning", - "enum": [ "none", "note", "warning", "error" ] + "enum": ["none", "note", "warning", "error"] }, "rank": { @@ -1952,7 +1904,6 @@ "type": "object", "additionalProperties": false, "properties": { - "id": { "description": "The id of the descriptor.", "type": "string" @@ -1981,11 +1932,7 @@ "$ref": "#/definitions/propertyBag" } }, - "anyOf": [ - { "required": [ "index" ] }, - { "required": [ "guid" ] }, - { "required": [ "id" ] } - ] + "anyOf": [{ "required": ["index"] }, { "required": ["guid"] }, { "required": ["id"] }] }, "reportingDescriptorRelationship": { @@ -1993,7 +1940,6 @@ "type": "object", "additionalProperties": false, "properties": { - "target": { "description": "A reference to the related reporting descriptor.", "$ref": "#/definitions/reportingDescriptorReference" @@ -2002,7 +1948,7 @@ "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" ], + "default": ["relevant"], "uniqueItems": true, "items": { "type": "string" @@ -2019,7 +1965,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "target" ] + "required": ["target"] }, "result": { @@ -2027,7 +1973,6 @@ "additionalProperties": false, "type": "object", "properties": { - "ruleId": { "description": "The stable, unique identifier of the rule, if any, to which this result is relevant.", "type": "string" @@ -2048,13 +1993,13 @@ "kind": { "description": "A value that categorizes results by evaluation state.", "default": "fail", - "enum": [ "notApplicable", "pass", "fail", "review", "open", "informational" ] + "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" ] + "enum": ["none", "note", "warning", "error"] }, "message": { @@ -2179,12 +2124,7 @@ "baselineState": { "description": "The state of a result relative to a baseline of a previous run.", - "enum": [ - "new", - "unchanged", - "updated", - "absent" - ] + "enum": ["new", "unchanged", "updated", "absent"] }, "rank": { @@ -2265,7 +2205,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "message" ] + "required": ["message"] }, "resultProvenance": { @@ -2273,7 +2213,6 @@ "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", @@ -2328,7 +2267,6 @@ "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" @@ -2466,7 +2404,7 @@ "type": "array", "minItems": 1, "uniqueItems": true, - "default": [ "\r\n", "\n" ], + "default": ["\r\n", "\n"], "items": { "type": "string" } @@ -2474,7 +2412,7 @@ "columnKind": { "description": "Specifies the unit in which the tool measures columns.", - "enum": [ "utf16CodeUnits", "unicodeCodePoints" ] + "enum": ["utf16CodeUnits", "unicodeCodePoints"] }, "externalPropertyFileReferences": { @@ -2570,7 +2508,7 @@ } }, - "required": [ "tool" ] + "required": ["tool"] }, "runAutomationDetails": { @@ -2578,7 +2516,6 @@ "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" @@ -2613,7 +2550,6 @@ "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" @@ -2631,7 +2567,6 @@ "additionalProperties": false, "type": "object", "properties": { - "message": { "description": "A message relevant to this call stack.", "$ref": "#/definitions/message" @@ -2652,7 +2587,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "frames" ] + "required": ["frames"] }, "stackFrame": { @@ -2660,7 +2595,6 @@ "additionalProperties": false, "type": "object", "properties": { - "location": { "description": "The location to which this stack frame refers.", "$ref": "#/definitions/location" @@ -2700,7 +2634,6 @@ "additionalProperties": false, "type": "object", "properties": { - "guid": { "description": "A stable, unique identifer for the suprression in the form of a GUID.", "type": "string", @@ -2709,19 +2642,12 @@ "kind": { "description": "A string that indicates where the suppression is persisted.", - "enum": [ - "inSource", - "external" - ] + "enum": ["inSource", "external"] }, "status": { "description": "A string that indicates the review status of the suppression.", - "enum": [ - "accepted", - "underReview", - "rejected" - ] + "enum": ["accepted", "underReview", "rejected"] }, "justification": { @@ -2739,7 +2665,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "kind" ] + "required": ["kind"] }, "threadFlow": { @@ -2747,7 +2673,6 @@ "type": "object", "additionalProperties": false, "properties": { - "id": { "description": "An string that uniquely identifies the threadFlow within the codeFlow in which it occurs.", "type": "string" @@ -2758,7 +2683,6 @@ "$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", @@ -2791,7 +2715,7 @@ } }, - "required": [ "locations" ] + "required": ["locations"] }, "threadFlowLocation": { @@ -2799,7 +2723,6 @@ "additionalProperties": false, "type": "object", "properties": { - "index": { "description": "The index within the run threadFlowLocations array.", "type": "integer", @@ -2873,7 +2796,7 @@ "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" ], + "enum": ["important", "essential", "unimportant"], "default": "important" }, @@ -2899,7 +2822,6 @@ "additionalProperties": false, "type": "object", "properties": { - "driver": { "description": "The analysis tool that was run.", "$ref": "#/definitions/toolComponent" @@ -2922,7 +2844,7 @@ } }, - "required": [ "driver" ] + "required": ["driver"] }, "toolComponent": { @@ -2930,7 +2852,6 @@ "additionalProperties": false, "type": "object", "properties": { - "guid": { "description": "A unique identifer for the tool component in the form of a GUID.", "type": "string", @@ -3067,12 +2988,9 @@ "description": "The kinds of data contained in this object.", "type": "array", "uniqueItems": true, - "default": [ "localizedData", "nonLocalizedData" ], + "default": ["localizedData", "nonLocalizedData"], "items": { - "enum": [ - "localizedData", - "nonLocalizedData" - ] + "enum": ["localizedData", "nonLocalizedData"] } }, @@ -3119,7 +3037,7 @@ } }, - "required": [ "name" ] + "required": ["name"] }, "toolComponentReference": { @@ -3127,7 +3045,6 @@ "type": "object", "additionalProperties": false, "properties": { - "name": { "description": "The 'name' property of the referenced toolComponent.", "type": "string" @@ -3158,7 +3075,6 @@ "type": "object", "additionalProperties": false, "properties": { - "name": { "description": "The name associated with the translation metadata.", "type": "string" @@ -3196,7 +3112,7 @@ "$ref": "#/definitions/propertyBag" } }, - "required": [ "name" ] + "required": ["name"] }, "versionControlDetails": { @@ -3204,7 +3120,6 @@ "type": "object", "additionalProperties": false, "properties": { - "repositoryUri": { "description": "The absolute URI of the repository.", "type": "string", @@ -3243,7 +3158,7 @@ } }, - "required": [ "repositoryUri" ] + "required": ["repositoryUri"] }, "webRequest": { @@ -3251,13 +3166,11 @@ "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": { @@ -3313,7 +3226,6 @@ "type": "object", "additionalProperties": false, "properties": { - "index": { "description": "The index within the run.webResponses array of the response object associated with this result.", "type": "integer", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 49beabf..b47b889 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -132,7 +132,10 @@ function dispatch( const global = resolveGlobals(args, color); if (global.json && global.sarif) { - throw usageError('cli.exclusive_flags', 'The --json and --sarif flags are mutually exclusive.'); + throw usageError( + 'cli.exclusive_flags', + 'The --json and --sarif flags are mutually exclusive.', + ); } const cwd = global.cwd ? resolveInput(baseCwd, global.cwd) : baseCwd; diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index a53de89..8f5e369 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -50,7 +50,11 @@ function runDoctor(opts: { cwd: string; json?: boolean; quiet?: boolean }): Comm profile: 'strict', color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; const context: CommandContext = { args, global, cwd: opts.cwd, emitter }; return doctorCommand(context); } @@ -163,13 +167,13 @@ describe('doctor: multi-host repo', () => { forAgent: true, }, { - command: - 'earsyntax validate "openspec/specs/**" "openspec/changes/**" --profile openspec', + 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', + 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, }, @@ -263,7 +267,9 @@ describe('doctor: pretty output', () => { 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'); + expect(pretty).toContain( + 'earsyntax init --agent claude,codex,cursor,copilot,gemini --host kiro,speckit,openspec', + ); }); it('reports no detections plainly for an empty repo', () => { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index c5b4ae2..e9e9d8e 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -95,7 +95,9 @@ function detectHosts(root: string): DetectedHost[] { const hosts: DetectedHost[] = []; const kiro = - detectDir(root, '.kiro/specs') ?? detectDir(root, '.kiro/steering') ?? detectDir(root, '.kiro/hooks'); + detectDir(root, '.kiro/specs') ?? + detectDir(root, '.kiro/steering') ?? + detectDir(root, '.kiro/hooks'); if (kiro !== undefined) { hosts.push({ host: 'kiro', evidence: kiro, profile: 'kiro' }); } @@ -197,7 +199,9 @@ function renderPretty( 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})`); + lines.push( + ` ${host.host.padEnd(10)} ${host.evidence.padEnd(24)} (profile ${host.profile})`, + ); } } if (agents.length > 0) { diff --git a/packages/cli/src/commands/explain.test.ts b/packages/cli/src/commands/explain.test.ts index dfbfdfb..7f506ee 100644 --- a/packages/cli/src/commands/explain.test.ts +++ b/packages/cli/src/commands/explain.test.ts @@ -45,7 +45,10 @@ 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 { +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, @@ -56,7 +59,11 @@ function makeContext(positionals: string[], options: { json?: boolean; quiet?: b cwd: '/work', color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; return { args, global, cwd: '/work', emitter }; } @@ -116,7 +123,9 @@ describe('explain — deprecated alias resolution', () => { 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}.`); + 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); } @@ -216,7 +225,10 @@ const EXAMPLE_CONTEXT: Record = { { id: 'F2', name: 'retries are enabled' }, ], }, - goodCatalog: { systems: SYSTEM, features: [{ id: 'F3', name: 'automatic 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' }] }, @@ -244,7 +256,10 @@ const EXAMPLE_CONTEXT: Record = { { id: 'E2', name: 'the reset is triggered' }, ], }, - goodCatalog: { systems: SYSTEM, events: [{ id: 'E3', name: 'the watchdog 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' }] }, @@ -307,9 +322,9 @@ const NON_EXECUTABLE: Record void }> = { 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), - ); + const guided = lintEars('timer reset maybe when idle', undefined, { + mode: 'guided', + }).diagnostics.map((diagnostic) => idForCode(diagnostic.code)); expect(guided).toContain('EARS-W014'); }, }, @@ -346,7 +361,9 @@ describe('explain — the registry examples execute as documented', () => { 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); + expect(goodCodes, `${entry.id} good example should not emit ${entry.id}`).not.toContain( + entry.id, + ); } }); @@ -395,7 +412,9 @@ describe('explain — errors, envelope, and purity', () => { 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'); + 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', () => { @@ -403,7 +422,9 @@ describe('explain — errors, envelope, and purity', () => { 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.'); + 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', () => { diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index b2409c6..a575339 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -90,7 +90,12 @@ function suggestIds(requested: string): string[] { /** 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: [] }); + const response = buildResponse({ + command: 'explain', + ok: false, + diagnostics: [diagnostic], + next: [], + }); return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; } @@ -154,9 +159,7 @@ export function explainCommand(context: CommandContext): CommandResult { { id: entry.id, requestedId, - ...(viaAlias - ? { alias: true, deprecationNote: deprecationNote(requestedId, entry.id) } - : {}), + ...(viaAlias ? { alias: true, deprecationNote: deprecationNote(requestedId, entry.id) } : {}), severity: entry.defaultSeverity, title: entry.title, meaning: entry.meaning, diff --git a/packages/cli/src/commands/extract.test.ts b/packages/cli/src/commands/extract.test.ts index e7a3634..599dbce 100644 --- a/packages/cli/src/commands/extract.test.ts +++ b/packages/cli/src/commands/extract.test.ts @@ -51,7 +51,11 @@ function runExtract(opts: { profile: opts.profile ?? 'strict', color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; const context: CommandContext = { args, global, cwd: opts.cwd ?? FIXTURES, emitter }; return extractCommand(context); } @@ -169,7 +173,6 @@ describe('extract snapshots against profile fixtures', () => { expect(result.response.summary).toEqual({ files: 1, candidates: 0 }); }); } - }); describe('extract candidate projection', () => { diff --git a/packages/cli/src/commands/extract.ts b/packages/cli/src/commands/extract.ts index 63592bf..9456288 100644 --- a/packages/cli/src/commands/extract.ts +++ b/packages/cli/src/commands/extract.ts @@ -134,7 +134,10 @@ function renderPretty(candidates: readonly FacadeCandidate[], quiet: boolean): s 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.'); + throw usageError( + 'extract.no_paths', + 'Provide one or more files (or `-` for stdin) to extract.', + ); } const resolved = resolveProfile(context.global.profile); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index a90610c..53736c0 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -133,9 +133,7 @@ describe('runInit — idempotency', () => { 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(payload(second).skipped).toEqual([...payload(first).written].sort()); expect(files.size).toBe(snapshot.size); for (const [path, content] of files) { @@ -310,7 +308,9 @@ describe('initCommand and dispatcher — real disk', () => { 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); + expect(readFileSync(resolve(dir, '.claude/commands/earsyntax-author.md'), 'utf8')).toBe( + authored, + ); }); it('blanks pretty output under --quiet but still writes', () => { @@ -320,7 +320,9 @@ describe('initCommand and dispatcher — real disk', () => { ); expect(res.code).toBe(0); expect(res.out.trim()).toBe(''); - expect(readFileSync(resolve(dir, '.cursor/rules/earsyntax.mdc'), 'utf8')).toContain('earsyntax'); + expect(readFileSync(resolve(dir, '.cursor/rules/earsyntax.mdc'), 'utf8')).toContain( + 'earsyntax', + ); }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index eb831f6..1ea5c3e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -131,10 +131,7 @@ export function runInit(inputs: InitInputs, deps: InitDeps = DEFAULT_DEPS): Init } if (agents.values.length === 0 && hosts.values.length === 0) { - return usageResult( - 'init.no_targets', - 'Request at least one --agent or --host to render.', - ); + return usageResult('init.no_targets', 'Request at least one --agent or --host to render.'); } const root = detectRoot(inputs.cwd) ?? inputs.cwd; @@ -258,7 +255,12 @@ function prettyInit( /** 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: [] }); + const response = buildResponse({ + command: 'init', + ok: false, + diagnostics: [diagnostic], + next: [], + }); return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; } diff --git a/packages/cli/src/commands/instructions.test.ts b/packages/cli/src/commands/instructions.test.ts index 08281e1..5528111 100644 --- a/packages/cli/src/commands/instructions.test.ts +++ b/packages/cli/src/commands/instructions.test.ts @@ -88,7 +88,11 @@ function runInstructions(opts: { profile: opts.profile ?? 'strict', color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; const context: CommandContext = { args, global, cwd: opts.cwd ?? FIXTURES, emitter }; return instructionsCommand(context); } @@ -135,7 +139,13 @@ describe('instructions payload shape per mode', () => { for (const mode of FINDINGS_MODES) { it(`embeds findings for ${mode}`, () => { - const result = runInstructions({ mode, file: 'broken.md', profile: 'kiro', json: true, cwd: tmpDir }); + 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); @@ -143,7 +153,13 @@ describe('instructions payload shape per mode', () => { } 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 }); + const result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); expect(Object.keys(result.response)).toEqual([ 'version', 'command', @@ -193,7 +209,13 @@ describe('instructions payload shape per mode', () => { 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 }); + 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 }); @@ -206,7 +228,15 @@ describe('instructions --from validity matrix', () => { 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 }), + () => + runInstructions({ + mode, + file: 'broken.md', + from: KIRO_DOC, + profile: 'kiro', + json: true, + cwd: tmpDir, + }), 'instructions.from_not_allowed', ); }); @@ -215,7 +245,13 @@ describe('instructions --from validity matrix', () => { 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 result = runInstructions({ + mode: 'repair', + file: 'broken.md', + profile: 'kiro', + json: true, + cwd: tmpDir, + }); const findings = result.response.findings as { ok: boolean; summary: { errors: number }; @@ -260,7 +296,13 @@ describe('instructions never emits accept-style 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 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); @@ -275,12 +317,18 @@ describe('instructions locator and dialect are derived from profile data', () => 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.', + 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 }); + 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.', @@ -310,11 +358,18 @@ describe('instructions locator and dialect are derived from profile data', () => 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 }); + 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.', + reason: + 'Validate the host file after editing and repeat until no error-severity finding remains.', forAgent: true, }, ]); @@ -344,13 +399,24 @@ describe('instructions file-existence policy by mode', () => { }); it('allows a not-yet-existing host file for author', () => { - const result = runInstructions({ mode: 'author', file: 'brand/new.md', profile: 'kiro', json: true }); + 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 }); + 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); }); @@ -358,7 +424,10 @@ describe('instructions file-existence policy by mode', () => { describe('instructions usage and envelope errors', () => { it('exits 2 when the mode is missing', () => { - expectUsageError(() => runInstructions({ file: 'x.md', json: true }), 'instructions.missing_mode'); + expectUsageError( + () => runInstructions({ file: 'x.md', json: true }), + 'instructions.missing_mode', + ); }); it('exits 2 on an unknown mode', () => { @@ -369,7 +438,10 @@ describe('instructions usage and envelope errors', () => { }); it('exits 2 when --file is absent', () => { - expectUsageError(() => runInstructions({ mode: 'author', json: true }), 'instructions.missing_file_flag'); + expectUsageError( + () => runInstructions({ mode: 'author', json: true }), + 'instructions.missing_file_flag', + ); }); it('exits 2 on an unknown profile', () => { @@ -382,7 +454,13 @@ describe('instructions usage and envelope errors', () => { 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 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(); diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts index c48ba8a..8a89f2a 100644 --- a/packages/cli/src/commands/instructions.ts +++ b/packages/cli/src/commands/instructions.ts @@ -242,5 +242,7 @@ export function instructionsCommand(context: CommandContext): CommandResult { next, }); - return global.quiet && !global.json ? { response, pretty: '', exitCode: 0 } : { response, pretty, exitCode: 0 }; + 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 index e9fef0b..13bcf94 100644 --- a/packages/cli/src/commands/profiles.test.ts +++ b/packages/cli/src/commands/profiles.test.ts @@ -20,7 +20,9 @@ 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 { +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, @@ -31,7 +33,11 @@ function makeContext(options: { json?: boolean; quiet?: boolean; profile?: strin cwd: '/work', color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; return { args, global, cwd: '/work', emitter }; } diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 029e057..65b87bb 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -88,7 +88,9 @@ describe('runValidate — inputs and exit codes', () => { 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); + 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); @@ -105,7 +107,14 @@ describe('runValidate — inputs and exit codes', () => { 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 }, + { + 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); @@ -115,7 +124,14 @@ describe('runValidate — inputs and exit codes', () => { it('expands a glob pattern in sorted order', () => { const r = runValidate( - { paths: ['*.ears'], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + { + 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'] }, @@ -141,11 +157,20 @@ describe('runValidate — inputs and exit codes', () => { it('rejects reading stdin twice', () => { const r = runValidate( - { paths: ['-', '-'], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + { + 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'); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe( + 'validate.duplicate_stdin', + ); }); it('exits 2 with a typed diagnostic on an unknown profile', () => { @@ -158,7 +183,14 @@ describe('runValidate — inputs and exit codes', () => { 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 }, + { + paths: ['ghost.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, fakeDeps({}), ); expect(r.exitCode).toBe(2); @@ -169,7 +201,14 @@ describe('runValidate — inputs and exit codes', () => { it('exits 2 on an unreadable file', () => { const r = runValidate( - { paths: ['locked.ears'], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + { + paths: ['locked.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, fakeDeps( { [`${CWD}/locked.ears`]: '' }, { @@ -195,7 +234,14 @@ describe('runValidate — inputs and exit codes', () => { it('exits 2 when a glob matches nothing', () => { const r = runValidate( - { paths: ['*.ears'], profileName: 'strict', strict: false, sarif: false, json: true, cwd: CWD }, + { + paths: ['*.ears'], + profileName: 'strict', + strict: false, + sarif: false, + json: true, + cwd: CWD, + }, fakeDeps({}, { glob: () => [] }), ); expect(r.exitCode).toBe(2); @@ -204,7 +250,14 @@ describe('runValidate — inputs and exit codes', () => { 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 }, + { + paths: ['clean.ears'], + profileName: 'strict', + strict: false, + sarif: true, + json: false, + cwd: CWD, + }, fakeDeps({ [`${CWD}/clean.ears`]: CLEAN_LINE }), ); expect(r.exitCode).toBe(0); @@ -220,7 +273,14 @@ describe('runValidate — inputs and exit codes', () => { 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 }, + { + 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); @@ -230,7 +290,9 @@ describe('runValidate — inputs and exit codes', () => { ruleId: string; ruleIndex: number; level: string; - locations: { physicalLocation: { artifactLocation: { uri: string }; region: { startLine: number } } }[]; + locations: { + physicalLocation: { artifactLocation: { uri: string }; region: { startLine: number } }; + }[]; }[]; tool: { driver: { rules: { id: string }[] } }; }[]; @@ -248,7 +310,14 @@ describe('runValidate — inputs and exit codes', () => { 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 }, + { + paths: ['clean.ears'], + profileName: 'strict', + strict: false, + sarif: true, + json: true, + cwd: CWD, + }, fakeDeps({ [`${CWD}/clean.ears`]: CLEAN_LINE }), ); expect(r.exitCode).toBe(2); @@ -272,19 +341,16 @@ describe('runValidate — JSON envelope shape', () => { ]); 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', - ]); + 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]?.command).toBe( + 'earsyntax instructions repair --file bad.ears --profile strict --json', + ); expect(next[0]?.forAgent).toBe(true); }); @@ -315,7 +381,11 @@ function makeContext( cwd: options.cwd, color: false, }; - const emitter: Emitter = { json: global.json, painter: createPainter(false), write: () => undefined }; + const emitter: Emitter = { + json: global.json, + painter: createPainter(false), + write: () => undefined, + }; return { args, global, cwd: options.cwd, emitter }; } @@ -376,7 +446,9 @@ describe('validateCommand — kiro and strict profiles', () => { 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 })); + 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)); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 886e2fa..44a0c9c 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -28,7 +28,12 @@ import { type PipelineNotice, runPipeline, } from '@earsyntax/extract'; -import { buildSarifLog, canonicalizeFindings, type Findings, serializeSarifLog } from '@earsyntax/cli-contract'; +import { + buildSarifLog, + canonicalizeFindings, + 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'; @@ -95,8 +100,7 @@ interface ResolvedFile { /** The outcome of resolving the positional inputs into pipeline files. */ type ResolveInputsOutcome = - | { ok: true; files: PipelineFile[] } - | { ok: false; result: ValidateResult }; + { ok: true; files: PipelineFile[] } | { ok: false; result: ValidateResult }; /** * Run a stateless validation and frame the result. @@ -110,12 +114,18 @@ type ResolveInputsOutcome = * @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 { +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 (cli.exclusive_flags); this in-band // guard covers direct callers of runValidate so the invariant holds either way. if (inputs.sarif && inputs.json) { - return usageResult('cli.conflicting_flags', 'The --json and --sarif flags are mutually exclusive.'); + return usageResult( + 'cli.conflicting_flags', + 'The --json and --sarif flags are mutually exclusive.', + ); } const resolved = resolveProfile(inputs.profileName); @@ -273,7 +283,11 @@ function frame( { findings }, ); - const framed: ValidateResult = { response, pretty: prettyFindings(findings, diagnostics), exitCode }; + const framed: ValidateResult = { + response, + pretty: prettyFindings(findings, diagnostics), + exitCode, + }; if (sarif) { framed.raw = serializeSarifLog(buildSarifLog(findings)); } @@ -322,7 +336,9 @@ function prettyFindings(findings: Findings, diagnostics: FacadeDiagnostic[]): st } for (const diagnostic of diagnostics) { const at = diagnostic.line === undefined ? '' : `:${diagnostic.line}`; - lines.push(`${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`); + lines.push( + `${diagnostic.path ?? ''}${at} ${diagnostic.severity} ${diagnostic.code} ${diagnostic.message}`, + ); } const { valid, requirements, files, errors, warnings } = findings.summary; lines.push( @@ -339,7 +355,12 @@ function usageResult(code: string, message: string, path?: string): ValidateResu message, ...(path === undefined ? {} : { path }), }; - const response = buildResponse({ command: 'validate', ok: false, diagnostics: [diagnostic], next: [] }); + const response = buildResponse({ + command: 'validate', + ok: false, + diagnostics: [diagnostic], + next: [], + }); return { response, pretty: `error ${code}: ${message}`, exitCode: 2 }; } diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts index d85df59..b8dae3e 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -10,7 +10,10 @@ 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 response = buildResponse( + { command: 'version', ok: true, next: [] }, + { features: FEATURES }, + ); const pretty = context.args.booleans.has('features') ? [ diff --git a/packages/cli/src/response.ts b/packages/cli/src/response.ts index bffe5f6..a14beb9 100644 --- a/packages/cli/src/response.ts +++ b/packages/cli/src/response.ts @@ -52,7 +52,12 @@ export interface Emitter { } /** 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 { +export function emitResult( + emitter: Emitter, + response: FacadeResponse, + pretty: string, + raw?: string, +): void { if (raw !== undefined) { emitter.write(raw.endsWith('\n') ? raw : `${raw}\n`); return; diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts index f6f5e7d..5d6e2f7 100644 --- a/packages/cli/src/rules.ts +++ b/packages/cli/src/rules.ts @@ -53,10 +53,7 @@ export interface DialectPayload { } /** 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 { +function fallbackRuleSentence(rule: Profile['locator']['include'][number], kinds: string): string { switch (rule.kind) { case 'every-line': return `Every non-empty line of ${kinds} files.`; @@ -182,37 +179,46 @@ const FROM_RULES: readonly string[] = [ */ 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-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-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-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-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-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-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-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.', + '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. */ diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 865a89a..238810f 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -53,7 +53,16 @@ export interface Features { /** Capability map returned by `version --features`. */ export const FEATURES: Features = { facade: FACADE_CONTRACT, - commands: ['validate', 'extract', 'instructions', 'explain', 'profiles', 'doctor', 'init', 'version'], + 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'], diff --git a/packages/core/src/expression-parser.ts b/packages/core/src/expression-parser.ts index 2d58971..74cd053 100644 --- a/packages/core/src/expression-parser.ts +++ b/packages/core/src/expression-parser.ts @@ -317,7 +317,9 @@ class ExprParser { // 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 }; + const notExpr: NotExpr = span + ? { kind: 'not', item: node, span } + : { kind: 'not', item: node }; node = notExpr; } return node; @@ -368,7 +370,11 @@ class ExprParser { } // 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 } }; + const group: GroupExpr = { + kind: 'group', + item: inner, + span: { start: open.start, end: closeEnd }, + }; return group; } diff --git a/packages/core/src/findings.test.ts b/packages/core/src/findings.test.ts index 3f7523f..26aee60 100644 --- a/packages/core/src/findings.test.ts +++ b/packages/core/src/findings.test.ts @@ -62,9 +62,7 @@ describe('toFindings summary math', () => { }, { file: 'specs/b.ears', - items: [ - { input: { text: 'warn', source: { line: 2 } }, result: lint([vagueWarning]) }, - ], + items: [{ input: { text: 'warn', source: { line: 2 } }, result: lint([vagueWarning]) }], }, ]; diff --git a/packages/core/src/findings.ts b/packages/core/src/findings.ts index ac0c6ea..3142323 100644 --- a/packages/core/src/findings.ts +++ b/packages/core/src/findings.ts @@ -274,7 +274,9 @@ export function toFindings(input: FindingsInput, options: ToFindingsOptions = {} if (severity === 'error') { hasError = true; } - collected.push(buildFindingsDiagnostic(id, severity, fileGroup.file, item, diagnostic.message)); + collected.push( + buildFindingsDiagnostic(id, severity, fileGroup.file, item, diagnostic.message), + ); } if (!hasError) { valid += 1; diff --git a/packages/core/src/pipeline.test.ts b/packages/core/src/pipeline.test.ts index 8659c97..c42a3df 100644 --- a/packages/core/src/pipeline.test.ts +++ b/packages/core/src/pipeline.test.ts @@ -38,7 +38,12 @@ describe('candidatesToFindings', () => { { file: 'spec.ears', candidates: [ - candidate({ text: 'This is not a requirement at all.', line: 12, col: 7, file: 'spec.ears' }), + candidate({ + text: 'This is not a requirement at all.', + line: 12, + col: 7, + file: 'spec.ears', + }), ], }, ]; @@ -56,7 +61,15 @@ describe('candidatesToFindings', () => { const files: CandidateFile[] = [ { file: 'spec.ears', - candidates: [{ file: 'spec.ears', line: 3, text: 'Nonsense line.', locatorRuleId: 'r', profile: 'strict' }], + candidates: [ + { + file: 'spec.ears', + line: 3, + text: 'Nonsense line.', + locatorRuleId: 'r', + profile: 'strict', + }, + ], }, ]; const findings = candidatesToFindings(files, STRICT); @@ -65,7 +78,12 @@ describe('candidatesToFindings', () => { 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 })] }, + { + 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); @@ -98,7 +116,9 @@ describe('candidatesToFindings', () => { const files: CandidateFile[] = [ { file: 'a.ears', - candidates: [candidate({ requirementId: 'REQ-007', text: 'Bad requirement text.', line: 2 })], + candidates: [ + candidate({ requirementId: 'REQ-007', text: 'Bad requirement text.', line: 2 }), + ], }, ]; const findings = candidatesToFindings(files, STRICT); diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index f1ed37e..fc748b2 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -23,7 +23,12 @@ * Findings value. */ -import { toFindings, type Findings, type FindingsInputFile, type FindingsInputItem } from './findings.js'; +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'; diff --git a/packages/core/src/profiles/builtins.test.ts b/packages/core/src/profiles/builtins.test.ts index b212a13..c99ca1f 100644 --- a/packages/core/src/profiles/builtins.test.ts +++ b/packages/core/src/profiles/builtins.test.ts @@ -37,7 +37,11 @@ describe('built-in profiles', () => { }); /** 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 { +function fieldIsLooserOrEqual( + strict: Profile, + earsx: Profile, + field: keyof Profile['dialect'], +): boolean { const s = strict.dialect; const x = earsx.dialect; switch (field) { @@ -46,10 +50,15 @@ function fieldIsLooserOrEqual(strict: Profile, earsx: Profile, field: keyof Prof 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; + 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)); + return s.allowLiteralSystemName.every((literal) => + x.allowLiteralSystemName.includes(literal), + ); case 'allowStoryWrapper': case 'allowFrameMetadata': case 'allowProhibition': diff --git a/packages/core/src/profiles/builtins.ts b/packages/core/src/profiles/builtins.ts index eda1e2c..b6a1718 100644 --- a/packages/core/src/profiles/builtins.ts +++ b/packages/core/src/profiles/builtins.ts @@ -152,7 +152,8 @@ const SPECKIT: Profile = { { id: 'speckit.non-requirement-section', kind: 'heading-section', - headingPattern: '^(design|background|context|overview|non-goals?|key entities|success criteria|assumptions)$', + 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).', }, ], diff --git a/packages/core/src/profiles/diff.test.ts b/packages/core/src/profiles/diff.test.ts index d597836..9a47887 100644 --- a/packages/core/src/profiles/diff.test.ts +++ b/packages/core/src/profiles/diff.test.ts @@ -41,7 +41,9 @@ describe('diffProfile', () => { }); 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.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'); }); diff --git a/packages/core/src/profiles/diff.ts b/packages/core/src/profiles/diff.ts index d4028e6..9b4842b 100644 --- a/packages/core/src/profiles/diff.ts +++ b/packages/core/src/profiles/diff.ts @@ -71,7 +71,10 @@ export function diffProfile(profile: Profile): ProfileDiff { const dialect = profile.dialect; - if (dialect.keywordCase !== strict.dialect.keywordCase && dialect.keywordCase === 'case-insensitive') { + if ( + dialect.keywordCase !== strict.dialect.keywordCase && + dialect.keywordCase === 'case-insensitive' + ) { relaxes.push('keyword case'); } if ( diff --git a/packages/core/src/profiles/resolve.ts b/packages/core/src/profiles/resolve.ts index e00875b..bd3d7d5 100644 --- a/packages/core/src/profiles/resolve.ts +++ b/packages/core/src/profiles/resolve.ts @@ -25,8 +25,7 @@ export interface UnknownProfileError { /** The outcome of {@link resolveProfile}. */ export type ResolveProfileResult = - | { ok: true; profile: Profile } - | { ok: false; error: UnknownProfileError }; + { ok: true; profile: Profile } | { ok: false; error: UnknownProfileError }; function isProfileName(name: string): name is ProfileName { return (BUILTIN_PROFILE_NAMES as readonly string[]).includes(name); diff --git a/packages/core/src/profiles/schema.ts b/packages/core/src/profiles/schema.ts index 0b99861..7df926d 100644 --- a/packages/core/src/profiles/schema.ts +++ b/packages/core/src/profiles/schema.ts @@ -157,8 +157,7 @@ export interface ProfileValidationError { /** The outcome of {@link validateProfile}. */ export type ProfileValidationResult = - | { ok: true; profile: Profile } - | { ok: false; errors: ProfileValidationError[] }; + { 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']; @@ -201,7 +200,12 @@ 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 { +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({ @@ -213,7 +217,12 @@ function reportUnknownKeys(value: Record, known: readonly strin } } -function requireEnum(value: unknown, allowed: readonly T[], path: string, errors: Errors): value is T { +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, @@ -250,7 +259,11 @@ function requireRegexSource(value: unknown, path: string, errors: Errors): boole RegExp(value); return true; } catch { - errors.push({ path, code: 'invalid-pattern', message: `Not a valid regular expression: '${value}'.` }); + errors.push({ + path, + code: 'invalid-pattern', + message: `Not a valid regular expression: '${value}'.`, + }); return false; } } @@ -269,7 +282,11 @@ function validateLocatorRule(value: unknown, path: string, errors: Errors): void } if (!('kind' in value)) { - errors.push({ path: `${path}.kind`, code: 'missing-key', message: "Missing required key 'kind'." }); + errors.push({ + path: `${path}.kind`, + code: 'missing-key', + message: "Missing required key 'kind'.", + }); } else { requireEnum(value.kind, LOCATOR_KINDS, `${path}.kind`, errors); } @@ -312,7 +329,11 @@ function validateDialect(value: unknown, errors: Errors): void { 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'." }); + errors.push({ + path: `${path}.keywordCase`, + code: 'missing-key', + message: "Missing required key 'keywordCase'.", + }); } if ('allowLiteralSystemName' in value) { @@ -326,7 +347,12 @@ function validateDialect(value: unknown, errors: Errors): void { } if ('commaAfterLeadingClause' in value) { - requireEnum(value.commaAfterLeadingClause, COMMA_MODES, `${path}.commaAfterLeadingClause`, errors); + requireEnum( + value.commaAfterLeadingClause, + COMMA_MODES, + `${path}.commaAfterLeadingClause`, + errors, + ); } else { errors.push({ path: `${path}.commaAfterLeadingClause`, @@ -339,7 +365,11 @@ function validateDialect(value: unknown, errors: Errors): void { if (key in value) { requireBoolean(value[key], `${path}.${key}`, errors); } else { - errors.push({ path: `${path}.${key}`, code: 'missing-key', message: `Missing required key '${key}'.` }); + errors.push({ + path: `${path}.${key}`, + code: 'missing-key', + message: `Missing required key '${key}'.`, + }); } } } @@ -355,25 +385,41 @@ function validateLocator(value: unknown, errors: Errors): void { if ('documentKinds' in value) { requireStringArray(value.documentKinds, `${path}.documentKinds`, errors); } else { - errors.push({ path: `${path}.documentKinds`, code: 'missing-key', message: "Missing required key 'documentKinds'." }); + 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'." }); + 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'." }); + 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'." }); + errors.push({ + path: `${path}.codeFences`, + code: 'missing-key', + message: "Missing required key 'codeFences'.", + }); } } @@ -406,7 +452,11 @@ function validateIdFormat(value: unknown, errors: Errors): void { if ('required' in value) { requireBoolean(value.required, `${path}.required`, errors); } else { - errors.push({ path: `${path}.required`, code: 'missing-key', message: "Missing required key 'required'." }); + errors.push({ + path: `${path}.required`, + code: 'missing-key', + message: "Missing required key 'required'.", + }); } if ('pattern' in value) { @@ -428,7 +478,10 @@ 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.' }] }; + return { + ok: false, + errors: [{ path: '', code: 'not-object', message: 'Expected a profile object.' }], + }; } reportUnknownKeys(input, TOP_KEYS, '', errors); @@ -442,31 +495,51 @@ export function validateProfile(input: unknown): ProfileValidationResult { 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'." }); + 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'." }); + 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'." }); + 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'." }); + 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'." }); + errors.push({ + path: 'idFormat', + code: 'missing-key', + message: "Missing required key 'idFormat'.", + }); } if (errors.length > 0) { diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 7319b59..7ad9e3e 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -369,7 +369,8 @@ const ENTRIES: DiagnosticRegistryEntry[] = [ 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.', + goodExample: + 'When a payment webhook is received, the billing service shall retain the audit log.', profileNotes: COVERAGE_PROFILE_NOTE, }, { diff --git a/packages/core/test/profiles-schema.test.ts b/packages/core/test/profiles-schema.test.ts index fb30b0f..79d098c 100644 --- a/packages/core/test/profiles-schema.test.ts +++ b/packages/core/test/profiles-schema.test.ts @@ -14,7 +14,10 @@ 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'); +const SCHEMA_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../fixtures/profiles/schema', +); interface ExpectedError { path: string; diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts index 18a32c9..76616f0 100644 --- a/packages/extract/src/index.ts +++ b/packages/extract/src/index.ts @@ -19,11 +19,7 @@ export { extractFromContent, extractFromFile } from './dispatch.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 { extractCandidates, runPipeline, inferKind } from './pipeline.js'; export type { DocumentKind, PipelineFile, diff --git a/packages/extract/src/locator.test.ts b/packages/extract/src/locator.test.ts index 3ab01b8..c7d9f92 100644 --- a/packages/extract/src/locator.test.ts +++ b/packages/extract/src/locator.test.ts @@ -19,7 +19,9 @@ function candidates(path: string, content: string, profile = strict) { 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 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.' }); @@ -84,7 +86,9 @@ describe('markdown list-item locator (kiro)', () => { ].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.'); + expect(result[0].text).toBe( + 'WHEN a webhook arrives THE SYSTEM SHALL verify it and record the outcome.', + ); }); }); diff --git a/packages/extract/src/locator.ts b/packages/extract/src/locator.ts index 456bc13..d815cae 100644 --- a/packages/extract/src/locator.ts +++ b/packages/extract/src/locator.ts @@ -22,7 +22,12 @@ * Determinism: pure string processing. No clock, file system, or network. */ -import { isStoryWrapperLine, type Candidate, type LocatorRule, type Profile } from '@earsyntax/core'; +import { + isStoryWrapperLine, + type Candidate, + type LocatorRule, + type Profile, +} from '@earsyntax/core'; import { splitId } from './internal.js'; import { classifyFences } from './markdown-scan.js'; @@ -173,14 +178,7 @@ function applyIncludeRule( 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, - ); + return bodyLineCandidates(model, profile, file, includeCode, rule.id, () => true); default: return []; } @@ -198,8 +196,13 @@ function headingSectionCandidates( 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)), + return bodyLineCandidates( + model, + profile, + file, + includeCode, + rule.id, + (i) => model.headingLevels[i] === 0 && model.headingStacks[i].some((text) => re.test(text)), ); } @@ -247,7 +250,15 @@ function blockCandidates( if (!isEarsShaped(model.lines[i].slice(contentStart))) { continue; } - const candidate = buildCandidate(model.lines[i], contentStart, i + 1, rule.id, profile, file, allowFrameMetadata); + const candidate = buildCandidate( + model.lines[i], + contentStart, + i + 1, + rule.id, + profile, + file, + allowFrameMetadata, + ); claimed = true; if (candidate !== undefined) { candidates.push(candidate); @@ -389,7 +400,10 @@ interface ListItemMatch { } /** Match a bullet or numbered list item, honoring the marker filter. */ -function matchListItem(raw: string, marker: 'bullet' | 'ordered' | 'any'): ListItemMatch | undefined { +function matchListItem( + raw: string, + marker: 'bullet' | 'ordered' | 'any', +): ListItemMatch | undefined { if (marker !== 'ordered') { const bullet = BULLET.exec(raw); if (bullet) { diff --git a/packages/extract/src/pipeline.test.ts b/packages/extract/src/pipeline.test.ts index 8dfcad9..1fc1bea 100644 --- a/packages/extract/src/pipeline.test.ts +++ b/packages/extract/src/pipeline.test.ts @@ -16,7 +16,8 @@ describe('runPipeline', () => { files: [ { path: '-', - content: 'When a payment webhook arrives, the billing service shall verify the signature.', + content: + 'When a payment webhook arrives, the billing service shall verify the signature.', }, ], profile: strict, @@ -43,14 +44,23 @@ describe('runPipeline', () => { 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' }); + 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 }); + 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', @@ -64,7 +74,10 @@ describe('extractCandidates', () => { 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 }); + 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'); diff --git a/packages/extract/src/pipeline.ts b/packages/extract/src/pipeline.ts index 58dc730..d5b484b 100644 --- a/packages/extract/src/pipeline.ts +++ b/packages/extract/src/pipeline.ts @@ -178,7 +178,8 @@ function structuredCandidates( path: string, profile: Profile, ): { candidates: Candidate[]; notices: PipelineNotice[] } { - const result: ExtractResult = kind === 'yaml' ? extractYaml(content, path) : extractJson(content, path); + 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 = { diff --git a/test/conformance/helpers.ts b/test/conformance/helpers.ts index ff814dd..0404505 100644 --- a/test/conformance/helpers.ts +++ b/test/conformance/helpers.ts @@ -49,7 +49,10 @@ export function runCli(args: string[], options: RunOptions = {}): CliResult { } /** Run the CLI and parse its stdout as the JSON envelope, failing loudly on bad JSON. */ -export function runCliJson(args: string[], options: RunOptions = {}): { +export function runCliJson( + args: string[], + options: RunOptions = {}, +): { result: CliResult; json: Record; } { diff --git a/test/conformance/smoke.test.ts b/test/conformance/smoke.test.ts index 228d9d8..f8f4354 100644 --- a/test/conformance/smoke.test.ts +++ b/test/conformance/smoke.test.ts @@ -180,16 +180,22 @@ 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, - }); + 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, - }); + 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([]); From 1e8adc3541d3abed6b8028500eb1cc91287dab44 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 15:29:20 +0300 Subject: [PATCH 18/20] chore: remove dead code and stale references after host-native pivot Orphaned command stub, dead demo fixtures, unused pretty projection cluster in cli-contract, stale contract and README references. --- README.md | 3 +- docs/api.md | 39 +------ docs/contracts/findings.md | 24 +--- fixtures/demo/catalog.json | 58 --------- fixtures/demo/requirements.ears | 19 --- fixtures/demo/valid-only.ears | 15 --- .../src/__fixtures__/sample-run.ts | 92 --------------- packages/cli-contract/src/index.ts | 9 +- packages/cli-contract/src/input.ts | 40 ------- packages/cli-contract/src/pretty.test.ts | 65 ----------- packages/cli-contract/src/pretty.ts | 110 ------------------ packages/cli/src/commands/stub.ts | 28 ----- 12 files changed, 11 insertions(+), 491 deletions(-) delete mode 100644 fixtures/demo/catalog.json delete mode 100644 fixtures/demo/requirements.ears delete mode 100644 fixtures/demo/valid-only.ears delete mode 100644 packages/cli-contract/src/__fixtures__/sample-run.ts delete mode 100644 packages/cli-contract/src/input.ts delete mode 100644 packages/cli-contract/src/pretty.test.ts delete mode 100644 packages/cli-contract/src/pretty.ts delete mode 100644 packages/cli/src/commands/stub.ts diff --git a/README.md b/README.md index d425257..67d6ff7 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ Boundaries: The maintained design briefs in this repository are: -- [EARSYNTAX-CLI-FACADE-ALPHA-0.md](EARSYNTAX-CLI-FACADE-ALPHA-0.md) +- [docs/refactor/host-native-facade.md](docs/refactor/host-native-facade.md) (frozen authority for the host-native contracts) - [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) +- [EARSYNTAX-CLI-FACADE-ALPHA-0.md](EARSYNTAX-CLI-FACADE-ALPHA-0.md) (superseded by `docs/refactor/host-native-facade.md`) diff --git a/docs/api.md b/docs/api.md index dac0f6f..8b0b0e2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -311,42 +311,9 @@ const { findings } = runPipeline({ `@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 two shapes: -a `ReportInput` (files of `{ input, result }` items) for terminal rendering, and a -`Findings` object (from `@earsyntax/core` or `@earsyntax/extract`) for JSON, SARIF, -and the exit code. - -### Terminal model from a `ReportInput` - -Collect one item per requirement, group them by file, and pass the array to -`buildPrettyModel`: - -```ts -import { lintEars } from '@earsyntax/core'; -import { buildPrettyModel } from '@earsyntax/cli-contract'; - -const inputs = [ - { - id: 'REQ-001', - text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', - source: { file: 'requirements.ears', line: 1 }, - }, - { - id: 'REQ-002', - text: 'If the HMAC signature is invalid, the billing service shall reject the webhook.', - source: { file: 'requirements.ears', line: 2 }, - }, -]; - -const model = buildPrettyModel([ - { - path: 'requirements.ears', - items: inputs.map((input) => ({ input, result: lintEars(input.text) })), - }, -]); - -// model.summary === { files: 1, requirements: 2, errors: 1, warnings: 0, infos: 0, valid: false } -``` +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 diff --git a/docs/contracts/findings.md b/docs/contracts/findings.md index 5aa1a73..1d2f889 100644 --- a/docs/contracts/findings.md +++ b/docs/contracts/findings.md @@ -173,22 +173,8 @@ in `docs/refactor/host-native-facade.md`; this document fixes only the ## Relationship to the current code -The current CLI does not emit this model. `validate` today builds a -`ValidationResult[]` shape (`packages/cli/src/facade-types.ts:110-119`) with -`references`, `ast`, and core `Diagnostic` objects keyed by the old -`DiagnosticCode` union. The current `@earsyntax/cli-contract` `JsonReport` -(`packages/cli-contract/src/json-report.ts`) is a separate projection with an -`infos` count and `span` offsets. - -Findings v1 replaces both as the canonical result: - -- `Diagnostic.id` is the new `EARS-E###` / `EARS-W###` ID, not the old dotted - code. The migration table is in `docs/refactor/host-native-facade.md`. -- Positions are `file` + `line` (+ `col`), mapped to the original host - document, replacing the character `span` offsets that only made sense for - single-requirement input. -- There is no `references`, `ast`, `pattern`, or `infos` in the Findings model. - A command MAY expose parser detail (pattern, AST) as its own optional - command-specific data, but that data is not part of the frozen Findings - contract and `ok` never depends on it. -- `summary` gains `valid` (count of clean requirements) and drops `infos`. +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/fixtures/demo/catalog.json b/fixtures/demo/catalog.json deleted file mode 100644 index aa101a9..0000000 --- a/fixtures/demo/catalog.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "systems": [ - { "id": "SYS-BILLING", "name": "billing service", "aliases": ["billing", "BFF"] }, - { "id": "SYS-PAYMENT", "name": "payment gateway", "aliases": ["payment service"] }, - { "id": "SYS-WEBHOOK", "name": "webhook processor", "aliases": ["webhook handler"] } - ], - "events": [ - { - "id": "EVT-WEBHOOK", - "name": "a payment webhook is received", - "aliases": ["payment webhook received"] - }, - { "id": "EVT-REFUND", "name": "a refund is requested", "aliases": ["refund requested"] }, - { "id": "EVT-INVOICE", "name": "an invoice is finalized", "aliases": ["invoice finalized"] }, - { - "id": "EVT-PAYMENT-DECLINED", - "name": "a payment is declined", - "aliases": ["payment declined"] - } - ], - "states": [ - { - "id": "STATE-PROVIDER-UNAVAIL", - "name": "the payment provider is unavailable", - "aliases": ["provider unavailable"] - }, - { - "id": "STATE-MAINTENANCE", - "name": "the system is in maintenance mode", - "aliases": ["maintenance mode"] - } - ], - "features": [ - { - "id": "FEAT-DUNNING", - "name": "dunning management is enabled", - "aliases": ["dunning enabled", "dunning"] - }, - { "id": "FEAT-TAX", "name": "tax calculation is enabled", "aliases": ["tax calculation"] } - ], - "conditions": [ - { - "id": "COND-HMAC-INVALID", - "name": "the HMAC signature is invalid", - "aliases": ["invalid HMAC signature"] - }, - { - "id": "COND-PAYMENT-DECLINED", - "name": "the payment is declined", - "aliases": ["payment declined"] - }, - { - "id": "COND-STALE-TIMESTAMP", - "name": "the webhook timestamp is stale", - "aliases": ["stale webhook timestamp"] - } - ] -} diff --git a/fixtures/demo/requirements.ears b/fixtures/demo/requirements.ears deleted file mode 100644 index 1135d7f..0000000 --- a/fixtures/demo/requirements.ears +++ /dev/null @@ -1,19 +0,0 @@ -REQ-001: The billing service shall verify the HMAC signature of every incoming webhook. -REQ-002: The billing service shall retry failed webhook deliveries up to five times. -REQ-003: The payment gateway shall record an audit log entry for every processed webhook. -REQ-004: While the payment provider is unavailable, the billing service shall queue retryable events. -REQ-005: While the system is in maintenance mode, the billing service shall reject incoming webhooks. -REQ-006: When a payment webhook is received, the billing service shall verify the HMAC signature. -REQ-007: When a refund is requested, the billing service shall issue a refund to the original payment method. -REQ-008: When an invoice is finalized, the billing service shall send the invoice to the customer. -REQ-009: Where dunning management is enabled, the billing service shall retry declined charges. -REQ-010: Where tax calculation is enabled, the billing service shall add sales tax to each invoice. -REQ-011: If the HMAC signature is invalid, then the billing service shall reject the webhook. -REQ-012: If the payment is declined, then the billing service shall notify the customer. -REQ-013: If the webhook timestamp is stale, then the billing service shall discard the event. -REQ-014: While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. -REQ-015: When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook. -REQ-016: The billing service verifies the HMAC signature. -REQ-017: If the payment is declined, the billing service shall notify the customer. -REQ-018: The billing service shall respond to webhook failures in an appropriate manner. -REQ-019: While the payment provider is unavailable, the billing service shall shall queue retryable events. diff --git a/fixtures/demo/valid-only.ears b/fixtures/demo/valid-only.ears deleted file mode 100644 index df86f21..0000000 --- a/fixtures/demo/valid-only.ears +++ /dev/null @@ -1,15 +0,0 @@ -REQ-001: The billing service shall verify the HMAC signature of every incoming webhook. -REQ-002: The billing service shall retry failed webhook deliveries up to five times. -REQ-003: The payment gateway shall record an audit log entry for every processed webhook. -REQ-004: While the payment provider is unavailable, the billing service shall queue retryable events. -REQ-005: While the system is in maintenance mode, the billing service shall reject incoming webhooks. -REQ-006: When a payment webhook is received, the billing service shall verify the HMAC signature. -REQ-007: When a refund is requested, the billing service shall issue a refund to the original payment method. -REQ-008: When an invoice is finalized, the billing service shall send the invoice to the customer. -REQ-009: Where dunning management is enabled, the billing service shall retry declined charges. -REQ-010: Where tax calculation is enabled, the billing service shall add sales tax to each invoice. -REQ-011: If the HMAC signature is invalid, then the billing service shall reject the webhook. -REQ-012: If the payment is declined, then the billing service shall notify the customer. -REQ-013: If the webhook timestamp is stale, then the billing service shall discard the event. -REQ-014: While the payment provider is unavailable, when a payment webhook is received, the billing service shall queue the event for retry. -REQ-015: When a payment webhook is received, if the HMAC signature is invalid, then the billing service shall reject the webhook. diff --git a/packages/cli-contract/src/__fixtures__/sample-run.ts b/packages/cli-contract/src/__fixtures__/sample-run.ts deleted file mode 100644 index 2635984..0000000 --- a/packages/cli-contract/src/__fixtures__/sample-run.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * A hand-built linting run used across the contract tests. - * - * It exercises the shapes the builders care about: multiple files, requirements - * with and without ids, source lines and spans present and absent, and all - * three severities. It does not call the core linter (still a stub); it - * assembles {@link LintResult} values directly so the builders can be tested in - * isolation. - */ - -import type { LintResult } from '@earsyntax/core'; -import type { ReportInput } from '../input.js'; - -function result(partial: Partial & Pick): LintResult { - return { references: [], diagnostics: [], ...partial }; -} - -export const sampleRun = [ - { - path: 'specs/subscribe.ears', - items: [ - { - input: { id: 'REQ-1', text: 'The BFF shall return a 200 response.', source: { line: 3 } }, - result: result({ valid: true, pattern: 'ubiquitous' }), - }, - { - input: { - id: 'REQ-2', - text: 'When alice subscribes the BFF shall notify Redis appropriately.', - source: { line: 7 }, - }, - result: result({ - valid: false, - pattern: 'event-driven', - diagnostics: [ - { - code: 'lint.vague_response', - severity: 'warning', - message: 'A response uses vague, unverifiable wording.', - span: { start: 44, end: 57 }, - }, - { - code: 'catalog.system_unresolved', - severity: 'error', - message: 'The system does not resolve to a catalog entry.', - span: { start: 20, end: 23 }, - }, - ], - }), - }, - ], - }, - { - path: 'specs/payment.ears', - items: [ - { - // No id, no source line: exercises optional-key omission and the - // span-based SARIF region path. - input: { text: 'gibberish that does not match' }, - result: result({ - valid: false, - diagnostics: [ - { - code: 'ears.no_match', - severity: 'error', - message: 'Text does not match any EARS shell pattern.', - span: { start: 0, end: 29 }, - }, - { - code: 'lint.suspicious_text_shape', - severity: 'info', - message: 'Requirement text has a suspicious shape.', - }, - ], - }), - }, - ], - }, -] satisfies ReportInput; - -/** A clean run with no diagnostics, used for the "valid" and EXIT_OK paths. */ -export const cleanRun = [ - { - path: 'specs/clean.ears', - items: [ - { - input: { id: 'OK-1', text: 'The BFF shall persist the order.', source: { line: 1 } }, - result: result({ valid: true, pattern: 'ubiquitous' }), - }, - ], - }, -] satisfies ReportInput; diff --git a/packages/cli-contract/src/index.ts b/packages/cli-contract/src/index.ts index 10aec3f..f0decbf 100644 --- a/packages/cli-contract/src/index.ts +++ b/packages/cli-contract/src/index.ts @@ -8,9 +8,7 @@ * * 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}); the pretty - * model is a legacy projection kept compiling, to be rebuilt from Findings by a - * later agent. + * 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. @@ -27,8 +25,6 @@ export { canonicalizeFindings, serializeFindings } from './findings-report.js'; export { EXIT_LINT_ERRORS, EXIT_OK, EXIT_USAGE, exitCodeForFindings } from './exit-codes.js'; -export type { ReportInput, ReportInputFile, ReportInputItem } from './input.js'; - export { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; export type { @@ -50,6 +46,3 @@ export { SARIF_TOOL_NAME, SARIF_VERSION, } from './sarif.js'; - -export type { PrettyModel, PrettyRecord, PrettySummary } from './pretty.js'; -export { buildPrettyModel } from './pretty.js'; diff --git a/packages/cli-contract/src/input.ts b/packages/cli-contract/src/input.ts deleted file mode 100644 index 5776d34..0000000 --- a/packages/cli-contract/src/input.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Facade-neutral input contract for the report builders. - * - * These shapes describe the DATA a linting run produced, grouped by source - * file. They are intentionally decoupled from any CLI command surface: no flag - * names, no argv parsing, no I/O. A tool collects one {@link ReportInputItem} - * per requirement it linted, groups them into {@link ReportInputFile}s, and - * passes the array to `buildJsonReport`, `buildSarifLog`, or - * `buildPrettyModel`. - */ - -import type { LintResult, RequirementInput } from '@earsyntax/core'; - -/** - * One linted requirement: the requirement that went in, and the result that - * came out. - */ -export interface ReportInputItem { - /** The requirement that was linted (`id`, `text`, and optional `source`). */ - input: RequirementInput; - /** The result the core linter returned for {@link ReportInputItem.input}. */ - result: LintResult; -} - -/** - * All linted requirements that share one source file path. - */ -export interface ReportInputFile { - /** The source file path the items were extracted from. */ - path: string; - /** The linted requirements, in the order they appeared in the file. */ - items: ReportInputItem[]; -} - -/** - * The complete input to every report builder: files in the order they were - * processed, each carrying its linted requirements in source order. Builders - * never reorder files or items; array order is the contract. - */ -export type ReportInput = ReportInputFile[]; diff --git a/packages/cli-contract/src/pretty.test.ts b/packages/cli-contract/src/pretty.test.ts deleted file mode 100644 index 5f7ea6e..0000000 --- a/packages/cli-contract/src/pretty.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildPrettyModel } from './pretty.js'; -import { cleanRun, sampleRun } from './__fixtures__/sample-run.js'; - -describe('buildPrettyModel', () => { - it('flattens diagnostics in file then requirement then diagnostic order', () => { - const model = buildPrettyModel(sampleRun); - expect(model.records.map((r) => r.code)).toEqual([ - 'lint.vague_response', - 'catalog.system_unresolved', - 'ears.no_match', - 'lint.suspicious_text_shape', - ]); - }); - - it('carries file, line, severity, message, and requirement text', () => { - const first = buildPrettyModel(sampleRun).records[0]; - expect(first).toMatchObject({ - file: 'specs/subscribe.ears', - line: 7, - severity: 'warning', - code: 'lint.vague_response', - requirementText: 'When alice subscribes the BFF shall notify Redis appropriately.', - }); - }); - - it('omits line when the source line is unknown', () => { - const model = buildPrettyModel(sampleRun); - const noMatch = model.records.find((r) => r.code === 'ears.no_match'); - expect(noMatch).toBeDefined(); - expect(Object.hasOwn(noMatch as object, 'line')).toBe(false); - }); - - it('summarizes counts the same way the JSON report does', () => { - expect(buildPrettyModel(sampleRun).summary).toEqual({ - files: 2, - requirements: 3, - errors: 2, - warnings: 1, - infos: 1, - valid: false, - }); - }); - - it('produces no records but still counts requirements for a clean run', () => { - const model = buildPrettyModel(cleanRun); - expect(model.records).toEqual([]); - expect(model.summary).toEqual({ - files: 1, - requirements: 1, - errors: 0, - warnings: 0, - infos: 0, - valid: true, - }); - }); - - it('emits no ANSI escape codes', () => { - const serialized = JSON.stringify(buildPrettyModel(sampleRun)); - // The ESC byte (char code 27) introduces every ANSI sequence; the model - // carries none. - const esc = String.fromCharCode(27); - expect(serialized.includes(esc)).toBe(false); - }); -}); diff --git a/packages/cli-contract/src/pretty.ts b/packages/cli-contract/src/pretty.ts deleted file mode 100644 index 3fc3d49..0000000 --- a/packages/cli-contract/src/pretty.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * A structured model for pretty (human) rendering. - * - * This is NOT terminal output: it carries no ANSI codes, no column widths, and - * no formatting decisions. It flattens a linting run into one record per - * diagnostic plus a summary, so a CLI can render it however it likes while the - * ordering and content stay defined here. - */ - -import type { DiagnosticCode, Severity } from '@earsyntax/core'; -import type { ReportInput } from './input.js'; - -/** One diagnostic flattened for rendering, with its file and requirement. */ -export interface PrettyRecord { - /** The source file path. */ - file: string; - /** 1-based source line, when known. */ - line?: number; - /** Severity of the diagnostic. */ - severity: Severity; - /** The diagnostic code. */ - code: DiagnosticCode; - /** The human-readable message. */ - message: string; - /** The text of the requirement the diagnostic belongs to. */ - requirementText: string; -} - -/** Aggregate counts for the pretty summary line. */ -export interface PrettySummary { - /** Number of source files. */ - files: number; - /** Number of linted requirements. */ - requirements: number; - /** Total error-severity diagnostics. */ - errors: number; - /** Total warning-severity diagnostics. */ - warnings: number; - /** Total info-severity diagnostics. */ - infos: number; - /** `true` when there are no error-severity diagnostics anywhere. */ - valid: boolean; -} - -/** The complete pretty model: flattened records plus a summary. */ -export interface PrettyModel { - /** One record per diagnostic, in file then requirement then diagnostic order. */ - records: PrettyRecord[]; - /** Aggregate counts across the run. */ - summary: PrettySummary; -} - -/** - * Build a {@link PrettyModel} from a linting run. - * - * Records follow input order: files, then requirements within each file, then - * diagnostics within each requirement (in the order core produced them). - * Requirements with no diagnostics contribute to the summary counts but add no - * records. - * - * @param input The linted requirements grouped by source file. - * @returns The flattened pretty model. - */ -export function buildPrettyModel(input: ReportInput): PrettyModel { - const records: PrettyRecord[] = []; - let errors = 0; - let warnings = 0; - let infos = 0; - let requirements = 0; - - for (const file of input) { - for (const item of file.items) { - requirements += 1; - const line = item.input.source?.line; - - for (const diagnostic of item.result.diagnostics) { - if (diagnostic.severity === 'error') { - errors += 1; - } else if (diagnostic.severity === 'warning') { - warnings += 1; - } else { - infos += 1; - } - - const record: PrettyRecord = { - file: file.path, - severity: diagnostic.severity, - code: diagnostic.code, - message: diagnostic.message, - requirementText: item.input.text, - }; - if (line !== undefined) { - record.line = line; - } - records.push(record); - } - } - } - - const summary: PrettySummary = { - files: input.length, - requirements, - errors, - warnings, - infos, - valid: errors === 0, - }; - - return { records, summary }; -} diff --git a/packages/cli/src/commands/stub.ts b/packages/cli/src/commands/stub.ts deleted file mode 100644 index f4fd42e..0000000 --- a/packages/cli/src/commands/stub.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Compiling stubs for the commands whose bodies land in a later alpha. - * - * `instructions`, `init`, `doctor`, `explain`, and `profiles` are part of the - * closed command surface and appear in help, so the dispatcher routes them and - * help stays truthful. Their bodies are reimplemented by the host-native - * command agents; until then each returns a typed not-yet-reimplemented - * envelope with exit `2`. - */ - -import type { CommandContext, CommandHandler, CommandResult } from '../context.js'; -import { buildResponse } from '../response.js'; - -/** Build a handler that reports the command is not yet available in this alpha. */ -export function notImplemented(name: string, withMode = false): CommandHandler { - return (context: CommandContext): CommandResult => { - const mode = withMode ? context.args.positionals.at(0) : undefined; - const command = mode ? `${name} ${mode}` : name; - const message = `The ${command} command arrives in a later alpha of the host-native CLI.`; - const response = buildResponse({ - command, - ok: false, - diagnostics: [{ code: 'cli.not_implemented', severity: 'error', message }], - next: [], - }); - return { response, pretty: `error cli.not_implemented: ${message}`, exitCode: 2 }; - }; -} From 596c02d30333c5fd230f0fec0d2e8c457b5a4631 Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 18:39:37 +0300 Subject: [PATCH 19/20] refactor: remove duplicate registries and legacy extract surface Delete cli-contract diagnostic descriptions duplicate, legacy extractors and table path, dead exports and fields across the CLI. Wire validate exit codes to the shared contract, reject values on boolean flags, apply quiet uniformly, document idFormat as reserved, clean dist before packing. --- .design-research/readme-examples/SOURCES.md | 32 + .../readme-examples/astro.README.md | 103 +++ .../readme-examples/biome.README.md | 183 ++++ .../readme-examples/dbt-core.README.md | 78 ++ .../readme-examples/eslint.README.md | 366 ++++++++ .../readme-examples/fiber.README.md | 873 ++++++++++++++++++ .../github-changelog-generator.README.md | 367 ++++++++ .../readme-examples/httpie-cli.README.md | 107 +++ .../readme-examples/playwright.README.md | 318 +++++++ .../readme-examples/typescript.README.md | 61 ++ .../readme-examples/vite.README.md | 66 ++ README.md | 497 ++++++---- docs/api.md | 45 +- docs/contracts/profile.md | 8 + docs/input-formats.md | 239 ++--- docs/public-api.md | 26 +- docs/refactor/inventory.md | 42 +- .../src/diagnostic-registry.test.ts | 21 - .../cli-contract/src/diagnostic-registry.ts | 71 -- packages/cli-contract/src/index.ts | 29 +- packages/cli-contract/src/sarif.ts | 12 +- packages/cli/package.json | 1 + packages/cli/src/args.test.ts | 62 ++ packages/cli/src/args.ts | 16 +- packages/cli/src/cli.ts | 3 +- packages/cli/src/color.ts | 4 +- packages/cli/src/commands/.gitkeep | 0 packages/cli/src/commands/doctor.test.ts | 10 +- packages/cli/src/commands/explain.test.ts | 1 - packages/cli/src/commands/extract.test.ts | 10 +- .../cli/src/commands/instructions.test.ts | 7 +- packages/cli/src/commands/profiles.test.ts | 27 +- packages/cli/src/commands/profiles.ts | 8 +- packages/cli/src/commands/validate.test.ts | 3 +- packages/cli/src/commands/validate.ts | 11 +- packages/cli/src/commands/version.test.ts | 107 +++ packages/cli/src/commands/version.ts | 5 +- packages/cli/src/errors.ts | 6 +- packages/cli/src/renderers/agents.ts | 2 - packages/cli/src/renderers/hosts.ts | 24 +- packages/cli/src/response.ts | 6 - packages/cli/src/rules.ts | 6 + packages/cli/src/version.ts | 9 +- packages/core/src/findings.ts | 2 +- packages/core/src/profiles/schema.ts | 12 +- .../core/test/profiles-strict-earsx.test.ts | 40 +- packages/extract/src/dispatch.test.ts | 87 -- packages/extract/src/dispatch.ts | 73 -- packages/extract/src/ears.test.ts | 118 --- packages/extract/src/ears.ts | 49 - packages/extract/src/index.ts | 13 +- packages/extract/src/markdown-scan.test.ts | 32 +- packages/extract/src/markdown-scan.ts | 85 +- packages/extract/src/markdown.test.ts | 227 ----- packages/extract/src/markdown.ts | 212 ----- 55 files changed, 3268 insertions(+), 1554 deletions(-) create mode 100644 .design-research/readme-examples/SOURCES.md create mode 100644 .design-research/readme-examples/astro.README.md create mode 100644 .design-research/readme-examples/biome.README.md create mode 100644 .design-research/readme-examples/dbt-core.README.md create mode 100644 .design-research/readme-examples/eslint.README.md create mode 100644 .design-research/readme-examples/fiber.README.md create mode 100644 .design-research/readme-examples/github-changelog-generator.README.md create mode 100644 .design-research/readme-examples/httpie-cli.README.md create mode 100644 .design-research/readme-examples/playwright.README.md create mode 100644 .design-research/readme-examples/typescript.README.md create mode 100644 .design-research/readme-examples/vite.README.md delete mode 100644 packages/cli-contract/src/diagnostic-registry.test.ts delete mode 100644 packages/cli-contract/src/diagnostic-registry.ts create mode 100644 packages/cli/src/args.test.ts delete mode 100644 packages/cli/src/commands/.gitkeep create mode 100644 packages/cli/src/commands/version.test.ts delete mode 100644 packages/extract/src/dispatch.test.ts delete mode 100644 packages/extract/src/dispatch.ts delete mode 100644 packages/extract/src/ears.test.ts delete mode 100644 packages/extract/src/ears.ts delete mode 100644 packages/extract/src/markdown.test.ts delete mode 100644 packages/extract/src/markdown.ts diff --git a/.design-research/readme-examples/SOURCES.md b/.design-research/readme-examples/SOURCES.md new file mode 100644 index 0000000..3cf6c89 --- /dev/null +++ b/.design-research/readme-examples/SOURCES.md @@ -0,0 +1,32 @@ +# README Example Sources + +These README files were copied for structural research while shaping the +`earsyntax` README. Use them to compare layout, sequencing, tone, command +examples, status boundaries, and project positioning. + +Do not copy prose verbatim into `earsyntax`. Reuse structure and documentation +patterns, and check each source repository's license before reusing any text. + +| Local file | Repository | Source README | License family | +| -------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------- | +| `astro.README.md` | `withastro/astro` | | MIT | +| `biome.README.md` | `biomejs/biome` | | MIT / Apache-2.0 | +| `dbt-core.README.md` | `dbt-labs/dbt-core` | | Apache-2.0 | +| `eslint.README.md` | `eslint/eslint` | | MIT | +| `fiber.README.md` | `gofiber/fiber` | | MIT | +| `github-changelog-generator.README.md` | `github-changelog-generator/github-changelog-generator` | | MIT | +| `httpie-cli.README.md` | `httpie/cli` | | BSD-3-Clause | +| `playwright.README.md` | `microsoft/playwright` | | Apache-2.0 | +| `typescript.README.md` | `microsoft/TypeScript` | | Apache-2.0 | +| `vite.README.md` | `vitejs/vite` | | MIT | + +## What To Compare + +- First screen: purpose, audience, and first useful command. +- Quickstart: how quickly a user reaches a working example. +- Expected output: whether the README shows what success looks like. +- CLI shape: how commands, flags, and configuration are summarized. +- Status boundaries: how alpha, experimental, or unsupported behavior is named. +- Routing: what stays in the README versus what moves to docs. +- Contribution/support links: how maintainers direct issue reports and community + usage. diff --git a/.design-research/readme-examples/astro.README.md b/.design-research/readme-examples/astro.README.md new file mode 100644 index 0000000..6a29188 --- /dev/null +++ b/.design-research/readme-examples/astro.README.md @@ -0,0 +1,103 @@ +![Build the web you want](.github/assets/banner.jpg 'Build the web you want') + +

+
+
Astro is a website build tool for the modern web — +
+ powerful developer experience meets lightweight output. +

+

+ +
+ +[![main](https://github.com/withastro/astro/actions/workflows/ci.yml/badge.svg)](https://github.com/withastro/astro/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/withastro/astro/blob/main/LICENSE) +[![npm version](https://badge.fury.io/js/astro.svg)](https://badge.fury.io/js/astro) + +
+ +## Install + +The **recommended** way to install the latest version of Astro is by running the command below: + +```bash +npm create astro@latest +``` + +You can also install Astro **manually** by running this command instead: + +```bash +npm install astro +``` + +Looking for help? Start with our [Getting Started](https://docs.astro.build/en/getting-started/) guide. + +Looking for quick examples? [Open a starter project](https://astro.new/) right in your browser. + +## Documentation + +Visit our [official documentation](https://docs.astro.build/). + +## Support + +Having trouble? Get help in the official [Astro Discord](https://astro.build/chat). + +## Contributing + +**New contributors welcome!** Check out our [Contributors Guide](CONTRIBUTING.md) for help getting started. + +Join us on [Discord](https://astro.build/chat) to meet other maintainers. We'll help you get your first contribution in no time! + +## Directory + +| Package | Release Notes | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [astro](packages/astro) | [![astro version](https://img.shields.io/npm/v/astro.svg?label=%20)](packages/astro/CHANGELOG.md) | +| [create-astro](packages/create-astro) | [![create-astro version](https://img.shields.io/npm/v/create-astro.svg?label=%20)](packages/create-astro/CHANGELOG.md) | +| [@astrojs/react](packages/integrations/react) | [![@astrojs/react version](https://img.shields.io/npm/v/@astrojs/react.svg?label=%20)](packages/integrations/react/CHANGELOG.md) | +| [@astrojs/preact](packages/integrations/preact) | [![@astrojs/preact version](https://img.shields.io/npm/v/@astrojs/preact.svg?label=%20)](packages/integrations/preact/CHANGELOG.md) | +| [@astrojs/solid-js](packages/integrations/solid) | [![@astrojs/solid version](https://img.shields.io/npm/v/@astrojs/solid-js.svg?label=%20)](packages/integrations/solid/CHANGELOG.md) | +| [@astrojs/svelte](packages/integrations/svelte) | [![@astrojs/svelte version](https://img.shields.io/npm/v/@astrojs/svelte.svg?label=%20)](packages/integrations/svelte/CHANGELOG.md) | +| [@astrojs/vue](packages/integrations/vue) | [![@astrojs/vue version](https://img.shields.io/npm/v/@astrojs/vue.svg?label=%20)](packages/integrations/vue/CHANGELOG.md) | +| [@astrojs/node](packages/integrations/node) | [![@astrojs/node version](https://img.shields.io/npm/v/@astrojs/node.svg?label=%20)](packages/integrations/node/CHANGELOG.md) | +| [@astrojs/vercel](packages/integrations/vercel) | [![@astrojs/vercel version](https://img.shields.io/npm/v/@astrojs/vercel.svg?label=%20)](packages/integrations/vercel/CHANGELOG.md) | +| [@astrojs/cloudflare](packages/integrations/cloudflare) | [![@astrojs/cloudflare version](https://img.shields.io/npm/v/@astrojs/cloudflare.svg?label=%20)](packages/integrations/cloudflare/CHANGELOG.md) | +| [@astrojs/partytown](packages/integrations/partytown) | [![@astrojs/partytown version](https://img.shields.io/npm/v/@astrojs/partytown.svg?label=%20)](packages/integrations/partytown/CHANGELOG.md) | +| [@astrojs/sitemap](packages/integrations/sitemap) | [![@astrojs/sitemap version](https://img.shields.io/npm/v/@astrojs/sitemap.svg?label=%20)](packages/integrations/sitemap/CHANGELOG.md) | +| [@astrojs/alpinejs](packages/integrations/alpinejs) | [![@astrojs/alpinejs version](https://img.shields.io/npm/v/@astrojs/alpinejs.svg?label=%20)](packages/integrations/alpinejs/CHANGELOG.md) | +| [@astrojs/mdx](packages/integrations/mdx) | [![@astrojs/mdx version](https://img.shields.io/npm/v/@astrojs/mdx.svg?label=%20)](packages/integrations/mdx/CHANGELOG.md) | +| [@astrojs/rss](packages/astro-rss) | [![@astrojs/rss version](https://img.shields.io/npm/v/@astrojs/rss.svg?label=%20)](packages/astro-rss/CHANGELOG.md) | +| [@astrojs/netlify](packages/integrations/netlify) | [![@astrojs/netlify version](https://img.shields.io/npm/v/@astrojs/netlify.svg?label=%20)](packages/integrations/netlify/CHANGELOG.md) | +| [@astrojs/check](packages/language-tools/astro-check) | [![astro-check version](https://img.shields.io/npm/v/@astrojs/check.svg?label=%20)](packages/language-tools/astro-check/CHANGELOG.md) | +| [@astrojs/language-server](packages/language-tools/language-server) | [![@astrojs/language-server version](https://img.shields.io/npm/v/@astrojs/language-server.svg?label=%20)](packages/language-tools/language-server/CHANGELOG.md) | +| [@astrojs/ts-plugin](packages/language-tools/ts-plugin) | [![@astrojs/ts-plugin version](https://img.shields.io/npm/v/@astrojs/ts-plugin.svg?label=%20)](packages/language-tools/ts-plugin/CHANGELOG.md) | +| [astro-vscode](packages/language-tools/vscode) | [![astro-vscode version](https://img.shields.io/npm/v/astro-vscode.svg?label=%20)](packages/language-tools/vscode/CHANGELOG.md) | + +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/6178/badge)](https://bestpractices.coreinfrastructure.org/projects/6178) + +Several official projects are maintained outside of this repo: + +| Project | Repository | +| ---------------------------------------------------------- | ------------------------------------------------------------- | +| [@astrojs/compiler](https://github.com/withastro/compiler) | [withastro/compiler](https://github.com/withastro/compiler) | +| [Starlight](https://github.com/withastro/starlight) | [withastro/starlight](https://github.com/withastro/starlight) | + +## Links + +- [License (MIT)](LICENSE) +- [Code of Conduct](https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md) +- [Open Governance & Voting](https://github.com/withastro/.github/blob/main/GOVERNANCE.md) +- [Project Funding](https://github.com/withastro/.github/blob/main/FUNDING.md) +- [Website](https://astro.build/) + +## Sponsors + +Astro is free, open source software made possible by these wonderful sponsors. + +[❤️ Sponsor Astro! ❤️](https://github.com/withastro/.github/blob/main/FUNDING.md) + +

+ + Sponsor logos including the current Astro Sponsors, Gold Sponsors, and Exclusive Partner Sponsors: Netlify, Sentry, and Project IDX. + +

diff --git a/.design-research/readme-examples/biome.README.md b/.design-research/readme-examples/biome.README.md new file mode 100644 index 0000000..7d8ec57 --- /dev/null +++ b/.design-research/readme-examples/biome.README.md @@ -0,0 +1,183 @@ +
+ + + + Shows the banner of Biome, with its logo and the phrase 'Biome - Toolchain of the web'. + + +
+
+ +[![CI on main][ci-badge]][ci-url] +[![Discord chat][discord-badge]][discord-url] +[![npm version][npm-badge]][npm-url] +[![VSCode version][vscode-badge]][vscode-url] +[![Open VSX version][open-vsx-badge]][open-vsx-url] + +[ci-badge]: https://github.com/biomejs/biome/actions/workflows/main.yml/badge.svg +[ci-url]: https://github.com/biomejs/biome/actions/workflows/main.yml +[discord-badge]: https://badgen.net/discord/online-members/BypW39g6Yc?icon=discord&label=discord&color=60a5fa +[discord-url]: https://biomejs.dev/chat +[npm-badge]: https://npmx.dev/api/registry/badge/version/@biomejs/biome?color=60a5fa&style=shieldsio&label=%40biomejs%2Fbiome +[npm-url]: https://npmx.dev/package/@biomejs/biome +[vscode-badge]: https://img.shields.io/visual-studio-marketplace/v/biomejs.biome?label=Visual%20Studio%20Marketplace&labelColor=374151&color=60a5fa +[vscode-url]: https://marketplace.visualstudio.com/items?itemName=biomejs.biome +[open-vsx-badge]: https://img.shields.io/visual-studio-marketplace/v/biomejs.biome?label=Open%20VSX%20Registry&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2aWV3Qm94PSI0LjYgNSA5Ni4yIDEyMi43IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Ik0zMCA0NC4yTDUyLjYgNUg3LjN6TTQuNiA4OC41aDQ1LjNMMjcuMiA0OS40em01MSAwbDIyLjYgMzkuMiAyMi42LTM5LjJ6IiBmaWxsPSIjYzE2MGVmIi8+CiAgPHBhdGggZD0iTTUyLjYgNUwzMCA0NC4yaDQ1LjJ6TTI3LjIgNDkuNGwyMi43IDM5LjEgMjIuNi0zOS4xem01MSAwTDU1LjYgODguNWg0NS4yeiIgZmlsbD0iI2E2MGVlNSIvPgo8L3N2Zz4=&labelColor=374151&color=60a5fa +[open-vsx-url]: https://open-vsx.org/extension/biomejs/biome + + + +[हिन्दी](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.hi.md) | English | [Español](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.es.md) | [Français](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.fr.md) | [繁體中文](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.zh-TW.md) | [简体中文](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.zh-CN.md) | [日本語](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.ja.md) | [Polski](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.pl.md) | [Português do Brasil](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.pt-BR.md) | [한국어](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.kr.md) | [Русский](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.ru.md) | [Українська](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.uk.md) +
+ +
+ +**Biome** is a performant toolchain for web projects, it aims to provide developer tools to maintain the health of said projects. + +**Biome is a [fast formatter](https://github.com/biomejs/benchmark#formatting)** for _JavaScript_, _TypeScript_, _JSX_, _JSON_, _CSS_ and _GraphQL_ that scores **[97% compatibility with _Prettier_](https://algora.io/challenges/prettier)**. + +**Biome is a [performant linter](https://github.com/biomejs/benchmark#linting)** for _JavaScript_, _TypeScript_, _JSX_, _JSON_, _CSS_, and _GraphQL_ that features **[more than 500 rules](https://biomejs.dev/linter/javascript/rules/)** from ESLint, typescript-eslint, and [other sources](https://github.com/biomejs/biome/discussions/3). +It **outputs detailed and contextualized diagnostics** that help you to improve your code and become a better programmer! + +**Biome** is designed from the start to be used [interactively within an editor](https://biomejs.dev/editors/first-party-extensions/). +It can format and lint malformed code as you are writing it. + +### Installation + +```shell +npm install --save-dev --save-exact @biomejs/biome +``` + +### Usage + +```shell +# format files +npx @biomejs/biome format --write + +# lint files and apply the safe fixes +npx @biomejs/biome lint --write + +# run format, lint, etc. and apply the safe fixes +npx @biomejs/biome check --write + +# check all files against format, lint, etc. in CI environments +npx @biomejs/biome ci +``` + +If you want to give Biome a run without installing it, use the [online playground](https://biomejs.dev/playground/), compiled to WebAssembly. + +## Documentation + +Check out our [homepage][biomejs] to learn more about Biome, +or directly head to the [Getting Started guide][getting-started] to start using Biome. + +## More about Biome + +**Biome** has sane defaults and it doesn't require configuration. + +**Biome** aims to support [all main languages][language-support] of modern web development. + +**Biome** [doesn't require Node.js](https://biomejs.dev/guides/manual-installation/) to function. + +**Biome** has first-class LSP support, with a sophisticated parser that represents the source text in full fidelity and top-notch error recovery. + +**Biome** wants to offer a high-quality _Developer Experience_, with descriptive diagnostics and great performance. + +**Biome** unifies functionalities that have previously been separate tools. Building upon a shared base allows us to provide a cohesive experience for processing code, displaying errors, parallelize work, caching, and configuration. + +Read more about our [project philosophy][biome-philosophy]. + +**Biome** is [MIT licensed](https://github.com/biomejs/biome/tree/main/LICENSE-MIT) or [Apache 2.0 licensed](https://github.com/biomejs/biome/tree/main/LICENSE-APACHE) and moderated under the [Contributor Covenant Code of Conduct](https://github.com/biomejs/biome/tree/main/CODE_OF_CONDUCT.md). + +## Funding + +You can fund the project in different ways + +### Project sponsorship and funding + +You can sponsor or fund the project via [Open collective](https://opencollective.com/biome) or [GitHub sponsors](https://github.com/sponsors/biomejs) + +Biome offers a simple sponsorship program that allows companies to get visibility and recognition among various developers. + +Biome offers [enterprise support](https://biomejs.dev/enterprise), where Core Contributors can be employed to work on company-focused projects. + +## Sponsors + +### Platinum Sponsors + + + + + + + +
+ + + + + Depot logo + + +
+ +### Silver Sponsors + + + + + + + + +
+ Lokalise logo + + Latitude logo +
+ +### Bronze Sponsors + + + + + + + + + + + + + + + + +
+ Vital logo + + CodeRabbit logo + + Forge42 logo + + RStudio logo + + Pennylane logo + + JetBrains logo + + EGSTOCK, Inc. logo + + Convex logo + + Graphite logo + + Kraken Tech logo +
+ +[biomejs]: https://biomejs.dev/ +[biome-philosophy]: https://biomejs.dev/internals/philosophy/ +[language-support]: https://biomejs.dev/internals/language-support/ +[getting-started]: https://biomejs.dev/guides/getting-started/ diff --git a/.design-research/readme-examples/dbt-core.README.md b/.design-research/readme-examples/dbt-core.README.md new file mode 100644 index 0000000..712dd1a --- /dev/null +++ b/.design-research/readme-examples/dbt-core.README.md @@ -0,0 +1,78 @@ +

+ dbt logo +

+ +> [!WARNING] +> **dbt Core v1 development has moved to the [`1.latest`](https://github.com/dbt-labs/dbt-core/tree/1.latest) branch.** +> The `main` branch now hosts dbt Core v2.0 (alpha) — a ground-up rewrite in Rust that is the foundation of the Fusion engine. If you're looking for the Python implementation of dbt Core, switch to [`1.latest`](https://github.com/dbt-labs/dbt-core/tree/1.latest). + +**[dbt](https://www.getdbt.com/)** enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications. + +![architecture](https://raw.githubusercontent.com/dbt-labs/dbt-core/202cb7e51e218c7b29eb3b11ad058bd56b7739de/etc/dbt-transform.png) + +## About dbt Core v2.0 + +> 🚧 dbt Core v2.0 is in alpha. Behavior, APIs, and on-disk formats may change before the stable release. + +dbt Core v2.0 is engineered for performance at scale — parsing, compiling, and running projects in a fraction of the time compared to v1. It's released under the Apache 2.0 license and is the foundation of the [Fusion engine](https://docs.getdbt.com/docs/fusion/about-fusion). + +The big shifts from v1: + +- **Faster** — parse and compile times are dramatically improved, especially on the largest dbt projects. +- **Stricter** — a tightly-defined language specification enforces correctness at parse time. +- **More scalable artifacts** — v2.0 produces Parquet artifacts that can be easily queried, joined, and analyzed to understand your dbt project. The artifacts encompass everything in the JSON artifacts (e.g. `manifest.json`), which continue to be produced for backwards compatibility. +- **Easier to install** — distributed as a single self-contained binary, with no Python runtime or dependency management required. +- **A completely revamped local documentation experience** — dbt docs is now powered by those new artifacts and capable of scaling to large projects. + +### Supported operating systems and architectures + +dbt Core v2.0 and its drivers are compiled per operating system and architecture. + +Legend: + +- 🟢 — Supported today +- 🟡 — Not yet supported + +| Operating system | x86-64 | ARM | +| ---------------- | ------ | --- | +| macOS | 🟢 | 🟢 | +| Linux | 🟢 | 🟢 | +| Windows | 🟢 | 🟡 | + +## Understanding dbt + +Analysts using dbt can transform their data by simply writing select statements, while dbt handles turning these statements into tables and views in a data warehouse. + +These select statements, or "models", form a dbt project. Models frequently build on top of one another – dbt makes it easy to [manage relationships](https://docs.getdbt.com/docs/ref) between models, and [visualize these relationships](https://docs.getdbt.com/docs/documentation), as well as assure the quality of your transformations through [testing](https://docs.getdbt.com/docs/testing). + +![dbt dag](https://raw.githubusercontent.com/dbt-labs/dbt-core/6c6649f9129d5d108aa3b0526f634cd8f3a9d1ed/etc/dbt-dag.png) + +## Getting started + +Start by choosing a distribution. dbt Core is the baseline distribution of dbt. Fusion extends dbt Core with additional SQL comprehension abilities. Both distributions are free to install and can run locally. + +- **If you need an Apache 2.0 licensed tool** and the ability to review every line of code inside of it, [install dbt Core](https://docs.getdbt.com/docs/local/install-dbt#dbt-core). +- **If you need a free CLI you can use locally**, [install Fusion](https://docs.getdbt.com/docs/local/install-dbt#dbt-fusion-engine-recommended). It can do more than dbt Core out of the box and you can seamlessly enable other advanced features over time if you choose to. + +Regardless of the distribution you choose, each is part of a single framework with a single language specification, meaning your business logic is portable in both directions. + +Explore the [dbt platform](https://docs.getdbt.com/docs/cloud/about-cloud/dbt-cloud-features) for an enhanced collaboration experience. +Read the [introduction](https://docs.getdbt.com/docs/introduction/) and [viewpoint](https://docs.getdbt.com/docs/about/viewpoint/) + +## Join the dbt Community + +- Be part of the conversation in the [dbt Community Slack](http://community.getdbt.com/) +- Read more on the [dbt Community Discourse](https://discourse.getdbt.com) + +## Reporting bugs and contributing code + +- Want to report a bug or request a feature? Let us know and open [an issue](https://github.com/dbt-labs/dbt-core/issues/new/choose) +- Want to help us build dbt? Check out the [Contributing Guide](https://github.com/dbt-labs/dbt-core/blob/HEAD/CONTRIBUTING.md) + +## Code of Conduct + +Everyone interacting in the dbt project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [dbt Code of Conduct](https://docs.getdbt.com/community/resources/code-of-conduct). + +## License + +dbt Core is licensed under the [Apache License 2.0](LICENSE). diff --git a/.design-research/readme-examples/eslint.README.md b/.design-research/readme-examples/eslint.README.md new file mode 100644 index 0000000..cae057e --- /dev/null +++ b/.design-research/readme-examples/eslint.README.md @@ -0,0 +1,366 @@ +[![npm version](https://img.shields.io/npm/v/eslint.svg)](https://www.npmjs.com/package/eslint) +[![Downloads](https://img.shields.io/npm/dm/eslint.svg)](https://www.npmjs.com/package/eslint) +[![Build Status](https://github.com/eslint/eslint/workflows/CI/badge.svg)](https://github.com/eslint/eslint/actions) +
+[![Open Collective Backers](https://img.shields.io/opencollective/backers/eslint)](https://opencollective.com/eslint) +[![Open Collective Sponsors](https://img.shields.io/opencollective/sponsors/eslint)](https://opencollective.com/eslint) + +# ESLint + +[Website](https://eslint.org) | +[Configure ESLint](https://eslint.org/docs/latest/use/configure) | +[Rules](https://eslint.org/docs/rules/) | +[Contribute to ESLint](https://eslint.org/docs/latest/contribute) | +[Report Bugs](https://eslint.org/docs/latest/contribute/report-bugs) | +[Code of Conduct](https://eslint.org/conduct) | +[X](https://x.com/geteslint) | +[Discord](https://eslint.org/chat) | +[Mastodon](https://fosstodon.org/@eslint) | +[Bluesky](https://bsky.app/profile/eslint.org) + +ESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code. In many ways, it is similar to JSLint and JSHint with a few exceptions: + +- ESLint uses [Espree](https://github.com/eslint/js/tree/main/packages/espree) for JavaScript parsing. +- ESLint uses an AST to evaluate patterns in code. +- ESLint is completely pluggable, every single rule is a plugin and you can add more at runtime. + +## Table of Contents + +1. [Installation and Usage](#installation-and-usage) +1. [Configuration](#configuration) +1. [Version Support](#version-support) +1. [Code of Conduct](#code-of-conduct) +1. [Filing Issues](#filing-issues) +1. [Frequently Asked Questions](#frequently-asked-questions) +1. [Releases](#releases) +1. [Security Policy](#security-policy) +1. [Semantic Versioning Policy](#semantic-versioning-policy) +1. [ESM Dependencies](#esm-dependencies) +1. [License](#license) +1. [Team](#team) +1. [Sponsors](#sponsors) +1. [Technology Sponsors](#technology-sponsors) + +## Installation and Usage + +### Prerequisites + +To use ESLint, you must have [Node.js](https://nodejs.org/) (`^20.19.0`, `^22.13.0`, or `>=24`) installed and built with SSL and ICU support. (If you are using an official Node.js distribution, both SSL and ICU are always built in.) + +If you use ESLint's TypeScript type definitions, TypeScript 5.3 or later is required. + +### npm Installation + +You can install and configure ESLint using this command: + +```shell +npm init @eslint/config@latest +``` + +After that, you can run ESLint on any file or directory like this: + +```shell +npx eslint yourfile.js +``` + +### pnpm Installation + +To use ESLint with pnpm, we recommend setting up a `.npmrc` file with at least the following settings: + +```text +auto-install-peers=true +node-linker=hoisted +``` + +This ensures that pnpm installs dependencies in a way that is more compatible with npm and is less likely to produce errors. + +## Configuration + +You can configure rules in your `eslint.config.js` files as in this example: + +```js +import { defineConfig } from 'eslint/config'; + +export default defineConfig([ + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + rules: { + 'prefer-const': 'warn', + 'no-constant-binary-expression': 'error', + }, + }, +]); +``` + +The names `"prefer-const"` and `"no-constant-binary-expression"` are the names of [rules](https://eslint.org/docs/rules) in ESLint. The first value is the error level of the rule and can be one of these values: + +- `"off"` or `0` - turn the rule off +- `"warn"` or `1` - turn the rule on as a warning (doesn't affect exit code) +- `"error"` or `2` - turn the rule on as an error (exit code will be 1) + +The three error levels allow you fine-grained control over how ESLint applies rules (for more configuration options and details, see the [configuration docs](https://eslint.org/docs/latest/use/configure)). + +## Version Support + +The ESLint team provides ongoing support for the current version and six months of limited support for the previous version. Limited support includes critical bug fixes, security issues, and compatibility issues only. + +ESLint offers commercial support for both current and previous versions through our partners, [Tidelift][tidelift] and [HeroDevs][herodevs]. + +See [Version Support](https://eslint.org/version-support) for more details. + +## Code of Conduct + +ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + +## Filing Issues + +Before filing an issue, please be sure to read the guidelines for what you're reporting: + +- [Bug Report](https://eslint.org/docs/latest/contribute/report-bugs) +- [Propose a New Rule](https://eslint.org/docs/latest/contribute/propose-new-rule) +- [Proposing a Rule Change](https://eslint.org/docs/latest/contribute/propose-rule-change) +- [Request a Change](https://eslint.org/docs/latest/contribute/request-change) + +## Frequently Asked Questions + +### Does ESLint support JSX? + +Yes, ESLint natively supports parsing JSX syntax (this must be enabled in [configuration](https://eslint.org/docs/latest/use/configure)). Please note that supporting JSX syntax _is not_ the same as supporting React. React applies specific semantics to JSX syntax that ESLint doesn't recognize. We recommend using [eslint-plugin-react](https://www.npmjs.com/package/eslint-plugin-react) if you are using React and want React semantics. + +### Does Prettier replace ESLint? + +No, ESLint and Prettier have different jobs: ESLint is a linter (looking for problematic patterns) and Prettier is a code formatter. Using both tools is common, refer to [Prettier's documentation](https://prettier.io/docs/en/install#eslint-and-other-linters) to learn how to configure them to work well with each other. + +### What ECMAScript versions does ESLint support? + +ESLint has full support for ECMAScript 3, 5, and every year from 2015 up until the most recent stage 4 specification (the default). You can set your desired ECMAScript syntax and other settings (like global variables) through [configuration](https://eslint.org/docs/latest/use/configure). + +### What about experimental features? + +ESLint's parser only officially supports the latest final ECMAScript standard. We will make changes to core rules in order to avoid crashes on stage 3 ECMAScript syntax proposals (as long as they are implemented using the correct experimental ESTree syntax). We may make changes to core rules to better work with language extensions (such as JSX, Flow, and TypeScript) on a case-by-case basis. + +In other cases (including if rules need to warn on more or fewer cases due to new syntax, rather than just not crashing), we recommend you use other parsers and/or rule plugins. If you are using Babel, you can use [@babel/eslint-parser](https://www.npmjs.com/package/@babel/eslint-parser) and [@babel/eslint-plugin](https://www.npmjs.com/package/@babel/eslint-plugin) to use any option available in Babel. + +Once a language feature has been adopted into the ECMAScript standard (stage 4 according to the [TC39 process](https://tc39.github.io/process-document/)), we will accept issues and pull requests related to the new feature, subject to our [contributing guidelines](https://eslint.org/docs/latest/contribute). Until then, please use the appropriate parser and plugin(s) for your experimental feature. + +### Which Node.js versions does ESLint support? + +ESLint updates the supported Node.js versions with each major release of ESLint. At that time, ESLint's supported Node.js versions are updated to be: + +1. The most recent maintenance release of Node.js +1. The lowest minor version of the Node.js LTS release that includes the features the ESLint team wants to use. +1. The Node.js Current release + +ESLint is also expected to work with Node.js versions released after the Node.js Current release. + +Refer to the [Quick Start Guide](https://eslint.org/docs/latest/use/getting-started#prerequisites) for the officially supported Node.js versions for a given ESLint release. + +### Where to ask for help? + +Open a [discussion](https://github.com/eslint/eslint/discussions) or stop by our [Discord server](https://eslint.org/chat). + +### Why doesn't ESLint lock dependency versions? + +Lock files like `package-lock.json` are helpful for deployed applications. They ensure that dependencies are consistent between environments and across deployments. + +Packages like `eslint` that get published to the npm registry do not include lock files. `npm install eslint` as a user will respect version constraints in ESLint's `package.json`. ESLint and its dependencies will be included in the user's lock file if one exists, but ESLint's own lock file would not be used. + +We intentionally don't lock dependency versions so that we have the latest compatible dependency versions in development and CI that our users get when installing ESLint in a project. + +The Twilio blog has a [deeper dive](https://www.twilio.com/blog/lockfiles-nodejs) to learn more. + +## Releases + +We have scheduled releases every two weeks on Friday or Saturday. You can follow a [release issue](https://github.com/eslint/eslint/issues?q=is%3Aopen+is%3Aissue+label%3Arelease) for updates about the scheduling of any particular release. + +## Security Policy + +ESLint takes security seriously. We work hard to ensure that ESLint is safe for everyone and that security issues are addressed quickly and responsibly. Read the full [security policy](https://github.com/eslint/.github/blob/master/SECURITY.md). + +## Semantic Versioning Policy + +ESLint follows [semantic versioning](https://semver.org). However, due to the nature of ESLint as a code quality tool, it's not always clear when a minor or major version bump occurs. To help clarify this for everyone, we've defined the following semantic versioning policy for ESLint: + +- Patch release (intended to not break your lint build) + - A bug fix in a rule that results in ESLint reporting fewer linting errors. + - A bug fix to the CLI or core (including formatters). + - Improvements to documentation. + - Non-user-facing changes such as refactoring code, adding, deleting, or modifying tests, and increasing test coverage. + - Re-releasing after a failed release (i.e., publishing a release that doesn't work for anyone). +- Minor release (might break your lint build) + - A bug fix that results in ESLint reporting more linting errors (e.g., fixing false negatives in a core rule, or linting additional files that were previously incorrectly skipped). + - A new rule is created. + - A new option to an existing rule that does not result in ESLint reporting more linting errors by default. + - A new addition to an existing rule to support a newly-added language feature (within the last 12 months) that will result in ESLint reporting more linting errors by default. + - An existing rule is deprecated. + - A new CLI capability is created. + - New capabilities to the public API are added (new classes, new methods, new arguments to existing methods, etc.). + - A new formatter is created. + - `eslint:recommended` is updated and will result in strictly fewer linting errors (e.g., rule removals). +- Major release (likely to break your lint build) + - `eslint:recommended` is updated and may result in new linting errors (e.g., rule additions, most rule option updates). + - A new option to an existing rule that results in ESLint reporting more linting errors by default. + - An existing formatter is removed. + - Part of the public API is removed or changed in an incompatible way. The public API includes: + - Rule schemas + - Configuration schema + - Command-line options + - Node.js API + - Rule, formatter, parser, plugin APIs + +According to our policy, any minor update may report more linting errors than the previous release (ex: from a bug fix). As such, we recommend using the tilde (`~`) in `package.json` e.g. `"eslint": "~3.1.0"` to guarantee the results of your builds. + +## ESM Dependencies + +Since ESLint is a CommonJS package, there are restrictions on which ESM-only packages can be used as dependencies. + +Packages that are controlled by the ESLint team and have no external dependencies can be safely loaded synchronously using [`require(esm)`](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require) and therefore used in any contexts. + +For external packages, we don't use `require(esm)` because a package could add a top-level `await` and thus break ESLint. We can use an external ESM-only package only in case it is needed only in asynchronous code, in which case it can be loaded using dynamic `import()`. + +These policies don't apply to packages intended for our own usage only, such as `eslint-config-eslint`. + +## License + +MIT License + +Copyright OpenJS Foundation and other contributors, + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +## Team + +These folks keep the project moving and are resources for help. + + + + + +### Technical Steering Committee (TSC) + +The people who manage releases, review feature requests, and meet regularly to ensure ESLint is properly maintained. + +
+ +Nicholas C. Zakas's Avatar
+Nicholas C. Zakas +
+
+ +Francesco Trotta's Avatar
+Francesco Trotta +
+
+ +Milos Djermanovic's Avatar
+Milos Djermanovic +
+
+ +### Reviewers + +The people who review and implement new features. + +
+ +唯然's Avatar
+唯然 +
+
+ +Nitin Kumar's Avatar
+Nitin Kumar +
+
+ +### Committers + +The people who review and fix bugs and help triage issues. + +
+ +fnx's Avatar
+fnx +
+
+ +Sweta Tanwar's Avatar
+Sweta Tanwar +
+
+ +Tanuj Kanti's Avatar
+Tanuj Kanti +
+
+ +lumir's Avatar
+lumir +
+
+ +Pixel's Avatar
+Pixel +
+
+ +### Website Team + +Team members who focus specifically on eslint.org + +
+ +Amaresh  S M's Avatar
+Amaresh S M +
+
+ +Harish's Avatar
+Harish +
+
+ +Percy Ma's Avatar
+Percy Ma +
+
+ + + + + + +## Sponsors + +The following companies, organizations, and individuals support ESLint's ongoing maintenance and development. [Become a Sponsor](https://eslint.org/donate) +to get your logo on our READMEs and [website](https://eslint.org/sponsors). + +

Platinum Sponsors

+

Automattic

Gold Sponsors

+

Qlty Software Shopify CodeRabbit

Silver Sponsors

+

Vite Liftoff StackBlitz

Bronze Sponsors

+

Cybozu SAP apuesdeportivas.es Icons8 Discord GitBook Citadel AI

+

Technology Sponsors

+Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work. +

Netlify Algolia 1Password

+ + + +[tidelift]: https://tidelift.com/funding/github/npm/eslint +[herodevs]: https://www.herodevs.com/support/eslint-nes?utm_source=ESLintWebsite&utm_medium=ESLintWebsite&utm_campaign=ESLintNES&utm_id=ESLintNES diff --git a/.design-research/readme-examples/fiber.README.md b/.design-research/readme-examples/fiber.README.md new file mode 100644 index 0000000..ed33477 --- /dev/null +++ b/.design-research/readme-examples/fiber.README.md @@ -0,0 +1,873 @@ +

+ + + + Fiber + + +
+ + + + + + + + Codecov + + + + + + + + + + + + + + + + +

+ +

+ Fiber is an Express inspired web framework built on top of Fasthttp, the fastest HTTP engine for Go. Designed to ease things up for fast development with zero memory allocation and performance in mind. +

+ +--- + + +

+ + + + Official Sponsors + + +

+

+ + + + CodeRabbit + + +

+

+ + Blacksmith + +

+ +--- + + + +## ⚙️ Installation + +Fiber requires **Go version `1.25` or higher** to run. If you need to install or upgrade Go, visit the [official Go download page](https://go.dev/dl/). To start setting up your project, create a new directory for your project and navigate into it. Then, initialize your project with Go modules by executing the following command in your terminal: + +```bash +go mod init github.com/your/repo +``` + +To learn more about Go modules and how they work, you can check out the [Using Go Modules](https://go.dev/blog/using-go-modules) blog post. + +After setting up your project, you can install Fiber with the `go get` command: + +```bash +go get -u github.com/gofiber/fiber/v3 +``` + +This command fetches the Fiber package and adds it to your project's dependencies, allowing you to start building your web applications with Fiber. + +## ⚡️ Quickstart + +Getting started with Fiber is easy. Here's a basic example to create a simple web server that responds with "Hello, World 👋!" on the root path. This example demonstrates initializing a new Fiber app, setting up a route, and starting the server. + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + // Initialize a new Fiber app + app := fiber.New() + + // Define a route for the GET method on the root path '/' + app.Get("/", func(c fiber.Ctx) error { + // Send a string response to the client + return c.SendString("Hello, World 👋!") + }) + + // Start the server on port 3000 + log.Fatal(app.Listen(":3000")) +} +``` + +This simple server is easy to set up and run. It introduces the core concepts of Fiber: app initialization, route definition, and starting the server. Just run this Go program, and visit `http://localhost:3000` in your browser to see the message. + +## Zero Allocation + +Fiber is optimized for **high-performance**, meaning values returned from **fiber.Ctx** are **not** immutable by default and **will** be reused across requests. As a rule of thumb, you **must** only use context values within the handler and **must not** keep any references. Once you return from the handler, any values obtained from the context will be reused in future requests. Visit our [documentation](https://docs.gofiber.io/#zero-allocation) to learn more. + +## 🤖 Benchmarks + +These tests are performed by [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r19&hw=ph&test=plaintext). If you want to see all the results, please visit our [Wiki](https://docs.gofiber.io/extra/benchmarks). + +

+ + +

+ +## 🎯 Features + +- Robust [Routing](https://docs.gofiber.io/guide/routing) +- Serve [Static Files](https://docs.gofiber.io/api/app#static) +- Extreme [Performance](https://docs.gofiber.io/extra/benchmarks) +- [Low Memory](https://docs.gofiber.io/extra/benchmarks) footprint +- [API Endpoints](https://docs.gofiber.io/api/ctx) +- [Middleware](https://docs.gofiber.io/category/-middleware) & [Next](https://docs.gofiber.io/api/ctx#next) support +- [Rapid](https://dev.to/koddr/welcome-to-fiber-an-express-js-styled-fastest-web-framework-written-with-on-golang-497) server-side programming +- [Template Engines](https://github.com/gofiber/template) +- [WebSocket Support](https://github.com/gofiber/contrib/tree/main/websocket) +- [Socket.io Support](https://github.com/gofiber/contrib/tree/main/socketio) +- [Server-Sent Events](https://github.com/gofiber/recipes/tree/master/sse) +- [Rate Limiter](https://docs.gofiber.io/api/middleware/limiter) +- And much more, [explore Fiber](https://docs.gofiber.io/) + +## 💡 Philosophy + +New gophers that make the switch from [Node.js](https://nodejs.org/en/about/) to [Go](https://go.dev/doc/) are dealing with a learning curve before they can start building their web applications or microservices. Fiber, as a **web framework**, was created with the idea of **minimalism** and follows the **UNIX way**, so that new gophers can quickly enter the world of Go with a warm and trusted welcome. + +Fiber is **inspired** by Express, the most popular web framework on the Internet. We combined the **ease** of Express and **raw performance** of Go. If you have ever implemented a web application in Node.js (_using Express or similar_), then many methods and principles will seem **very common** to you. + +We **listen** to our users in [issues](https://github.com/gofiber/fiber/issues), Discord [channel](https://gofiber.io/discord) _and all over the Internet_ to create a **fast**, **flexible** and **friendly** Go web framework for **any** task, **deadline** and developer **skill**! Just like Express does in the JavaScript world. + +## ⚠️ Limitations + +- Due to Fiber's usage of unsafe, the library may not always be compatible with the latest Go version. Fiber v3 has been tested with Go version 1.25 or higher. +- Fiber automatically adapts common `net/http` handler shapes when you register them on the router, and you can still use the [adaptor middleware](https://docs.gofiber.io/next/middleware/adaptor/) when you need to bridge entire apps or `net/http` middleware. + +### net/http compatibility + +Fiber can run side by side with the standard library. The router accepts existing `net/http` handlers directly and even works with native `fasthttp.RequestHandler` callbacks, so you can plug in legacy endpoints without wrapping them manually: + +```go +package main + +import ( + "log" + "net/http" + + "github.com/gofiber/fiber/v3" +) + +func main() { + httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte("served by net/http")); err != nil { + panic(err) + } + }) + + app := fiber.New() + app.Get("/", httpHandler) + + // Start the server on port 3000 + log.Fatal(app.Listen(":3000")) +} +``` + +When you need to convert entire applications or reuse `net/http` middleware chains, rely on the [adaptor middleware](https://docs.gofiber.io/next/middleware/adaptor/). It converts handlers and middlewares in both directions and even lets you mount a Fiber app in a `net/http` server. + +### Express-style handlers + +Fiber also adapts Express-style callbacks that operate on the lightweight `fiber.Req` and `fiber.Res` helper interfaces. This lets you port middleware and route handlers from Express-inspired codebases while keeping Fiber's router features: + +```go +// Request/response handlers (2-argument) +app.Get("/", func(req fiber.Req, res fiber.Res) error { + return res.SendString("Hello from Express-style handlers!") +}) + +// Middleware with an error-returning next callback (3-argument) +app.Use(func(req fiber.Req, res fiber.Res, next func() error) error { + if req.IP() == "192.168.1.254" { + return res.SendStatus(fiber.StatusForbidden) + } + return next() +}) + +// Middleware with a no-arg next callback (3-argument) +app.Use(func(req fiber.Req, res fiber.Res, next func()) { + if req.Get("X-Skip") == "true" { + return // stop the chain without calling next + } + next() +}) +``` + +> **Note:** Adapted `net/http` handlers continue to operate with the standard-library semantics. They don't get access to `fiber.Ctx` features and incur the overhead of the compatibility layer, so native `fiber.Handler` callbacks still provide the best performance. + +## 👀 Examples + +Listed below are some of the common examples. If you want to see more code examples, please visit our [Recipes repository](https://github.com/gofiber/recipes) or visit our hosted [API documentation](https://docs.gofiber.io). + +### 📖 [**Basic Routing**](https://docs.gofiber.io/#basic-routing) + +```go title="Example" +package main + +import ( + "fmt" + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New() + + // GET /api/register + app.Get("/api/*", func(c fiber.Ctx) error { + msg := fmt.Sprintf("✋ %s", c.Params("*")) + return c.SendString(msg) // => ✋ register + }) + + // GET /flights/LAX-SFO + app.Get("/flights/:from-:to", func(c fiber.Ctx) error { + msg := fmt.Sprintf("💸 From: %s, To: %s", c.Params("from"), c.Params("to")) + return c.SendString(msg) // => 💸 From: LAX, To: SFO + }) + + // GET /dictionary.txt + app.Get("/:file.:ext", func(c fiber.Ctx) error { + msg := fmt.Sprintf("📃 %s.%s", c.Params("file"), c.Params("ext")) + return c.SendString(msg) // => 📃 dictionary.txt + }) + + // GET /john/75 + app.Get("/:name/:age/:gender?", func(c fiber.Ctx) error { + msg := fmt.Sprintf("👴 %s is %s years old", c.Params("name"), c.Params("age")) + return c.SendString(msg) // => 👴 john is 75 years old + }) + + // GET /john + app.Get("/:name", func(c fiber.Ctx) error { + msg := fmt.Sprintf("Hello, %s 👋!", c.Params("name")) + return c.SendString(msg) // => Hello john 👋! + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +#### 📖 [**Route Naming**](https://docs.gofiber.io/api/app#name) + +```go title="Example" +package main + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New() + + app.Get("/api/*", func(c fiber.Ctx) error { + msg := fmt.Sprintf("✋ %s", c.Params("*")) + return c.SendString(msg) // => ✋ register + }).Name("api") + + route := app.GetRoute("api") + + data, _ := json.MarshalIndent(route, "", " ") + fmt.Println(string(data)) + // Prints: + // { + // "method": "GET", + // "name": "api", + // "path": "/api/*", + // "params": [ + // "*1" + // ] + // } + + log.Fatal(app.Listen(":3000")) +} +``` + +#### 📖 [**Serving Static Files**](https://docs.gofiber.io/api/app#static) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/static" +) + +func main() { + app := fiber.New() + + // Serve static files from the "./public" directory + app.Get("/*", static.New("./public")) + // => http://localhost:3000/js/script.js + // => http://localhost:3000/css/style.css + + app.Get("/prefix*", static.New("./public")) + // => http://localhost:3000/prefix/js/script.js + // => http://localhost:3000/prefix/css/style.css + + // Serve a single file for any unmatched routes + app.Get("*", static.New("./public/index.html")) + // => http://localhost:3000/any/path/shows/index.html + + log.Fatal(app.Listen(":3000")) +} +``` + +#### 📖 [**Middleware & Next**](https://docs.gofiber.io/api/ctx#next) + +```go title="Example" +package main + +import ( + "fmt" + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New() + + // Middleware that matches any route + app.Use(func(c fiber.Ctx) error { + fmt.Println("🥇 First handler") + return c.Next() + }) + + // Middleware that matches all routes starting with /api + app.Use("/api", func(c fiber.Ctx) error { + fmt.Println("🥈 Second handler") + return c.Next() + }) + + // GET /api/list + app.Get("/api/list", func(c fiber.Ctx) error { + fmt.Println("🥉 Last handler") + return c.SendString("Hello, World 👋!") + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +
+ 📚 Show more code examples + +### Views Engines + +📖 [Config](https://docs.gofiber.io/api/fiber#config) +📖 [Engines](https://github.com/gofiber/template) +📖 [Render](https://docs.gofiber.io/api/ctx#render) + +Fiber defaults to the [html/template](https://pkg.go.dev/html/template/) when no view engine is set. + +If you want to execute partials or use a different engine like [amber](https://github.com/eknkc/amber), [handlebars](https://github.com/aymerick/raymond), [mustache](https://github.com/cbroglie/mustache), or [pug](https://github.com/Joker/jade), etc., check out our [Template](https://github.com/gofiber/template) package that supports multiple view engines. + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/template/pug" +) + +func main() { + // Initialize a new Fiber app with Pug template engine + app := fiber.New(fiber.Config{ + Views: pug.New("./views", ".pug"), + }) + + // Define a route that renders the "home.pug" template + app.Get("/", func(c fiber.Ctx) error { + return c.Render("home", fiber.Map{ + "title": "Homepage", + "year": 1999, + }) + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### Grouping Routes into Chains + +📖 [Group](https://docs.gofiber.io/api/app#group) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +func middleware(c fiber.Ctx) error { + log.Println("Middleware executed") + return c.Next() +} + +func handler(c fiber.Ctx) error { + return c.SendString("Handler response") +} + +func main() { + app := fiber.New() + + // Root API group with middleware + api := app.Group("/api", middleware) // /api + + // API v1 routes + v1 := api.Group("/v1", middleware) // /api/v1 + v1.Get("/list", handler) // /api/v1/list + v1.Get("/user", handler) // /api/v1/user + + // API v2 routes + v2 := api.Group("/v2", middleware) // /api/v2 + v2.Get("/list", handler) // /api/v2/list + v2.Get("/user", handler) // /api/v2/user + + log.Fatal(app.Listen(":3000")) +} +``` + +### Middleware Logger + +📖 [Logger](https://docs.gofiber.io/api/middleware/logger) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" +) + +func main() { + app := fiber.New() + + // Use Logger middleware + app.Use(logger.New()) + + // Define routes + app.Get("/", func(c fiber.Ctx) error { + return c.SendString("Hello, Logger!") + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### Cross-Origin Resource Sharing (CORS) + +📖 [CORS](https://docs.gofiber.io/api/middleware/cors) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/cors" +) + +func main() { + app := fiber.New() + + // Use CORS middleware with default settings + app.Use(cors.New()) + + // Define routes + app.Get("/", func(c fiber.Ctx) error { + return c.SendString("CORS enabled!") + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +Check CORS by passing any domain in `Origin` header: + +```bash +curl -H "Origin: http://example.com" --verbose http://localhost:3000 +``` + +### Custom 404 Response + +📖 [HTTP Methods](https://docs.gofiber.io/api/ctx#status) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New() + + // Define routes + app.Get("/", static.New("./public")) + + app.Get("/demo", func(c fiber.Ctx) error { + return c.SendString("This is a demo page!") + }) + + app.Post("/register", func(c fiber.Ctx) error { + return c.SendString("Registration successful!") + }) + + // Middleware to handle 404 Not Found + app.Use(func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusNotFound) // => 404 "Not Found" + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### JSON Response + +📖 [JSON](https://docs.gofiber.io/api/ctx#json) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +type User struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func main() { + app := fiber.New() + + // Route that returns a JSON object + app.Get("/user", func(c fiber.Ctx) error { + return c.JSON(&User{"John", 20}) + // => {"name":"John", "age":20} + }) + + // Route that returns a JSON map + app.Get("/json", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "success": true, + "message": "Hi John!", + }) + // => {"success":true, "message":"Hi John!"} + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### WebSocket Upgrade + +📖 [Websocket](https://github.com/gofiber/websocket) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/websocket" +) + +func main() { + app := fiber.New() + + // WebSocket route + app.Get("/ws", websocket.New(func(c *websocket.Conn) { + defer c.Close() + for { + // Read message from client + mt, msg, err := c.ReadMessage() + if err != nil { + log.Println("read:", err) + break + } + log.Printf("recv: %s", msg) + + // Write message back to client + err = c.WriteMessage(mt, msg) + if err != nil { + log.Println("write:", err) + break + } + } + })) + + log.Fatal(app.Listen(":3000")) + // Connect via WebSocket at ws://localhost:3000/ws +} +``` + +### Server-Sent Events + +📖 [More Info](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) + +```go title="Example" +package main + +import ( + "bufio" + "fmt" + "log" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" +) + +func main() { + app := fiber.New() + + // Server-Sent Events route + app.Get("/sse", func(c fiber.Ctx) error { + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + c.Set("Transfer-Encoding", "chunked") + + c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { + var i int + for { + i++ + msg := fmt.Sprintf("%d - the time is %v", i, time.Now()) + fmt.Fprintf(w, "data: Message: %s\n\n", msg) + fmt.Println(msg) + + w.Flush() + time.Sleep(5 * time.Second) + } + }) + + return nil + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### Recover Middleware + +📖 [Recover](https://docs.gofiber.io/api/middleware/recover) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/recover" +) + +func main() { + app := fiber.New() + + // Use Recover middleware to handle panics gracefully + app.Use(recover.New()) + + // Route that intentionally panics + app.Get("/", func(c fiber.Ctx) error { + panic("normally this would crash your app") + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +### Using Trusted Proxy + +📖 [Config](https://docs.gofiber.io/api/fiber#config) + +```go title="Example" +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New(fiber.Config{ + // Configure trusted proxies - WARNING: Only trust proxies you control + // Using TrustProxy: true with unrestricted IPs can lead to IP spoofing + TrustProxy: true, + TrustProxyConfig: fiber.TrustProxyConfig{ + Proxies: []string{"10.0.0.0/8", "172.16.0.0/12"}, // Example: Internal network ranges only + }, + ProxyHeader: fiber.HeaderXForwardedFor, + }) + + // Define routes + app.Get("/", func(c fiber.Ctx) error { + return c.SendString("Trusted Proxy Configured!") + }) + + log.Fatal(app.Listen(":3000")) +} +``` + +
+ +## 🧬 Internal Middleware + +Here is a list of middleware that are included within the Fiber framework. + +| Middleware | Description | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| [adaptor](https://github.com/gofiber/fiber/tree/main/middleware/adaptor) | Converter for net/http handlers to/from Fiber request handlers. | +| [basicauth](https://github.com/gofiber/fiber/tree/main/middleware/basicauth) | Provides HTTP basic authentication. It calls the next handler for valid credentials and 401 Unauthorized for missing or invalid credentials. | +| [cache](https://github.com/gofiber/fiber/tree/main/middleware/cache) | Intercept and cache HTTP responses. | +| [compress](https://github.com/gofiber/fiber/tree/main/middleware/compress) | Compression middleware for Fiber, with support for `deflate`, `gzip`, `brotli` and `zstd`. | +| [cors](https://github.com/gofiber/fiber/tree/main/middleware/cors) | Enable cross-origin resource sharing (CORS) with various options. | +| [csrf](https://github.com/gofiber/fiber/tree/main/middleware/csrf) | Protect from CSRF exploits. | +| [earlydata](https://github.com/gofiber/fiber/tree/main/middleware/earlydata) | Adds support for TLS 1.3's early data ("0-RTT") feature. | +| [encryptcookie](https://github.com/gofiber/fiber/tree/main/middleware/encryptcookie) | Encrypt middleware which encrypts cookie values. | +| [envvar](https://github.com/gofiber/fiber/tree/main/middleware/envvar) | Expose environment variables with providing an optional config. | +| [etag](https://github.com/gofiber/fiber/tree/main/middleware/etag) | Allows for caches to be more efficient and save bandwidth, as a web server does not need to resend a full response if the content has not changed. | +| [expvar](https://github.com/gofiber/fiber/tree/main/middleware/expvar) | Serves via its HTTP server runtime exposed variables in the JSON format. | +| [favicon](https://github.com/gofiber/fiber/tree/main/middleware/favicon) | Ignore favicon from logs or serve from memory if a file path is provided. | +| [healthcheck](https://github.com/gofiber/fiber/tree/main/middleware/healthcheck) | Liveness and Readiness probes for Fiber. | +| [helmet](https://github.com/gofiber/fiber/tree/main/middleware/helmet) | Helps secure your apps by setting various HTTP headers. | +| [hostauthorization](https://github.com/gofiber/fiber/tree/main/middleware/hostauthorization) | Validates the Host header against a configurable allowlist, protecting against DNS rebinding attacks. | +| [idempotency](https://github.com/gofiber/fiber/tree/main/middleware/idempotency) | Allows for fault-tolerant APIs where duplicate requests do not erroneously cause the same action performed multiple times on the server-side. | +| [keyauth](https://github.com/gofiber/fiber/tree/main/middleware/keyauth) | Adds support for key based authentication. | +| [limiter](https://github.com/gofiber/fiber/tree/main/middleware/limiter) | Adds Rate-limiting support to Fiber. Use to limit repeated requests to public APIs and/or endpoints such as password reset. | +| [logger](https://github.com/gofiber/fiber/tree/main/middleware/logger) | HTTP request/response logger. | +| [paginate](https://github.com/gofiber/fiber/tree/main/middleware/paginate) | Extracts pagination parameters from query strings. Supports page-based, offset-based, and cursor-based pagination with multi-field sorting. | +| [pprof](https://github.com/gofiber/fiber/tree/main/middleware/pprof) | Serves runtime profiling data in pprof format. | +| [proxy](https://github.com/gofiber/fiber/tree/main/middleware/proxy) | Allows you to proxy requests to multiple servers. | +| [recover](https://github.com/gofiber/fiber/tree/main/middleware/recover) | Recovers from panics anywhere in the stack chain and handles the control to the centralized ErrorHandler. | +| [redirect](https://github.com/gofiber/fiber/tree/main/middleware/redirect) | Redirect middleware. | +| [requestid](https://github.com/gofiber/fiber/tree/main/middleware/requestid) | Adds a request ID to every request. | +| [responsetime](https://github.com/gofiber/fiber/tree/main/middleware/responsetime) | Measures request handling duration and writes it to a configurable response header. | +| [rewrite](https://github.com/gofiber/fiber/tree/main/middleware/rewrite) | Rewrites the URL path based on provided rules. It can be helpful for backward compatibility or just creating cleaner and more descriptive links. | +| [session](https://github.com/gofiber/fiber/tree/main/middleware/session) | Session middleware. NOTE: This middleware uses our Storage package. | +| [skip](https://github.com/gofiber/fiber/tree/main/middleware/skip) | Skip middleware that skips a wrapped handler if a predicate is true. | +| [static](https://github.com/gofiber/fiber/tree/main/middleware/static) | Static middleware for Fiber that serves static files such as **images**, **CSS**, and **JavaScript**. | +| [timeout](https://github.com/gofiber/fiber/tree/main/middleware/timeout) | Adds a max time for a request and forwards to ErrorHandler if it is exceeded. | + +## 🧬 External Middleware + +List of externally hosted middleware modules and maintained by the [Fiber team](https://github.com/orgs/gofiber/people). + +| Middleware | Description | +| :---------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | +| [contrib](https://github.com/gofiber/contrib) | Third-party middlewares | +| [storage](https://github.com/gofiber/storage) | Premade storage drivers that implement the Storage interface, designed to be used with various Fiber middlewares. | +| [template](https://github.com/gofiber/template) | This package contains 9 template engines that can be used with Fiber. | + +## 🕶️ Awesome List + +For more articles, middlewares, examples, or tools, check our [awesome list](https://github.com/gofiber/awesome-fiber). + +## 👍 Contribute + +If you want to say **Thank You** and/or support the active development of `Fiber`: + +1. Add a [GitHub Star](https://github.com/gofiber/fiber/stargazers) to the project. +2. Tweet about the project [on your 𝕏 (Twitter)](https://x.com/intent/tweet?text=Fiber%20is%20an%20Express%20inspired%20%23web%20%23framework%20built%20on%20top%20of%20Fasthttp%2C%20the%20fastest%20HTTP%20engine%20for%20%23Go.%20Designed%20to%20ease%20things%20up%20for%20%23fast%20development%20with%20zero%20memory%20allocation%20and%20%23performance%20in%20mind%20%F0%9F%9A%80%20https%3A%2F%2Fgithub.com%2Fgofiber%2Ffiber). +3. Write a review or tutorial on [Medium](https://medium.com/), [Dev.to](https://dev.to/) or your personal blog. +4. Support the project by becoming a [GitHub Sponsor](https://github.com/sponsors/gofiber). + +## 💻 Development + +To ensure your contributions are ready for a Pull Request, please use the following `Makefile` commands. These tools help maintain code quality and consistency. + +- **make help**: Display available commands. +- **make audit**: Conduct quality checks. +- **make benchmark**: Benchmark code performance. +- **make coverage**: Generate test coverage report. +- **make format**: Automatically format code. +- **make lint**: Run lint checks. +- **make test**: Execute all tests. +- **make tidy**: Tidy dependencies. + +Run these commands to ensure your code adheres to project standards and best practices. + + + +## ☕ Supporters + +Fiber is an open-source project that runs on donations to pay the bills, e.g., our domain name, GitBook, Netlify, and serverless hosting. If you want to support Fiber, please sponsor the organization via ⭐ [**GitHub Sponsors**](https://github.com/sponsors/gofiber). + + + +### 📅 Monthly Sponsors + + + + + +
🔥 Fiber Guardian@coderabbitai
☕ Fiber Supporter@NdoleStudio @petercool
🪴 Fiber Friend@simonheisstpeter
+ +### 🎁 One-time Sponsors + + + + +
🚀 Fiber Hero@thnxdev
🪴 Fiber Friend@Gl1tchedPixzl
+ + + +## 💻 Code Contributors + + + Code Contributors + + +## ⭐️ Stargazers + + + + + Stargazers over time + + +## 🧾 License + +Copyright (c) 2019-present [Fenny](https://github.com/fenny) and [Contributors](https://github.com/gofiber/fiber/graphs/contributors). `Fiber` is free and open-source software licensed under the [MIT License](https://github.com/gofiber/fiber/blob/main/LICENSE). Official logo was created by [Vic Shóstak](https://github.com/koddr) and distributed under [Creative Commons](https://creativecommons.org/licenses/by-sa/4.0/) license (CC BY-SA 4.0 International). diff --git a/.design-research/readme-examples/github-changelog-generator.README.md b/.design-research/readme-examples/github-changelog-generator.README.md new file mode 100644 index 0000000..ea3a6ae --- /dev/null +++ b/.design-research/readme-examples/github-changelog-generator.README.md @@ -0,0 +1,367 @@ +[![Gem Version](https://badge.fury.io/rb/github_changelog_generator.svg)](http://badge.fury.io/rb/github_changelog_generator) +[![Ruby CI](https://github.com/github-changelog-generator/github-changelog-generator/actions/workflows/test.yml/badge.svg)](https://github.com/github-changelog-generator/github-changelog-generator/actions/workflows/test.yml) +[![Join the chat at https://gitter.im/github-changelog-generator/chat](https://badges.gitter.im/github-changelog-generator/chat.svg)](https://gitter.im/github-changelog-generator/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +# github-changelog-generator ![GitHub Logo](../master/images/logo.jpg) + + + + + +- [Changelog generation has never been so easy](#changelog-generation-has-never-been-so-easy) +- [_What’s the point of a changelog?_](#whats-the-point-of-a-changelog) +- [_Why should I care?_](#why-should-i-care) +- [Installation](#installation) +- [Usage](#usage) + - [Running with CLI:](#running-with-cli) + - [Running with Docker](#running-with-docker) +- [Output example](#output-example) + - [Params](#params) + - [Params File](#params-file) + - [GitHub token](#github-token) +- [Migrating from a manual changelog](#migrating-from-a-manual-changelog) + - [Rake task](#rake-task) +- [Features and advantages of this project](#features-and-advantages-of-this-project) + - [Using the summary section feature](#using-the-summary-section-feature) + - [Alternatives](#alternatives) + - [Projects using this library](#projects-using-this-library) +- [Practical Use Cases](#practical-use-cases) + - [Creating GitHub Release Notes](#creating-github-release-notes) +- [Am I missing some essential feature?](#am-i-missing-some-essential-feature) +- [FAQ](#faq) +- [Contributing](#contributing) +- [Contact us](#contact-us) +- [License](#license) + + + +### Changelog generation has never been so easy + +**Fully automated changelog generation** - This gem generates a changelog file based on **tags**, **issues** and merged **pull requests** (and splits them into separate lists according to labels) from :octocat: GitHub. + +Since you don't have to fill your `CHANGELOG.md` manually now: just run the script, relax and take a cup of :coffee: before your next release! :tada: + +### _What’s the point of a changelog?_ + +To make it easier for users and contributors to see precisely what notable changes have been made between each release (or version) of the project. + +### _Why should I care?_ + +Because software tools are for _people_. "Changelogs make it easier for users and +contributors to see precisely what notable changes have been made between each +release (or version) of the project." + +:arrow_right: _[https://keepachangelog.com](https://keepachangelog.com)_ + +## Installation + +GitHub Changelog Generator is a [Ruby](https://www.ruby-lang.org/) +program, distributed as a RubyGem. The Ruby language homepage has an [Installation page](https://www.ruby-lang.org/en/documentation/installation/). + +Install the gem like: + + $ gem install github_changelog_generator + +Depending on your system, you _may_ need to run the shell as an Administrator (Windows), +or use `sudo gem install github_changelog_generator` (Linux). + +## Usage + +### Running with CLI: + + github_changelog_generator -u github_project_namespace -p github_project + +(where the project namespace is _likely_ your username if it's a project you own, but it could also be the namespace of the project) + +### Running with Docker + +Using [Docker](https://www.docker.com/products/docker-desktop) is an alternative to installing Ruby and the gem. + +Example invocation: + + $ docker run -it --rm -v "$(pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator + +- For GitHub Enterprise repos, specify _both_ `--github-site` and `--github-api` options: + + $ github_changelog_generator --github-site="https://github.yoursite.com" \ + --github-api="https://github.yoursite.com/api/v3/" + +This generates a `CHANGELOG.md`, with pretty Markdown formatting. + +## Output example + +- Look at **[CHANGELOG.md](https://github.com/github-changelog-generator/github-changelog-generator/blob/master/CHANGELOG.md)** for this project +- [ActionSheetPicker-3.0/CHANGELOG.md](https://github.com/skywinder/ActionSheetPicker-3.0/blob/develop/CHANGELOG.md) was generated by command: + + $ github_changelog_generator -u github-changelog-generator -p ActionSheetPicker-3.0 + +- In general, it looks like this: + +> ## [1.2.5](https://github.com/github-changelog-generator/github-changelog-generator/tree/1.2.5) (2015-01-15) +> +> [Full Changelog](https://github.com/github-changelog-generator/github-changelog-generator/compare/1.2.4...1.2.5) +> +> **Implemented enhancements:** +> +> - Use milestone to specify in which version bug was fixed [\#22](https://github.com/github-changelog-generator/github-changelog-generator/issues/22) +> +> **Fixed bugs:** +> +> - Error when trying to generate log for repo without tags [\#32](https://github.com/github-changelog-generator/github-changelog-generator/issues/32) +> +> **Merged pull requests:** +> +> - PrettyPrint class is included using lowercase 'pp' [\#43](https://github.com/github-changelog-generator/github-changelog-generator/pull/43) ([schwing](https://github.com/schwing)) +> +> - support enterprise github via command line options [\#42](https://github.com/github-changelog-generator/github-changelog-generator/pull/42) ([glenlovett](https://github.com/glenlovett)) + +### Params + +Print help for all command-line options to learn more details: + + $ github_changelog_generator --help + +For more details about params, read the Wiki page: [**Advanced changelog generation examples**](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Advanced-change-log-generation-examples) + +### Params File + +In your project root, you can put a params file named `.github_changelog_generator` to override default params: + +Example: + +``` +unreleased=false +future-release=5.0.0 +since-tag=1.0.0 +``` + +### GitHub token + +GitHub only allows **50 unauthenticated requests per hour**. + +Therefore, it's recommended to run this script with authentication by using a **token**. + +Here's how: + +- [Generate a token here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token) - you only need "repo" scope for private repositories +- Either: + - Run the script with `--token `; **OR** + - Set the `CHANGELOG_GITHUB_TOKEN` environment variable to your 40 digit token + +You can set an environment variable by running the following command at the prompt, or by adding it to your shell profile (e.g., `.env`, `~/.bash_profile`, `~/.zshrc`, etc): + + export CHANGELOG_GITHUB_TOKEN="«your-40-digit-github-token»" + +So, if you get a message like this: + +```markdown +API rate limit exceeded for github_username. +See: https://developer.github.com/v3/#rate-limiting +``` + +It's time to create this token! (Or, wait an hour for GitHub to reset your unauthenticated request limit.) + +## Migrating from a manual changelog + +Knowing how dedicated you are to your project, you probably haven't been waiting for `github-changelog-generator` to keep a changelog. +But you probably don't want your project's open issues and PRs for all past features listed in your historic changelog, either. + +That's where `--base ` comes in handy! +This option lets append your old manual changelog to the end of the generated entries. + +If you have a `HISTORY.md` file in your project, it will automatically be picked as the static historical changelog and appended. + +### Rake task + +You love `rake`? We do, too! So, we've made it even easier for you: +we've provided a `rake` task library for your changelog generation. + +Configure the task in your `Rakefile`: + +```ruby +require 'github_changelog_generator/task' + +GitHubChangelogGenerator::RakeTask.new :changelog do |config| + config.user = 'username' + config.project = 'project-name' + config.since_tag = '0.1.14' + config.future_release = '0.2.0' +end +``` + +All command-line options can be passed to the `rake` task as `config` +parameters. And since you're naming the `rake` task yourself, you can create +as many as you want. + +You can look for params names from the [parser source code (#setup_parser)](https://github.com/github-changelog-generator/github-changelog-generator/blob/master/lib/github_changelog_generator/parser.rb). For example, to translate the bugs label to Portuguese, instead of setting `config.bugs_label`, you have to set `config.bug_prefix`, and so on. + +## Features and advantages of this project + +- Generate canonical, neat changelog file, with default sections that follow [basic changelog guidelines](http://keepachangelog.com) :gem: +- Optionally generate **Unreleased** changes (closed issues that have not released yet) :dizzy: +- **GitHub Enterprise support** via command line options! :factory: +- Flexible format **customization**: + - **Customize** issues that **should be added** to changelog :eight_spoked_asterisk: + - **Custom date formats** supported (but keep [ISO 8601](http://xkcd.com/1179/) in mind!) :date: + - Manually specify the version that fixed an issue (for cases when the issue's Closed date doesn't match) by giving the issue's `milestone` the same name as the tag of version :pushpin: + - Automatically **exclude specific issues** that are irrelevant to your changelog (by default, any issue labeled `question`, `duplicate`, `invalid`, or `wontfix`) :scissors: +- **Distinguish** issues **by labels**. :mag_right: + - Merged pull requests (all merged pull-requests) :twisted_rightwards_arrows: + - Bug fixes (issues labeled `bug`) :beetle: + - Enhancements (issues labeled `enhancement`) :star2: + - Issues (closed issues with no labels) :non-potable_water: + - Plus any additional custom label-to-section mappings you want :gear: + +- Manually include or exclude issues by labels :wrench: +- Customize lots more! Tweak the changelog to fit your preferences :tophat: + (_See `github_changelog_generator --help` for details)_ + +### Using the summary section feature + +For each version, you can add a _release summary_ with text, images, gif animations, +etc, and show new features and notes clearly to the user. This is done using GitHub metadata. + +**Example**: adding the release summary for v1.0.0: + +1. Create a new GitHub Issue +2. In the Issue's _Description_ field, add your release summary content + +``` +![image](https://user-images.githubusercontent.com/12690315/45935880-006a8200-bfeb-11e8-958e-ff742ae66b96.png) + +Hello, World! :tada: +``` + +3. Set the Issue Label `release-summary` and add it to the GitHub Milestone `v1.0.0` +4. Close the Issue and execute `github-changelog-generator` +5. The result looks like this: + +> ## [v1.0.0](https://github.com/github-changelog-generator/github-changelog-generator/tree/1.0.0) (2014-11-07) +> +> [Full Changelog](https://github.com/github-changelog-generator/github-changelog-generator/compare/0.1.0...1.0.0) +> +> ![image](https://user-images.githubusercontent.com/12690315/45935880-006a8200-bfeb-11e8-958e-ff742ae66b96.png) +> +> Hello, World! :tada: +> +> **Implemented enhancements:** +> +> - Add some features + +### Alternatives + +Here is a [wikipage list of alternatives](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Alternatives) that I found. But none satisfied my requirements. + +_If you know other projects, feel free to edit this Wiki page!_ + +### Projects using this library + +Here's a [wikipage list of projects](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Projects-using-GitHub-Changelog-Generator). + +If you've used this project in a live app, please let me know! Nothing makes me happier than seeing someone else take my work and go wild with it. + +_If you are using `github_changelog_generator` to generate your project's changelog, or know of other projects using it, please [add it to this list](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Projects-using-GitHub-Changelog-Generator)._ + +## Practical Use Cases + +### Creating GitHub Release Notes + +`github_changelog_generator` can be used in combination with the [github cli](https://cli.github.com/) to create release notes. Use the `--since-tag` and `--output` options of `github_changelog_generator` to create a changelog for the current release and store the results in a file. In the +example below, version `2.0.0` is the current release and version `1.0.0` is the previous release. + +``` +mkdir -p build +github_changelog_generator \ + --since-tag 1.0.0 \ + --output build/changelog.md +``` + +Then use the [release create](https://cli.github.com/manual/gh_release_create) feature of the github cli to create a new github release + +``` +gh release create 2.0.0 \ + --notes-file build/changelog.md \ + --title 2.0.0 +``` + +## Am I missing some essential feature? + +- **Nothing is impossible!** + +- Open an [issue](https://github.com/github-changelog-generator/github-changelog-generator/issues/new/choose) and let's make the generator better together! + +- _Bug reports, feature requests, patches, and well-wishes are always welcome._ :heavy_exclamation_mark: + +## FAQ + +- _**I already use GitHub Releases. Why do I need this?**_ + +GitHub Releases is a very good thing. And it's very good practice to maintain it. (Not a lot of people are using it yet!) :congratulations: + +_BTW: I would like to support GitHub Releases in [next releases](https://github.com/github-changelog-generator/github-changelog-generator/issues/56) ;)_ + +I'm not trying to compare the quality of handwritten and auto-generated logs. That said.... + +An auto-generated changelog really helps, even if you manually fill in the release notes! + +- **I want to define my own label-to-section mapping, how can I do it?** + +This is possible using either the `add-sections` or `configure-sections` +entry in `.github_changelog_generator`. For example, to add a single new +entry called "Maintenance" that will catch PRs tagged with your `maintenance` +label, you can add to `.github_changelog_generator` the line: + +``` +add-sections= {"maintenance":{"prefix":"**Project maintenance**","labels":["maintenance"]}} +``` + +A similar approach can be used via `configure-sections` to set all section +properties (including adding new ones!). + +- _**My Ruby version is very old, can I use this?**_ + +When your Ruby is old, and you don't want to upgrade, and you want to +control which libraries you use, you can use Bundler. + +In a Gemfile, perhaps in a non-deployed `:development` group, add this +gem: + +```ruby +group :development do + gem 'github_changelog_generator', require: false +end +``` + +Then you can keep back dependencies like rack, which currently is only +compatible with Ruby >= 2.2.2. So, use an older version for your app by +adding a line like this to the Gemfile: + +``` +gem 'rack', '~> 1.6' +``` + +This way, you can keep on using github_changelog_generator, even if you +can't get the latest version of Ruby installed. + +- _**Windows: 1.14.x wants to create a file on an invalid path. Why?**_ + +Windows: [v1.14.0 introduced a bug where it attempts to create /tmp/github_changelog-logger.log... which isn't a valid path on Windows and thus fails](https://github.com/github-changelog-generator/github-changelog-generator/issues/458) + +Workaround: Create a `C:\tmp`. + +## Contributing + +Would you like to contribute to this project? [CONTRIBUTING.md] has all the details on how to do that. + +[CONTRIBUTING.md]: CONTRIBUTING.md + +## Contact us + +[Join the chat at gitter : github-changelog-generator](https://gitter.im/github-changelog-generator/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +## License + +GitHub Changelog Generator is released under the [MIT License](http://www.opensource.org/licenses/MIT). diff --git a/.design-research/readme-examples/httpie-cli.README.md b/.design-research/readme-examples/httpie-cli.README.md new file mode 100644 index 0000000..bb9de55 --- /dev/null +++ b/.design-research/readme-examples/httpie-cli.README.md @@ -0,0 +1,107 @@ +

+ + HTTPie + +
+ HTTPie CLI: human-friendly HTTP client for the API era +

+ +
+ +[![HTTPie for Desktop](https://img.shields.io/static/v1?label=HTTPie&message=Desktop&color=4B78E6)](https://httpie.io/product) +[![](https://img.shields.io/static/v1?label=HTTPie&message=Web%20%26%20Mobile&color=73DC8C)](https://httpie.io/app) +[![](https://img.shields.io/static/v1?label=HTTPie&message=CLI&color=FA9BFA)](https://httpie.io/cli) +[![Twitter](https://img.shields.io/twitter/follow/httpie?style=flat&color=%234B78E6&logoColor=%234B78E6)](https://twitter.com/httpie) +[![Chat](https://img.shields.io/discord/725351238698270761?style=flat&label=Chat%20on%20Discord&color=%23FA9BFA)](https://httpie.io/discord) + +
+ +
+ +[![Docs](https://img.shields.io/badge/stable%20docs-httpie.io%2Fdocs%2Fcli-brightgreen?style=flat&color=%2373DC8C&label=Docs)](https://httpie.org/docs/cli) +[![Latest version](https://img.shields.io/pypi/v/httpie.svg?style=flat&label=Latest&color=%234B78E6&logo=&logoColor=white)](https://pypi.python.org/pypi/httpie) +[![Build](https://img.shields.io/github/actions/workflow/status/httpie/cli/tests.yml?branch=master&color=%23FA9BFA&label=Build)](https://github.com/httpie/cli/actions) +[![Coverage](https://img.shields.io/codecov/c/github/httpie/cli?style=flat&label=Coverage&color=%2373DC8C)](https://codecov.io/gh/httpie/cli) +[![PyPi downloads](https://img.shields.io/pepy/dt/httpie?style=flat&label=Downloads%20from%20PyPi%20only&color=4B78E6)](https://www.pepy.tech/projects/httpie) + +
+ +HTTPie (pronounced _aitch-tee-tee-pie_) is a command-line HTTP client. +Its goal is to make CLI interaction with web services as human-friendly as possible. +HTTPie is designed for testing, debugging, and generally interacting with APIs & HTTP servers. +The `http` & `https` commands allow for creating and sending arbitrary HTTP requests. +They use simple and natural syntax and provide formatted and colorized output. + +
+ +HTTPie in action + +
+ +## We lost 54k GitHub stars + +Please note we recently accidentally made this repo private for a moment, and GitHub deleted our community that took a decade to build. Read the full story here: https://httpie.io/blog/stardust + +![](docs/stardust.png) + +## Getting started + +- [Installation instructions →](https://httpie.io/docs#installation) +- [Full documentation →](https://httpie.io/docs) + +## Features + +- Expressive and intuitive syntax +- Formatted and colorized terminal output +- Built-in JSON support +- Forms and file uploads +- HTTPS, proxies, and authentication +- Arbitrary request data +- Custom headers +- Persistent sessions +- `wget`-like downloads + +[See all features →](https://httpie.io/docs) + +## Examples + +Hello World: + +```bash +https httpie.io/hello +``` + +Custom [HTTP method](https://httpie.io/docs#http-method), [HTTP headers](https://httpie.io/docs#http-headers) and [JSON](https://httpie.io/docs#json) data: + +```bash +http PUT pie.dev/put X-API-Token:123 name=John +``` + +Build and print a request without sending it using [offline mode](https://httpie.io/docs/cli/offline-mode): + +```bash +http --offline pie.dev/post hello=offline +``` + +Use [GitHub API](https://developer.github.com/v3/issues/comments/#create-a-comment) to post a comment on an [Issue](https://github.com/httpie/cli/issues/83) with [authentication](https://httpie.io/docs#authentication): + +```bash +http -a USERNAME POST https://api.github.com/repos/httpie/cli/issues/83/comments body='HTTPie is awesome! :heart:' +``` + +[See more examples →](https://httpie.io/docs#examples) + +## Community & support + +- Visit the [HTTPie website](https://httpie.io) for full documentation and useful links. +- Join our [Discord server](https://httpie.io/discord) is to ask questions, discuss features, and for general API chat. +- Tweet at [@httpie](https://twitter.com/httpie) on Twitter. +- Use [StackOverflow](https://stackoverflow.com/questions/tagged/httpie) to ask questions and include a `httpie` tag. +- Create [GitHub Issues](https://github.com/httpie/cli/issues) for bug reports and feature requests. +- Subscribe to the [HTTPie newsletter](https://httpie.io) for occasional updates. + +## Contributing + +Have a look through existing [Issues](https://github.com/httpie/cli/issues) and [Pull Requests](https://github.com/httpie/cli/pulls) that you could help with. If you'd like to request a feature or report a bug, please [create a GitHub Issue](https://github.com/httpie/cli/issues) using one of the templates provided. + +[See contribution guide →](https://github.com/httpie/cli/blob/master/CONTRIBUTING.md) diff --git a/.design-research/readme-examples/playwright.README.md b/.design-research/readme-examples/playwright.README.md new file mode 100644 index 0000000..a9791d3 --- /dev/null +++ b/.design-research/readme-examples/playwright.README.md @@ -0,0 +1,318 @@ +# 🎭 Playwright + +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-152.0.7977.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) + +## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) + +Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents. + +## Get Started + +Choose the path that fits your workflow: + +| | Best for | Install | +| --------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` | +| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` | +| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` | +| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` | +| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | + +--- + +## Playwright Test + +Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions. + +### Install + +```bash +npm init playwright@latest +``` + +Or add manually: + +```bash +npm i -D @playwright/test +npx playwright install +``` + +### Write a test + +```TypeScript +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + await page.getByRole('link', { name: 'Get started' }).click(); + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); +``` + +### Run tests + +```bash +npx playwright test +``` + +Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead. + +### Key capabilities + +**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met. + +**Locators.** Find elements with resilient locators that mirror how users see the page: + +```TypeScript +page.getByRole('button', { name: 'Submit' }) +page.getByLabel('Email') +page.getByPlaceholder('Search...') +page.getByTestId('login-form') +``` + +**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests: + +```TypeScript +// Save state after login +await page.context().storageState({ path: 'auth.json' }); + +// Reuse in other tests +test.use({ storageState: 'auth.json' }); +``` + +**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer): + +```TypeScript +// playwright.config.ts +export default defineConfig({ + use: { + trace: 'on-first-retry', + }, +}); +``` + +```bash +npx playwright show-trace trace.zip +``` + + + +**Parallelism.** Tests run in parallel by default across all configured browsers. + +[Full testing documentation](https://playwright.dev/docs/intro) + +--- + +## Playwright CLI + +[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context. + +### Install + +```bash +npm install -g @playwright/cli@latest +``` + +Optionally install skills for richer agent integration: + +```bash +playwright-cli install --skills +``` + +### Usage + +Point your coding agent at a task: + +``` +Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli. +Take screenshots for all successful and failing scenarios. +``` + +Or run commands directly: + +```bash +playwright-cli open https://demo.playwright.dev/todomvc/ --headed +playwright-cli type "Buy groceries" +playwright-cli press Enter +playwright-cli screenshot +``` + +### Session monitoring + +Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control. + +```bash +playwright-cli show +``` + + + +[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli) + +--- + +## Playwright MCP + +The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required. + +### Setup + +Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.): + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +**One-click install for VS Code:** + +[Install in VS Code](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) + +**For Claude Code:** + +```bash +claude mcp add playwright npx @playwright/mcp@latest +``` + +### How it works + +Ask your AI assistant to interact with any web page: + +``` +Navigate to https://demo.playwright.dev/todomvc and add a few todo items. +``` + +The agent sees the page as a structured accessibility tree: + +``` +- heading "todos" [level=1] +- textbox "What needs to be done?" [ref=e5] +- listitem: + - checkbox "Toggle Todo" [ref=e10] + - text: "Buy groceries" +``` + +It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more. + +[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp) + +--- + +## Playwright Library + +Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner. + +### Install + +```bash +npm i playwright +``` + +### Examples + +**Take a screenshot:** + +```TypeScript +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('https://playwright.dev/'); +await page.screenshot({ path: 'screenshot.png' }); +await browser.close(); +``` + +**Generate a PDF:** + +```TypeScript +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('https://playwright.dev/'); +await page.pdf({ path: 'page.pdf', format: 'A4' }); +await browser.close(); +``` + +**Emulate a mobile device:** + +```TypeScript +import { chromium, devices } from 'playwright'; + +const browser = await chromium.launch(); +const context = await browser.newContext(devices['iPhone 15']); +const page = await context.newPage(); +await page.goto('https://playwright.dev/'); +await page.screenshot({ path: 'mobile.png' }); +await browser.close(); +``` + +**Intercept network requests:** + +```TypeScript +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.route('**/*.{png,jpg,jpeg}', route => route.abort()); +await page.goto('https://playwright.dev/'); +await browser.close(); +``` + +[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright) + +--- + +## VS Code Extension + +The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor. + + + +**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view. + +**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you. + +**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard. + +**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step. + +[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode) + +--- + +## Cross-Browser Support + +| | Linux | macOS | Windows | +| :------------------------------------------------------------------------------ | :----------------: | :----------------: | :----------------: | +| Chromium1 152.0.7977.8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| WebKit 26.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Firefox 153.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | + +Headless and headed execution on all platforms. 1 Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default. + +## Other Languages + +Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro). + +## Resources + +- [Documentation](https://playwright.dev) +- [API reference](https://playwright.dev/docs/api/class-playwright) +- [MCP server](https://github.com/microsoft/playwright-mcp) +- [CLI for coding agents](https://github.com/microsoft/playwright-cli) +- [VS Code extension](https://github.com/microsoft/playwright-vscode) +- [Contribution guide](CONTRIBUTING.md) +- [Changelog](https://github.com/microsoft/playwright/releases) +- [Discord](https://aka.ms/playwright/discord) diff --git a/.design-research/readme-examples/typescript.README.md b/.design-research/readme-examples/typescript.README.md new file mode 100644 index 0000000..e682d29 --- /dev/null +++ b/.design-research/readme-examples/typescript.README.md @@ -0,0 +1,61 @@ +# TypeScript + + + +[![CI](https://github.com/microsoft/TypeScript/actions/workflows/ci.yml/badge.svg)](https://github.com/microsoft/TypeScript/actions/workflows/ci.yml) +[![npm version](https://badge.fury.io/js/typescript.svg)](https://www.npmjs.com/package/typescript) +[![Downloads](https://img.shields.io/npm/dm/typescript.svg)](https://www.npmjs.com/package/typescript) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/microsoft/TypeScript/badge)](https://securityscorecards.dev/viewer/?uri=github.com/microsoft/TypeScript) + +[TypeScript](https://www.typescriptlang.org/) is a language for application-scale JavaScript. TypeScript adds optional types to JavaScript that support tools for large-scale JavaScript applications for any browser, for any host, on any OS. TypeScript compiles to readable, standards-based JavaScript. Try it out at the [playground](https://www.typescriptlang.org/play/), and stay up to date via [our blog](https://blogs.msdn.microsoft.com/typescript) and [Twitter account](https://twitter.com/typescript). + +Find others who are using TypeScript at [our community page](https://www.typescriptlang.org/community/). + +## Installing + +For the latest stable version: + +```bash +npm install -D typescript +``` + +For our nightly builds: + +```bash +npm install -D typescript@next +``` + +## Contribute + +**NOTE: Code changes in this repo are now limited to a small category of fixes**: + +- Crashes that were introduced in 5.9 or 6.0 that _also_ repro in 7.0 _and_ have a portable fix _and_ don't incur other behavioral changes +- Security issues +- Language service crashes that substantially impact mainline usage +- Serious regressions from 5.9 (these must _seriously_ impact a _large_ proportion of users) + +Most bug fixes should be submitted to the [typescript-go](https://github.com/microsoft/TypeScript-go) repository. +Feature additions and behavioral changes are currently on pause until TypeScript 7.0 is completed. + +There are many ways to [contribute](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md) to TypeScript. + +- [Submit bugs](https://github.com/microsoft/TypeScript/issues) and help us verify fixes as they are checked in. +- Review the [source code changes](https://github.com/microsoft/TypeScript/pulls). +- Engage with other TypeScript users and developers on [StackOverflow](https://stackoverflow.com/questions/tagged/typescript). +- Help each other in the [TypeScript Community Discord](https://discord.gg/typescript). +- Join the [#typescript](https://twitter.com/search?q=%23TypeScript) discussion on Twitter. +- [Contribute bug fixes](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md). + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see +the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) +with any additional questions or comments. + +## Documentation + +- [TypeScript in 5 minutes](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) +- [Programming handbook](https://www.typescriptlang.org/docs/handbook/intro.html) +- [Homepage](https://www.typescriptlang.org/) + +## Roadmap + +For details on our planned features and future direction, please refer to our [roadmap](https://github.com/microsoft/TypeScript/wiki/Roadmap). diff --git a/.design-research/readme-examples/vite.README.md b/.design-research/readme-examples/vite.README.md new file mode 100644 index 0000000..069c468 --- /dev/null +++ b/.design-research/readme-examples/vite.README.md @@ -0,0 +1,66 @@ +

+
+
+ + + + + vite logo + + +
+
+

+
+

+ npm package + node compatibility + build status + discord chat +

+
+ +# Vite ⚡ + +> Next Generation Frontend Tooling + +- 💡 Instant Server Start +- ⚡️ Lightning Fast HMR +- 🛠️ Rich Features +- 📦 Optimized Build +- 🔩 Universal Plugin Interface +- 🔑 Fully Typed APIs + +Vite (French word for "quick", pronounced [`/viːt/`](https://cdn.jsdelivr.net/gh/vitejs/vite@main/docs/public/vite.mp3), like "veet") is a build tool that aims to provide a faster and leaner development experience for modern web projects. It consists of two major parts: + +- A dev server that provides [rich feature enhancements](https://vite.dev/guide/features) over [native ES modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), for example extremely fast [Hot Module Replacement (HMR)](https://vite.dev/guide/features#hot-module-replacement). + +- A build command that bundles your code with [Rolldown](https://rolldown.rs), pre-configured to output highly optimized static assets for production. + +In addition, Vite is highly extensible via its [Plugin API](https://vite.dev/guide/api-plugin.html) and [JavaScript API](https://vite.dev/guide/api-javascript.html) with full typing support. + +[Read the Docs to Learn More](https://vite.dev). + +## Packages + +| Package | Version (click for changelogs) | +| ----------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| [vite](packages/vite) | [![vite version](https://img.shields.io/npm/v/vite.svg?label=%20)](packages/vite/CHANGELOG.md) | +| [@vitejs/plugin-legacy](packages/plugin-legacy) | [![plugin-legacy version](https://img.shields.io/npm/v/@vitejs/plugin-legacy.svg?label=%20)](packages/plugin-legacy/CHANGELOG.md) | +| [create-vite](packages/create-vite) | [![create-vite version](https://img.shields.io/npm/v/create-vite.svg?label=%20)](packages/create-vite/CHANGELOG.md) | + +## Contribution + +See [Contributing Guide](CONTRIBUTING.md). + +## License + +[MIT](LICENSE). + +## Sponsors + +

+ + sponsors + +

diff --git a/README.md b/README.md index 67d6ff7..f8789bb 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,234 @@ # earsyntax -Deterministic EARS validation for requirements inside Kiro, Spec Kit, -OpenSpec, and plain requirement files. +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 ``` -Nothing to install and no setup: `npx` fetches the CLI, and `validate` runs in -guest mode against the files already in the repo. A run reports each finding, then -a summary line (numbers illustrative): +Successful output looks like: ```text -12/12 valid across 3 file(s), 0 error(s), 1 warning(s) +12/12 valid across 3 file(s), 0 error(s), 0 warning(s) ``` -`earsyntax` is a host-native facade for the Easy Approach to Requirements -Syntax. It locates EARS-shaped requirements in the specification files teams -already use, extracts the candidate lines, parses them deterministically, and -reports stable diagnostics for humans, CI, and coding agents. +`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 | -This README describes the host-native alpha facade being implemented on this -branch. The public command surface is intentionally narrow and does not include -an `earsyntax` project workspace. +## Install -## What It Does +Node.js 22 or newer is required. -`earsyntax` runs a deterministic pipeline: +Run without a global install: -```text -locate host document sections -extract candidate requirements with source positions -parse EARS syntax -lint requirement quality rules -emit findings for terminal, JSON, or SARIF +```bash +npx @earsyntax/cli validate requirements.ears --profile strict ``` -It does not call an LLM. Coding agents call `earsyntax`; `earsyntax` never -calls coding agents. - -Use it to: +Install in a repository: -- validate EARS requirements embedded in existing SDD documents -- inspect which lines a host profile extracts -- explain diagnostics with examples and rationale -- install thin agent wrappers for Claude Code, Codex, Cursor, Copilot, Gemini, - or generic agent files -- emit CI findings as JSON or SARIF +```bash +npm install --save-dev @earsyntax/cli +``` -## Supported Profiles +Use the local binary from package scripts or through your package manager: -Profiles define both syntax dialect and host-document extraction rules. +```bash +npx @earsyntax/cli version --features +``` -| Profile | Use it for | What it reads | -| ---------- | -------------------------- | ------------------------------------------------------- | -| `strict` | Canonical EARS validation | `.ears`, plain EARS text, stdin | -| `ears-x` | earsyntax extensions | strict plus frame metadata, prohibitions, REQ-id format | -| `kiro` | Kiro specs | `.kiro/specs/**/requirements.md` | -| `speckit` | Spec Kit specs | `specs/**/spec.md` | -| `openspec` | OpenSpec specs and changes | `openspec/specs/**`, `openspec/changes/**` | +## Validate Requirements -Run `profiles` to see exactly what each profile locates, relaxes, adds, and -changes by severity: +Plain EARS files use the `strict` profile. Each non-empty line is treated as one +requirement. ```bash -npx @earsyntax/cli profiles +printf 'The billing service shall verify the HMAC signature.\n' \ + | npx @earsyntax/cli validate - --profile strict ``` -## Install +```text +1/1 valid across 1 file(s), 0 error(s), 0 warning(s) +``` -Use the CLI without installing it globally: +Host files use profiles that know where requirements live inside the host +document. ```bash -npx @earsyntax/cli validate requirements.ears --profile strict +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 ``` -Or install it in a repository: +Exit codes: -```bash -npm install --save-dev @earsyntax/cli -``` +| 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. | -Node.js 22 or newer is required. +Warnings do not make `validate` return `1` unless `--strict` upgrades surviving +warnings to errors. -## Validate Existing Specs +## Natural Language To EARS -Kiro: +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. -```bash -earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro +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. ``` -Spec Kit: +Start the portable loop: ```bash -earsyntax validate "specs/**/spec.md" --profile speckit +earsyntax instructions convert \ + --file requirements.ears \ + --from feature.md \ + --profile strict \ + --json ``` -OpenSpec: +The response tells the agent to: -```bash -earsyntax validate "openspec/{specs,changes}/**/*.md" --profile openspec +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. ``` -Plain EARS: +Then validate it: ```bash earsyntax validate requirements.ears --profile strict ``` -Stdin: - -```bash -printf 'When a payment webhook arrives, the billing service shall verify the signature.\n' \ - | earsyntax validate - --profile strict +```text +4/4 valid across 1 file(s), 0 error(s), 0 warning(s) ``` -Exit codes: +If validation fails, the JSON response includes a `next` action such as: -| Code | Meaning | -| ---- | ----------------------------------------------------------------------- | -| `0` | command succeeded, and validation found no error diagnostics | -| `1` | validation completed and found error diagnostics | -| `2` | usage or environment failure, such as a missing file or unknown profile | +```json +{ + "command": "earsyntax instructions repair --file requirements.ears --profile strict --json", + "reason": "Get repair rules for the reported diagnostics.", + "forAgent": true +} +``` -## Inspect Extraction +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. -Use `extract` when a Markdown host file does not validate the way you expect. -It prints the candidate requirements the active profile found, with source -positions and locator rule IDs. +## EARS In One Minute -```bash -earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json +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. ``` -Example candidate: +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. -```json -{ - "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" -} -``` +## Profiles + +Profiles are closed built-ins. Each profile combines EARS dialect rules with the +host-document sections the CLI is allowed to scan. -## Repair With Claude Code +| 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 | -Install host and agent integration files: +Render the exact profile behavior: ```bash -earsyntax init --agent claude --host kiro +earsyntax profiles ``` -Then ask Claude Code to repair a host spec file: +Use `extract` when a profile does not validate the lines you expected: ```bash -claude -p "/earsyntax-repair .kiro/specs/checkout/requirements.md --profile kiro" +earsyntax extract ".kiro/specs/**/requirements.md" --profile kiro --json ``` -The wrapper tells Claude Code to: - -1. run `earsyntax instructions repair --file --profile --json` -2. follow the returned rules exactly -3. edit only the host spec file -4. run `earsyntax validate --profile --json` -5. repeat until validation is clean -6. report unresolved ambiguity for human review +Example candidate: -The agent does not approve, accept, or merge anything. Human review stays in -the host workflow: pull request review, Kiro review, Spec Kit review, or -OpenSpec change review. +```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." +} +``` -## How Init Works +## Agent And Host Setup -`init` installs integration files. It does not create an `earsyntax` project. +`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 @@ -194,156 +246,201 @@ AGENTS.md .kiro/hooks/ears-validate.yaml ``` -`init` is idempotent. Files that may already exist use managed begin and end -markers, so rerunning the same command should produce no diff. +`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` | 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 | +| 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 | Integration | -| ---------- | --------------------------------------------------- | -| `kiro` | Kiro steering and validation hook files | -| `speckit` | Spec Kit command or instruction files | -| `openspec` | OpenSpec agent instructions and validation commands | +| 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` | -## CI +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. -Use `validate` as the gate: +## 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 ".kiro/specs/**/requirements.md" --profile kiro +earsyntax validate requirements.ears --profile strict --json ``` -Emit SARIF for code-scanning integrations: +SARIF is available for code-scanning systems: ```bash -earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif +earsyntax validate requirements.ears --profile strict --sarif > earsyntax.sarif ``` -Upload the SARIF to GitHub code scanning so findings surface on the pull request. -`validate` exits `1` when it finds an error, so gate the job on it and still -upload the report: - -```yaml -name: earsyntax -on: [pull_request] -jobs: - ears: - runs-on: ubuntu-latest - permissions: - security-events: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Validate EARS requirements - run: npx @earsyntax/cli validate ".kiro/specs/**/requirements.md" --profile kiro --sarif > earsyntax.sarif - - name: Upload SARIF - if: always() - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: earsyntax.sarif +Explain any diagnostic by ID: + +```bash +earsyntax explain EARS-E006 ``` -Use JSON when another tool consumes the findings: +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 -| Command | Purpose | -| -------------------------------------- | ------------------------------------------------------- | -| `validate ` | Locate, extract, parse, lint, and report findings | -| `extract ` | Show requirement candidates found by the active profile | -| `instructions --file ` | Return rules for one agent loop step | -| `explain ` | Explain one diagnostic with examples | -| `profiles` | List built-in profiles and their exact behavior | -| `doctor` | Detect host and agent setup and suggest commands | -| `init --agent --host ` | Install host and agent integration files | -| `version --features` | Print package and facade capabilities | - -Eight commands, and the surface is closed. See the [CLI reference](docs/cli.md) -for flags, exit codes, and the JSON envelope per command. +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 a host document's requirements region | -| `convert` | Rewrite natural-language requirements in a host file into EARS in place | -| `repair` | Fix the findings returned by `validate` | -| `review` | Summarize the located requirements for a human; never approve or accept | +| 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 parser, linter, profile runtime, diagnostic -registry, and findings model for TypeScript users. +`@earsyntax/core` exposes the deterministic parser, linter, profile data, +diagnostic registry, and findings helpers for TypeScript users. ```ts -import { lintEars } from '@earsyntax/core'; +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(result.diagnostics); +console.log(profile.ok ? profile.profile.name : profile.error.message); ``` -For batch or host-document workflows, prefer the CLI unless you need direct -embedding inside another tool. +`@earsyntax/extract` owns host-aware extraction and the validation pipeline. +`@earsyntax/cli-contract` owns the shared findings and SARIF output contracts. -## EARS In One Minute +## Packages -EARS, the Easy Approach to Requirements Syntax, is a small set of templates -for requirements. A requirement names a system and the response it shall -perform, optionally guarded by a state, event, optional feature, unwanted -condition, or a combination of those clauses. +| 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. | -Examples: +## Development -```text -The billing service shall verify the HMAC signature of every incoming webhook. -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. +Install dependencies with the repository package manager: + +```bash +pnpm install ``` -`strict` keeps to canonical EARS. Host profiles are explicit adapters: every -relaxation or extension must be named by the profile and covered by fixtures. +Useful checks: -## Status And Limits +```bash +pnpm build +pnpm typecheck +pnpm test +pnpm --filter @earsyntax/cli test +pnpm format:check +``` -The host-native CLI is the alpha target for this branch. +## Status And Limits -Boundaries: +Current boundaries: -- deterministic parsing and linting only +- deterministic parsing, extraction, linting, and reporting only - no semantic contradiction checking - no natural-language intent inference in the CLI -- no LLM calls from core or CLI -- no `earsyntax` workspace lifecycle +- 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 -The maintained design briefs in this repository are: +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: -- [docs/refactor/host-native-facade.md](docs/refactor/host-native-facade.md) (frozen authority for the host-native contracts) - [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) -- [EARSYNTAX-CLI-FACADE-ALPHA-0.md](EARSYNTAX-CLI-FACADE-ALPHA-0.md) (superseded by `docs/refactor/host-native-facade.md`) + +The packages declare Apache-2.0 in their package manifests. diff --git a/docs/api.md b/docs/api.md index 8b0b0e2..ef26ec0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -249,43 +249,7 @@ Every group is optional. Matching is deterministic: exact canonical name, then e `@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. -`extractFromContent` dispatches on the file name's extension: - -```ts -import { extractFromContent } from '@earsyntax/extract'; - -const md = [ - '# Billing requirements', - '', - '- REQ-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.', -].join('\n'); - -const { items, errors } = extractFromContent(md, 'requirements.md'); -``` - -```json -{ - "items": [ - { - "id": "REQ-001", - "text": "When a payment webhook is received, the billing service shall verify the HMAC signature.", - "source": { "file": "requirements.md", "line": 3 } - }, - { - "text": "If the HMAC signature is invalid, then the billing service shall reject the webhook.", - "source": { "file": "requirements.md", "line": 4 } - } - ], - "errors": [] -} -``` - -The heading is ignored, the first bullet's `REQ-001:` prefix becomes the item `id`, and the second bullet keeps its text with no id. Pass the `items` to `lintEarsBatch` to lint them. `extractFromFile` reads from disk instead of a string and is the only function in the package that touches the file system. - -### Host-native pipeline - -The `extractFromContent`/`extractEars`/`extractMarkdown`/`extractYaml`/`extractJson` functions above are the non-profile surface: they read every list item, table row, and structured entry. The host-native pipeline is profile-driven, so a profile controls which regions of a host document become candidates. Two entry points cover it: +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'; @@ -340,7 +304,6 @@ serializeFindings(findings); // byte-stable JSON string of the canonical finding 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`, the `EXIT_OK`/`EXIT_LINT_ERRORS`/`EXIT_USAGE` -constants, and `DIAGNOSTIC_CODES`/`DIAGNOSTIC_DESCRIPTIONS`. This is the same -projection the CLI uses; `earsyntax validate --sarif` and `--json` are thin -wrappers over these serializers. +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/contracts/profile.md b/docs/contracts/profile.md index 990a5e4..611e28f 100644 --- a/docs/contracts/profile.md +++ b/docs/contracts/profile.md @@ -122,6 +122,14 @@ severity is never hard-coded in the linter. | `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 diff --git a/docs/input-formats.md b/docs/input-formats.md index efbd7d2..82bee37 100644 --- a/docs/input-formats.md +++ b/docs/input-formats.md @@ -1,48 +1,36 @@ # Input Formats -`@earsyntax/extract` turns the files people actually write into the -`RequirementInput` shape that `@earsyntax/core` lints. It supports four formats: -`.ears`, Markdown, YAML, and JSON. Every extractor is a pure function of its -input string, returns items in document order, and records a 1-based source -line for each requirement. +`@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 { extractFromContent } from '@earsyntax/extract'; +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 { items, errors } = extractFromContent(md, 'requirements.md'); -// items[0] === { -// id: 'REQ-001', +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.', -// source: { file: 'requirements.md', line: 1 }, +// profile: 'kiro', +// locatorRuleId: 'kiro.acceptance-criteria', +// requirementId: 'REQ-001', // } ``` -This package extracts requirements only. It never lints them or parses EARS -grammar; pass the extracted `items` to `@earsyntax/core` for that. - -## What every extractor returns - -Each extractor returns an `ExtractResult`: - -```ts -interface ExtractResult { - items: RequirementInput[]; // in document order - errors: ExtractError[]; // empty on a clean extraction -} - -interface ExtractError { - message: string; - file?: string; - line?: number; -} -``` - -Extraction is tolerant. A malformed file or a single bad entry produces an -`ExtractError` rather than a thrown exception, and the good entries around it -are still returned. IDs are always optional: when a source provides one it is -lifted into `item.id`, otherwise the item carries `text` and `source` only. +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` @@ -56,17 +44,9 @@ REQ-001: When a payment webhook is received, the billing service shall verify th REQ-002: If the HMAC signature is invalid, then the billing service shall reject the webhook. ``` -```ts -import { extractEars } from '@earsyntax/extract'; - -const { items } = extractEars(content, 'billing.ears'); -// items[0].id === 'REQ-001' -// items[0].source === { file: 'billing.ears', line: 2 } -``` - -A colon inside the requirement text is not mistaken for an ID prefix, because an -ID cannot contain spaces. `When the report is ready, the billing service shall -emit: a summary.` extracts with no `id` and its full text intact. +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 @@ -79,77 +59,37 @@ REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook. ``` -The id and the `[source: ...]` segment are stripped from the text before it is -handed to the linter, so the requirement classifies correctly. When a -`[source: path:line]` reference is present it becomes the item's source -location, overriding the physical `.ears` file and line: - -```ts -const line = - 'REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature.'; -const { items } = extractEars(line, 'requirements.ears'); -// items[0] === { -// id: 'REQ-001', -// text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', -// source: { file: 'specs/checkout.md', line: 14 }, -// } -``` - -For a `line-line` range the start line is used. Because `SourceLocation` holds a -single file and line, the physical `.ears` position is not retained separately -once a declared reference is present. A malformed `[source: ...]` segment (one -that does not parse as `path:line`) is stripped from the text, the id is still -extracted, and the source falls back to the physical `.ears` file and line. +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 extractor reads requirements from three structures and ignores -everything else. Prose paragraphs, headings, and fenced code blocks (both -` ``` ` and `~~~`) are skipped entirely. +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 -requirement. The same metadata prefix as `.ears` applies inside the item: a bare -`REQ-001:` id, or `REQ-001 [source: path:line]:` with a declared source -reference that becomes the item's source location. +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 -- REQ-001: When a payment webhook is received, the billing service shall verify the HMAC signature. +- **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. - -1. REQ-003 [source: specs/checkout.md:20]: The billing service shall retain receipts for seven years. ``` -The first item extracts as `id: 'REQ-001'`; the second has no id; the third -carries `id: 'REQ-003'` with `source: { file: 'specs/checkout.md', line: 20 }`. - -### Tables - -GFM pipe tables are supported in two forms. A table with `ID` and `Requirement` -columns takes the id from the `ID` column: - -```md -| ID | Requirement | -| ------- | ---------------------------------------------------------------------------------------- | -| 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. | -``` - -A single-column requirement table also works, and an `ID:` prefix inside a cell -is lifted just like in a bullet: - -```md -| Requirement | -| ------------------------------------------------------------- | -| REQ-001: The billing service shall verify the HMAC signature. | -| The billing service shall reject invalid webhooks. | -``` - -Column headers are matched case-insensitively. `ID` selects the id column; -`Requirement`, `Requirements`, `Text`, or `Statement` selects the text column. -A table needs a separator row (`| --- |`) with a header row directly above it; -pipe lines without one are treated as prose and ignored. +The first item extracts with `requirementId: 'FR-001'`; the second has no id. ## YAML @@ -163,27 +103,13 @@ requirements: - text: The billing service shall retain receipts for seven years. ``` -```ts -import { extractYaml } from '@earsyntax/extract'; - -const { items, errors } = extractYaml(content, 'requirements.yaml'); -``` - -The first entry extracts with `id: '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 in `errors`. Malformed YAML stops extraction; a -single bad entry is skipped while the others are still returned. - -```ts -// requirements: -// - id: REQ-001 -// text: "unterminated -// errors[0].message === 'Malformed YAML: ...' -// errors[0].line === 3 -``` +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 @@ -204,56 +130,19 @@ optional `id`. } ``` -```ts -import { extractJson } from '@earsyntax/extract'; - -const { items, errors } = extractJson(content, 'requirements.json'); -``` - 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 in -`errors` rather than thrown. - -## Dispatch by extension - -`extractFromContent` picks the right extractor from a file name's extension and -records that name as each item's source file: - -| Extension | Extractor | -| ------------------ | ----------------- | -| `.ears` | `extractEars` | -| `.md`, `.markdown` | `extractMarkdown` | -| `.yaml`, `.yml` | `extractYaml` | -| `.json` | `extractJson` | - -An unsupported extension returns no items and a single `ExtractError`. - -```ts -import { extractFromContent, extractFromFile } from '@earsyntax/extract'; - -// From a string you already hold: -const result = extractFromContent(content, 'requirements.md'); - -// Or read from disk. This is the only function in the package that touches -// the file system; a read failure is reported in `errors`, not thrown. -const fromDisk = extractFromFile('specs/requirements.yaml'); -``` +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 -The `extractEars`/`extractMarkdown`/`extractYaml`/`extractJson` functions above -are the non-profile surface: they read every list item, table row, and structured -entry regardless of context. The host-native commands (`validate`, `extract`) -use a profile-driven pipeline instead, so a profile controls which regions of a -host document become requirement candidates. - Two entry points cover the pipeline: ```ts import { extractCandidates, runPipeline } from '@earsyntax/extract'; import { BUILTIN_PROFILES } from '@earsyntax/core'; -// Stage 1-2 only: locate candidates (what `extract` prints). +// Locate candidates only (what `extract` prints). const { candidates, notices } = extractCandidates({ files: [{ path: '.kiro/specs/checkout/requirements.md', content }], profile: BUILTIN_PROFILES.kiro, @@ -292,26 +181,6 @@ JSON are structured requirement lists: no built-in profile declares them in carry a synthetic `locatorRuleId` of `structured.yaml` or `structured.json`. The active dialect still applies when their text is linted. -### Requirement ids under the pipeline - -Two id conventions apply, and they are handled differently on purpose: - -- 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. Speckit and Kiro use this form. -- An `ears-x` frame prefix (`REQ-001:` and an optional `[source: path:line]` - tag) is retained in the candidate `text`, with `col` 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: findings point at the physical line and column where the text sits. - This differs from the non-profile `extractEars`, which removes the prefix from - `text` and lets a `[source: ...]` reference override the item's source - location. - -Under a profile that allows neither (for example `strict`), a leading `REQ-001:` -is left untouched in the text and the linter reports it. - ### Never throws The pipeline never throws on malformed input. A bad YAML/JSON document or an diff --git a/docs/public-api.md b/docs/public-api.md index 5f94269..31da669 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -8,8 +8,8 @@ worked examples of the four lint functions and the report serializers, see the the [input formats guide](input-formats.md). Every function here is deterministic: no LLM calls, no network, no file system -access (the one exception is `extractFromFile`, which reads a path), no fuzzy -matching. Diagnostics are stably sorted; batch and pipeline order is preserved. +access, no fuzzy matching. Diagnostics are stably sorted; batch and pipeline +order is preserved. ## `@earsyntax/core` @@ -146,24 +146,10 @@ and a profile and returns Findings. The locate and extract stages live in ## `@earsyntax/extract` -### Format extractors - -```ts -import { - extractEars, - extractMarkdown, - extractYaml, - extractJson, - extractFromContent, - extractFromFile, -} from '@earsyntax/extract'; -import type { ExtractError, ExtractResult } from '@earsyntax/extract'; -``` - -Each extractor turns one authored format into `RequirementInput` items. Every -parser is pure; `extractFromContent` dispatches on the file extension, and -`extractFromFile` is the only function that reads disk. The formats are documented -in the [input formats guide](input-formats.md). +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 diff --git a/docs/refactor/inventory.md b/docs/refactor/inventory.md index 8bfee7a..8731a3b 100644 --- a/docs/refactor/inventory.md +++ b/docs/refactor/inventory.md @@ -11,18 +11,17 @@ 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`. Only `extractFromFile` touches disk. (`packages/extract/src/`) | -| `@earsyntax/cli-contract` | Report projections: JSON report, SARIF, pretty, exit codes, diagnostic registry descriptions. 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` already contains a `JsonReport`, a `SarifLog` -builder, an exit-code helper, and a `DIAGNOSTIC_DESCRIPTIONS` registry keyed by -the 29-code `DiagnosticCode` union. The CLI does not currently consume these -projections; `validate` builds its own `ValidationResult[]` shape directly. See -the findings contract for how these converge. +| 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 @@ -237,9 +236,8 @@ Rule bodies live in `packages/cli/src/rules.ts`: `ALLOWED_PATTERNS` `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 via `extractFromFile` (`validate.ts:136`); unreadable file with - errors throws `validate.unreadable` (exit `2`). -- Lint via `lintEarsBatch` (`validate.ts:144`). +- 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 }`, @@ -323,15 +321,15 @@ 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, 29 codes) +## Diagnostic registry (current, 32 codes) -`packages/core/src/types.ts:191-228` defines the `DiagnosticCode` union. -`packages/cli-contract/src/diagnostic-registry.ts:19-57` maps each to a -one-line description. `docs/diagnostics.md` documents severity by mode. +`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). Total 29. -The full list and its new-ID migration is in -`docs/refactor/host-native-facade.md`. +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`, diff --git a/packages/cli-contract/src/diagnostic-registry.test.ts b/packages/cli-contract/src/diagnostic-registry.test.ts deleted file mode 100644 index 2e62fe4..0000000 --- a/packages/cli-contract/src/diagnostic-registry.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; - -describe('diagnostic registry', () => { - it('lists every described code, sorted', () => { - const expected = [...Object.keys(DIAGNOSTIC_DESCRIPTIONS)].sort(); - expect(DIAGNOSTIC_CODES).toEqual(expected); - }); - - it('gives every code a non-empty description ending with a period', () => { - for (const code of DIAGNOSTIC_CODES) { - const text = DIAGNOSTIC_DESCRIPTIONS[code]; - expect(text.length).toBeGreaterThan(0); - expect(text.endsWith('.')).toBe(true); - } - }); - - it('has no duplicate codes', () => { - expect(new Set(DIAGNOSTIC_CODES).size).toBe(DIAGNOSTIC_CODES.length); - }); -}); diff --git a/packages/cli-contract/src/diagnostic-registry.ts b/packages/cli-contract/src/diagnostic-registry.ts deleted file mode 100644 index 3ac758b..0000000 --- a/packages/cli-contract/src/diagnostic-registry.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * The registry of diagnostic codes with short human-readable descriptions. - * - * This is the single source of truth the SARIF builder uses to declare tool - * rules, and any renderer can use it to look up a code's short description. - * The record is keyed by the frozen {@link DiagnosticCode} union, so adding a - * code to `@earsyntax/core` without describing it here is a compile error. - */ - -import type { DiagnosticCode } from '@earsyntax/core'; - -/** - * A one-line description for every {@link DiagnosticCode}. - * - * Descriptions are stable, third-person, and neutral. They describe what the - * code means, not how to fix it. The key set is exhaustive by construction - * (`Record`). - */ -export const DIAGNOSTIC_DESCRIPTIONS: Readonly> = Object.freeze({ - // EARS shell diagnostics. - 'ears.no_match': 'Text does not match any EARS shell pattern.', - 'ears.invalid_clause_order': 'EARS clauses appear in an invalid order.', - 'ears.missing_system': 'Requirement is missing a system before shall.', - 'ears.missing_shall': 'Requirement is missing the shall keyword.', - 'ears.multiple_shall': 'Requirement contains more than one shall keyword.', - 'ears.invalid_if_then_form': 'If/then unwanted-behaviour form is malformed.', - 'ears.empty_clause': 'A clause body is empty.', - 'ears.empty_response': 'The response after shall is empty.', - // Expression diagnostics. - 'expr.unbalanced_parentheses': 'Clause expression has unbalanced parentheses.', - 'expr.invalid_operator_sequence': 'Clause expression has an invalid operator sequence.', - 'expr.empty_subexpression': 'Clause expression contains an empty subexpression.', - 'expr.operator_precedence_warning': 'Mixed operators rely on implicit precedence.', - 'expr.unknown_term': 'A clause term does not resolve to a catalog entry.', - 'expr.ambiguous_term': 'A clause term matches more than one catalog entry.', - 'expr.mixed_unresolved_terms': 'A clause mixes resolved and unresolved terms.', - // Catalog diagnostics: system role. - 'catalog.system_unresolved': 'The system does not resolve to a catalog entry.', - 'catalog.system_ambiguous': 'The system matches more than one catalog entry.', - // Catalog diagnostics: state role. - 'catalog.state_unresolved': 'A state term does not resolve to a catalog entry.', - 'catalog.state_ambiguous': 'A state term matches more than one catalog entry.', - // Catalog diagnostics: event role. - 'catalog.event_unresolved': 'An event term does not resolve to a catalog entry.', - 'catalog.event_ambiguous': 'An event term matches more than one catalog entry.', - // Catalog diagnostics: feature role. - 'catalog.feature_unresolved': 'A feature term does not resolve to a catalog entry.', - 'catalog.feature_ambiguous': 'A feature term matches more than one catalog entry.', - // Catalog coverage. - 'catalog.term_unreferenced': 'A catalog entry is never referenced by any requirement.', - // Lint diagnostics. - 'lint.multiple_responses': 'A requirement declares more than one response.', - 'lint.vague_response': 'A response uses vague, unverifiable wording.', - 'lint.unparsed_tail': 'Trailing text after the requirement could not be parsed.', - 'lint.alias_used': 'A term was matched via an alias rather than its canonical name.', - 'lint.suspicious_text_shape': 'Requirement text has a suspicious shape.', - // Host-native grammar diagnostics. - 'ears.keyword_case': 'A keyword violates strict canonical casing.', - 'ears.missing_leading_comma': 'A leading clause is not comma-delimited where required.', - 'ears.prohibition_not_allowed': 'A shall not prohibition is used where the dialect forbids it.', -}); - -/** - * Every diagnostic code, sorted lexicographically. - * - * Sorting makes the SARIF rule list deterministic regardless of the key order - * the registry object was written in. - */ -export const DIAGNOSTIC_CODES: readonly DiagnosticCode[] = Object.freeze( - (Object.keys(DIAGNOSTIC_DESCRIPTIONS) as DiagnosticCode[]).sort(), -); diff --git a/packages/cli-contract/src/index.ts b/packages/cli-contract/src/index.ts index f0decbf..d540249 100644 --- a/packages/cli-contract/src/index.ts +++ b/packages/cli-contract/src/index.ts @@ -14,35 +14,10 @@ * preserve input order, so identical input always produces identical output. */ -export type { - Findings, - FindingsDiagnostic, - FindingsSummary, - FindingsSeverity, -} from '@earsyntax/core'; +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 { DIAGNOSTIC_CODES, DIAGNOSTIC_DESCRIPTIONS } from './diagnostic-registry.js'; - -export type { - BuildSarifOptions, - SarifDriver, - SarifLevel, - SarifLocation, - SarifLog, - SarifPhysicalLocation, - SarifRegion, - SarifResult, - SarifRule, - SarifRun, -} from './sarif.js'; -export { - buildSarifLog, - serializeSarifLog, - SARIF_SCHEMA, - SARIF_TOOL_NAME, - SARIF_VERSION, -} from './sarif.js'; +export { buildSarifLog, serializeSarifLog } from './sarif.js'; diff --git a/packages/cli-contract/src/sarif.ts b/packages/cli-contract/src/sarif.ts index 5d2defd..29c75ff 100644 --- a/packages/cli-contract/src/sarif.ts +++ b/packages/cli-contract/src/sarif.ts @@ -247,10 +247,14 @@ export function buildSarifLog(findings: Findings, options: BuildSarifOptions = { const ruleIndexById = new Map(distinctIds.map((id, index): [string, number] => [id, index])); const rules = distinctIds.map(buildRule); - const results = findings.diagnostics.map((diagnostic) => - // Every finding id is in distinctIds by construction, so the index is defined. - buildResult(diagnostic, ruleIndexById.get(diagnostic.id) ?? 0), - ); + 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; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7879c23..00d7385 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,7 @@ }, "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\"", 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 index d2eac82..891dc1f 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -53,7 +53,14 @@ export function parseArgs(argv: string[]): ParsedArgs { // `--flag=value` form. Last assignment wins for a repeated flag. const eq = body.indexOf('='); if (eq !== -1) { - values.set(body.slice(0, eq), body.slice(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; } @@ -83,12 +90,10 @@ export interface GlobalOptions { /** The active profile name; defaults to `strict`. Commands validate it against the registry. */ profile: string; cwd?: string; - /** Whether pretty output may use ANSI color. Set by the dispatcher, not a user flag. */ - color: boolean; } -/** Extract global options from parsed args. `color` is supplied by the dispatcher. */ -export function resolveGlobals(args: ParsedArgs, color: boolean): GlobalOptions { +/** Extract global options from parsed args. */ +export function resolveGlobals(args: ParsedArgs): GlobalOptions { return { json: args.booleans.has('json'), sarif: args.booleans.has('sarif'), @@ -96,6 +101,5 @@ export function resolveGlobals(args: ParsedArgs, color: boolean): GlobalOptions quiet: args.booleans.has('quiet'), profile: args.values.get('profile') ?? 'strict', cwd: args.values.get('cwd'), - color, }; } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b47b889..1b7d0a6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,7 +31,6 @@ import { versionCommand } from './commands/version.js'; export interface RunOptions { cwd?: string; stdout?: (text: string) => void; - stderr?: (text: string) => void; } /** A routed command: its handler and the flags it accepts beyond the universal set. */ @@ -130,7 +129,7 @@ function dispatch( const args = parseArgs(rest); checkFlags(command, spec, args); - const global = resolveGlobals(args, color); + const global = resolveGlobals(args); if (global.json && global.sarif) { throw usageError( 'cli.exclusive_flags', diff --git a/packages/cli/src/color.ts b/packages/cli/src/color.ts index a0ebf38..757a719 100644 --- a/packages/cli/src/color.ts +++ b/packages/cli/src/color.ts @@ -1,7 +1,8 @@ /** * Minimal ANSI color helpers. * - * Color is applied only when enabled (the `--no-color` global turns it off), so + * 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. */ @@ -13,7 +14,6 @@ const CODES = { bold: `${ESC}[1m`, dim: `${ESC}[2m`, red: `${ESC}[31m`, - green: `${ESC}[32m`, yellow: `${ESC}[33m`, cyan: `${ESC}[36m`, } as const; diff --git a/packages/cli/src/commands/.gitkeep b/packages/cli/src/commands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 8f5e369..85228c5 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -1,12 +1,9 @@ /** * Tests for `earsyntax doctor`. * - * The dispatcher (cli.ts/args.ts) is owned by another agent; these drive - * {@link doctorCommand} directly with a hand-built {@link CommandContext} and - * assert against the returned {@link CommandResult}. Once the dispatcher routes - * `doctor`, the acceptance gate - * `node packages/cli/bin/run.js doctor --cwd fixtures/host-repos/kiro --json` - * exercises the same path end to end. + * 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 @@ -48,7 +45,6 @@ function runDoctor(opts: { cwd: string; json?: boolean; quiet?: boolean }): Comm strict: false, quiet: opts.quiet ?? false, profile: 'strict', - color: false, }; const emitter: Emitter = { json: global.json, diff --git a/packages/cli/src/commands/explain.test.ts b/packages/cli/src/commands/explain.test.ts index 7f506ee..1c4ea85 100644 --- a/packages/cli/src/commands/explain.test.ts +++ b/packages/cli/src/commands/explain.test.ts @@ -57,7 +57,6 @@ function makeContext( quiet: options.quiet ?? false, profile: 'strict', cwd: '/work', - color: false, }; const emitter: Emitter = { json: global.json, diff --git a/packages/cli/src/commands/extract.test.ts b/packages/cli/src/commands/extract.test.ts index 599dbce..a8e9319 100644 --- a/packages/cli/src/commands/extract.test.ts +++ b/packages/cli/src/commands/extract.test.ts @@ -1,12 +1,9 @@ /** * Tests for `earsyntax extract`. * - * The command dispatcher (cli.ts/args.ts) is owned by another agent; these drive - * {@link extractCommand} directly with a hand-built {@link CommandContext} and - * assert against the returned {@link CommandResult}. Once the dispatcher routes - * `extract`, the acceptance gate - * `node packages/cli/bin/run.js extract fixtures/profiles/kiro/requirements.md --profile kiro --json` - * exercises the same path end to end. + * 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 @@ -49,7 +46,6 @@ function runExtract(opts: { strict: false, quiet: opts.quiet ?? false, profile: opts.profile ?? 'strict', - color: false, }; const emitter: Emitter = { json: global.json, diff --git a/packages/cli/src/commands/instructions.test.ts b/packages/cli/src/commands/instructions.test.ts index 5528111..26a00eb 100644 --- a/packages/cli/src/commands/instructions.test.ts +++ b/packages/cli/src/commands/instructions.test.ts @@ -3,10 +3,8 @@ * * These drive {@link instructionsCommand} directly with a hand-built * {@link CommandContext} and assert against the returned {@link CommandResult}, - * mirroring `extract.test.ts`. The dispatcher (cli.ts/args.ts) is covered - * separately; the acceptance gate - * `node packages/cli/bin/run.js instructions repair --file fixtures/profiles/kiro/requirements.md --profile kiro --json` - * exercises the same path end to end. + * 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 @@ -86,7 +84,6 @@ function runInstructions(opts: { strict: opts.strict ?? false, quiet: opts.quiet ?? false, profile: opts.profile ?? 'strict', - color: false, }; const emitter: Emitter = { json: global.json, diff --git a/packages/cli/src/commands/profiles.test.ts b/packages/cli/src/commands/profiles.test.ts index 13bcf94..3ff527c 100644 --- a/packages/cli/src/commands/profiles.test.ts +++ b/packages/cli/src/commands/profiles.test.ts @@ -31,7 +31,6 @@ function makeContext( quiet: options.quiet ?? false, profile: options.profile ?? 'strict', cwd: '/work', - color: false, }; const emitter: Emitter = { json: global.json, @@ -129,6 +128,19 @@ describe('profilesCommand — pretty rendering derives from the same data', () = }); }); +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 = ''; @@ -163,4 +175,17 @@ describe('profiles — through the dispatcher', () => { 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 index fb2d366..13e16bb 100644 --- a/packages/cli/src/commands/profiles.ts +++ b/packages/cli/src/commands/profiles.ts @@ -11,7 +11,8 @@ * * 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`. + * 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'; @@ -43,9 +44,10 @@ function prettyProfile(diff: ProfileDiff): string { * write. Takes no positionals or flags beyond the universal set, resolves no * repo, and always exits `0`. */ -export function profilesCommand(_context: CommandContext): CommandResult { +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'); - return { response, pretty, exitCode: 0 }; + 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 index 65b87bb..8eaa252 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -321,7 +321,7 @@ describe('runValidate — inputs and exit codes', () => { fakeDeps({ [`${CWD}/clean.ears`]: CLEAN_LINE }), ); expect(r.exitCode).toBe(2); - expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('cli.conflicting_flags'); + expect((r.response.diagnostics as { code: string }[])[0]?.code).toBe('cli.exclusive_flags'); }); }); @@ -379,7 +379,6 @@ function makeContext( quiet: options.quiet ?? false, profile: options.profile ?? 'strict', cwd: options.cwd, - color: false, }; const emitter: Emitter = { json: global.json, diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 44a0c9c..89dedcb 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -31,6 +31,8 @@ import { import { buildSarifLog, canonicalizeFindings, + EXIT_USAGE, + exitCodeForFindings, type Findings, serializeSarifLog, } from '@earsyntax/cli-contract'; @@ -119,11 +121,12 @@ export function runValidate( deps: ValidateDeps = DEFAULT_DEPS, ): ValidateResult { // `--json` and `--sarif` are mutually exclusive. The dispatcher rejects the - // combination before reaching a handler (cli.exclusive_flags); this in-band - // guard covers direct callers of runValidate so the invariant holds either way. + // 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.conflicting_flags', + 'cli.exclusive_flags', 'The --json and --sarif flags are mutually exclusive.', ); } @@ -270,7 +273,7 @@ function frame( const diagnostics = notices.map(noticeToDiagnostic); const environmentError = notices.some((notice) => notice.severity === 'error'); const ok = !environmentError && findings.summary.errors === 0; - const exitCode = environmentError ? 2 : findings.summary.errors > 0 ? 1 : 0; + const exitCode = environmentError ? EXIT_USAGE : exitCodeForFindings(findings); const next = buildNext(findings, profile); const response = buildResponse( 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 index b8dae3e..d4c3ded 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -3,6 +3,8 @@ * * 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'; @@ -30,5 +32,6 @@ export function versionCommand(context: CommandContext): CommandResult { ].join('\n') : `earsyntax ${CLI_VERSION}`; - return { response, pretty, exitCode: 0 }; + const result = { response, pretty, exitCode: 0 }; + return context.global.quiet && !context.global.json ? { ...result, pretty: '' } : result; } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index cec6834..21058f3 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -8,19 +8,17 @@ * workspace to refuse writes over, so exit `3` no longer exists. */ -import type { FacadeDiagnostic, NextAction } from './facade-types.js'; +import type { FacadeDiagnostic } from './facade-types.js'; export class CliError extends Error { readonly exitCode: number; readonly diagnostic: FacadeDiagnostic; - readonly next: NextAction[]; - constructor(exitCode: number, diagnostic: FacadeDiagnostic, next: NextAction[] = []) { + constructor(exitCode: number, diagnostic: FacadeDiagnostic) { super(diagnostic.message); this.name = 'CliError'; this.exitCode = exitCode; this.diagnostic = diagnostic; - this.next = next; } } diff --git a/packages/cli/src/renderers/agents.ts b/packages/cli/src/renderers/agents.ts index 1a4b2ca..3f97c38 100644 --- a/packages/cli/src/renderers/agents.ts +++ b/packages/cli/src/renderers/agents.ts @@ -130,7 +130,5 @@ export function renderAgent(agent: Agent, hosts: readonly string[]): Contributio { file: 'GEMINI.md', id: 'agent-loop', block: agentLoopBlock(profile, note) }, ]; return contribution; - default: - return contribution; } } diff --git a/packages/cli/src/renderers/hosts.ts b/packages/cli/src/renderers/hosts.ts index 0e07b55..d7692ad 100644 --- a/packages/cli/src/renderers/hosts.ts +++ b/packages/cli/src/renderers/hosts.ts @@ -17,21 +17,15 @@ 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: { - glob: '.kiro/specs/**/requirements.md', - command: 'earsyntax validate ".kiro/specs/**/requirements.md" --profile kiro', - }, - speckit: { - glob: 'specs/**/spec.md', - command: 'earsyntax validate "specs/**/spec.md" --profile speckit', - }, - openspec: { - glob: 'openspec/specs/**/*.md', - command: 'earsyntax validate "openspec/specs/**/*.md" --profile openspec', - }, +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[] = [ @@ -73,7 +67,7 @@ function kiroHook(): { path: string; content: string } { 'on:', ' fileEdited:', ' patterns:', - ` - "${HOST_VALIDATE.kiro.glob}"`, + ` - "${KIRO_HOOK_GLOB}"`, 'run: >-', ` ${HOST_VALIDATE.kiro.command} --json`, '', @@ -126,7 +120,5 @@ export function renderHost(host: Host): Contribution { { file: 'AGENTS.md', id: 'openspec-validate', block: openspecBlock() }, ]; return contribution; - default: - return contribution; } } diff --git a/packages/cli/src/response.ts b/packages/cli/src/response.ts index a14beb9..5018246 100644 --- a/packages/cli/src/response.ts +++ b/packages/cli/src/response.ts @@ -65,11 +65,6 @@ export function emitResult( emitter.write(`${emitter.json ? serialize(response) : pretty}\n`); } -/** Emit a response: JSON when `--json`, otherwise the pretty string. */ -export function emit(emitter: Emitter, response: FacadeResponse, pretty: string): void { - emitResult(emitter, response, pretty); -} - /** Build the response for a {@link CliError} so the dispatcher can emit it uniformly. */ export function errorResponse( command: string, @@ -81,6 +76,5 @@ export function errorResponse( ok: false, root, diagnostics: [error.diagnostic], - next: error.next, }); } diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts index 5d6e2f7..35dc999 100644 --- a/packages/cli/src/rules.ts +++ b/packages/cli/src/rules.ts @@ -176,6 +176,12 @@ const FROM_RULES: readonly string[] = [ * 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.', diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 238810f..be1a92c 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -10,6 +10,9 @@ 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 @@ -63,10 +66,10 @@ export const FEATURES: Features = { 'init', 'version', ], - profiles: ['strict', 'ears-x', 'kiro', 'speckit', 'openspec'], + profiles: [...BUILTIN_PROFILE_NAMES], instructions: ['author', 'convert', 'repair', 'review'], - hosts: ['kiro', 'speckit', 'openspec'], - agents: ['claude', 'codex', 'cursor', 'copilot', 'gemini', 'generic'], + hosts: [...HOSTS], + agents: [...AGENTS], inputFormats: ['ears', 'text', 'markdown', 'yaml', 'json'], outputFormats: ['pretty', 'json', 'sarif'], sarif: true, diff --git a/packages/core/src/findings.ts b/packages/core/src/findings.ts index 3142323..3b514bc 100644 --- a/packages/core/src/findings.ts +++ b/packages/core/src/findings.ts @@ -96,7 +96,7 @@ export type SeverityOverrides = Readonly>; /** * One linted requirement: the requirement that went in and the result that came - * out, mirroring `@earsyntax/cli-contract`'s report input item. + * out. */ export interface FindingsInputItem { /** The requirement that was linted (`id`, `text`, and optional `source`). */ diff --git a/packages/core/src/profiles/schema.ts b/packages/core/src/profiles/schema.ts index 7df926d..0cadd7d 100644 --- a/packages/core/src/profiles/schema.ts +++ b/packages/core/src/profiles/schema.ts @@ -111,7 +111,17 @@ export interface ProfileLocator { codeFences: CodeFences; } -/** Whether requirements must carry an id and its shape. */ +/** + * 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; diff --git a/packages/core/test/profiles-strict-earsx.test.ts b/packages/core/test/profiles-strict-earsx.test.ts index 073aec7..896dc66 100644 --- a/packages/core/test/profiles-strict-earsx.test.ts +++ b/packages/core/test/profiles-strict-earsx.test.ts @@ -41,6 +41,11 @@ interface StrictInvalidExpected { }[]; } +interface StrictValidExpected { + findings: { ok: boolean; errors: number; warnings: number }; + lines: { line: number; pattern: string; expect: { code: string }[] }[]; +} + interface PerProfileExpected { lines: { line: number; @@ -58,15 +63,22 @@ function readText(relPath: string): string { 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'); - test('every line is valid under strict with no diagnostics', () => { - for (const text of lines) { + 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); - expect(result.diagnostics, text).toHaveLength(0); - } - }); + const actual = result.diagnostics.map((d) => d.code).sort(); + expect(actual, text).toEqual(want); + }); + } }); describe('strict/invalid.ears', () => { @@ -102,9 +114,14 @@ describe('ears-x/prohibition.ears', () => { ); const underEarsX = lintEars(entry.text, undefined, { dialect: earsx }); - expect(underEarsX.valid, entry.text).toBe(true); - expect(underEarsX.diagnostics, entry.text).toHaveLength(0); - expect(underEarsX.ast?.prohibition, entry.text).toBe(true); + 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); } @@ -129,8 +146,11 @@ describe('ears-x/frame-metadata.ears', () => { ); const underEarsX = lintEars(entry.text, undefined, { dialect: earsx }); - expect(underEarsX.valid, entry.text).toBe(true); - expect(underEarsX.diagnostics, entry.text).toHaveLength(0); + 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); } diff --git a/packages/extract/src/dispatch.test.ts b/packages/extract/src/dispatch.test.ts deleted file mode 100644 index 55cd017..0000000 --- a/packages/extract/src/dispatch.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { extractFromContent, extractFromFile } from './dispatch.js'; - -describe('extractFromContent', () => { - it('routes .ears content to the ears extractor', () => { - const { items } = extractFromContent( - 'REQ-001: The billing service shall verify signatures.', - 'requirements.ears', - ); - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify signatures.', - source: { file: 'requirements.ears', line: 1 }, - }, - ]); - }); - - it('routes .md and .markdown content to the markdown extractor', () => { - const bullet = '- REQ-001: The billing service shall verify signatures.'; - expect(extractFromContent(bullet, 'reqs.md').items[0].id).toBe('REQ-001'); - expect(extractFromContent(bullet, 'reqs.markdown').items[0].id).toBe('REQ-001'); - }); - - it('routes .yaml and .yml content to the yaml extractor', () => { - const yaml = - 'requirements:\n - id: REQ-001\n text: The billing service shall verify signatures.\n'; - expect(extractFromContent(yaml, 'reqs.yaml').items[0].id).toBe('REQ-001'); - expect(extractFromContent(yaml, 'reqs.yml').items[0].id).toBe('REQ-001'); - }); - - it('routes .json content to the json extractor', () => { - const json = - '{ "requirements": [ { "id": "REQ-001", "text": "The billing service shall verify signatures." } ] }'; - expect(extractFromContent(json, 'reqs.json').items[0].id).toBe('REQ-001'); - }); - - it('reports an error for an unsupported extension', () => { - const { items, errors } = extractFromContent('anything', 'notes.txt'); - expect(items).toEqual([]); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Unsupported file extension'); - expect(errors[0].file).toBe('notes.txt'); - }); -}); - -describe('extractFromFile', () => { - let dir: string; - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'earsyntax-extract-')); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('reads a file from disk and extracts by extension', () => { - const path = join(dir, 'requirements.ears'); - writeFileSync(path, 'REQ-001: The billing service shall verify signatures.\n', 'utf8'); - - const { items, errors } = extractFromFile(path); - - expect(errors).toEqual([]); - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify signatures.', - source: { file: path, line: 1 }, - }, - ]); - }); - - it('reports a read error for a missing file rather than throwing', () => { - const path = join(dir, 'does-not-exist.ears'); - - const { items, errors } = extractFromFile(path); - - expect(items).toEqual([]); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Could not read file'); - expect(errors[0].file).toBe(path); - }); -}); diff --git a/packages/extract/src/dispatch.ts b/packages/extract/src/dispatch.ts deleted file mode 100644 index 2d5a60f..0000000 --- a/packages/extract/src/dispatch.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Format dispatch and the single disk-reading wrapper. - * - * {@link extractFromContent} routes a string to the right parser by the file - * name's extension. {@link extractFromFile} is the only function in the package - * that touches the file system; every parser stays pure string-in. - */ - -import { readFileSync } from 'node:fs'; -import { extname } from 'node:path'; -import type { ExtractResult } from './types.js'; -import { extractEars } from './ears.js'; -import { extractMarkdown } from './markdown.js'; -import { extractYaml } from './yaml.js'; -import { extractJson } from './json.js'; - -/** - * Extract requirements from raw content, choosing the parser by the extension - * of `filename`. The name is also recorded as each item's source file. - * - * Supported extensions: `.ears`, `.md`, `.markdown`, `.yaml`, `.yml`, `.json`. - * An unsupported extension yields a single {@link ExtractError} and no items. - * - * @param content Raw file contents. - * @param filename File name or path used to pick the parser and label sources. - */ -export function extractFromContent(content: string, filename: string): ExtractResult { - const extension = extname(filename).toLowerCase(); - switch (extension) { - case '.ears': - return extractEars(content, filename); - case '.md': - case '.markdown': - return extractMarkdown(content, filename); - case '.yaml': - case '.yml': - return extractYaml(content, filename); - case '.json': - return extractJson(content, filename); - default: - return { - items: [], - errors: [ - { message: `Unsupported file extension: "${extension || filename}".`, file: filename }, - ], - }; - } -} - -/** - * Read a file from disk and extract its requirements. This is the only function - * that performs I/O. A read failure is reported as an {@link ExtractError} - * rather than thrown. - * - * @param filePath Path to the file to read and extract. - */ -export function extractFromFile(filePath: string): ExtractResult { - let content: string; - try { - content = readFileSync(filePath, 'utf8'); - } catch (error) { - return { - items: [], - errors: [ - { - message: `Could not read file: ${error instanceof Error ? error.message : String(error)}`, - file: filePath, - }, - ], - }; - } - return extractFromContent(content, filePath); -} diff --git a/packages/extract/src/ears.test.ts b/packages/extract/src/ears.test.ts deleted file mode 100644 index 02afc8a..0000000 --- a/packages/extract/src/ears.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractEars } from './ears.js'; - -describe('extractEars', () => { - it('extracts one requirement per non-empty line with ids and line numbers', () => { - const content = [ - '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.', - ].join('\n'); - - const { items, errors } = extractEars(content, 'requirements.ears'); - - 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.ears', line: 1 }, - }, - { - id: 'REQ-002', - text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', - source: { file: 'requirements.ears', line: 2 }, - }, - ]); - }); - - it('omits id when no ID prefix is present', () => { - const content = - 'When a payment webhook is received, the billing service shall verify the HMAC signature.'; - - const { items } = extractEars(content); - - expect(items).toHaveLength(1); - expect(items[0].id).toBeUndefined(); - expect(items[0].text).toBe(content); - expect(items[0].source).toEqual({ line: 1 }); - }); - - it('does not treat a colon inside requirement prose as an id prefix', () => { - const content = - 'When the report is ready, the billing service shall emit: a summary and a total.'; - - const { items } = extractEars(content); - - expect(items[0].id).toBeUndefined(); - expect(items[0].text).toBe(content); - }); - - it('ignores comment lines and blank lines while keeping line numbers accurate', () => { - const content = [ - '# billing requirements', - '', - 'REQ-001: The billing service shall retain receipts.', - ' ', - '# trailing note', - ].join('\n'); - - const { items } = extractEars(content); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall retain receipts.', - source: { line: 3 }, - }, - ]); - }); - - it('parses the metadata prefix form and uses the declared source reference', () => { - const content = - 'REQ-001 [source: specs/checkout.md:14]: When a payment webhook is received, the billing service shall verify the HMAC signature.'; - - const { items, errors } = extractEars(content, 'requirements.ears'); - - 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: 'specs/checkout.md', line: 14 }, - }, - ]); - }); - - it('parses the metadata prefix source range, using the start line', () => { - const content = - 'REQ-002 [source: specs/checkout.md:12-14]: If the HMAC signature is invalid, then the billing service shall reject the webhook.'; - - const { items } = extractEars(content, 'requirements.ears'); - - expect(items[0].id).toBe('REQ-002'); - expect(items[0].source).toEqual({ file: 'specs/checkout.md', line: 12 }); - expect(items[0].text).toBe( - 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', - ); - }); - - it('strips a malformed [source:] segment, keeps the id, and falls back to the physical location', () => { - const content = 'REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; - - const { items, errors } = extractEars(content, 'requirements.ears'); - - expect(errors).toEqual([]); - expect(items).toEqual([ - { - id: 'REQ-003', - text: 'The billing service shall retain receipts.', - source: { file: 'requirements.ears', line: 1 }, - }, - ]); - }); - - it('returns no items for an empty file', () => { - expect(extractEars('')).toEqual({ items: [], errors: [] }); - expect(extractEars('\n\n \n')).toEqual({ items: [], errors: [] }); - }); -}); diff --git a/packages/extract/src/ears.ts b/packages/extract/src/ears.ts deleted file mode 100644 index 48d241a..0000000 --- a/packages/extract/src/ears.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * `.ears` extractor. - * - * Format: one requirement per non-empty line. A line may carry an optional - * metadata prefix: a bare `REQ-001:` id, or `REQ-001 [source: path:line]:` / - * `REQ-001 [source: path:line-line]:` with a declared source reference. When a - * `[source: ...]` reference is present it becomes the item's source location - * (overriding the physical `.ears` file and line); otherwise the source is the - * physical file and 1-based line. Lines whose first non-whitespace character is - * `#` are comments and are ignored. Blank lines are ignored. Order is document - * order. - */ - -import type { RequirementInput } from '@earsyntax/core'; -import type { ExtractResult } from './types.js'; -import { splitId } from './internal.js'; -import { stripBom } from './normalize.js'; - -/** - * Extract requirements from `.ears` content. - * - * @param rawContent Raw file contents. - * @param file Optional source path, echoed onto each item's source location. - */ -export function extractEars(rawContent: string, file?: string): ExtractResult { - const items: RequirementInput[] = []; - const lines = stripBom(rawContent).split('\n'); - - for (let i = 0; i < lines.length; i++) { - const raw = lines[i]; - const trimmed = raw.trim(); - if (trimmed === '' || trimmed.startsWith('#')) { - continue; - } - const line = i + 1; - const { id, ref, text } = splitId(trimmed); - const source = - ref === undefined - ? { ...(file === undefined ? {} : { file }), line } - : { file: ref.file, line: ref.line }; - items.push({ - ...(id === undefined ? {} : { id }), - text, - source, - }); - } - - return { items, errors: [] }; -} diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts index 76616f0..770f26b 100644 --- a/packages/extract/src/index.ts +++ b/packages/extract/src/index.ts @@ -1,9 +1,10 @@ /** * `@earsyntax/extract` public API surface. * - * Turns files people author (`.ears`, Markdown, YAML, JSON) into the frozen - * {@link RequirementInput} shape from `@earsyntax/core`. Every parser is a pure - * function of its input string; only {@link extractFromFile} touches the disk. + * 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. @@ -11,12 +12,6 @@ export type { ExtractError, ExtractResult } from './types.js'; -export { extractEars } from './ears.js'; -export { extractMarkdown } from './markdown.js'; -export { extractYaml } from './yaml.js'; -export { extractJson } from './json.js'; -export { extractFromContent, extractFromFile } from './dispatch.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'; diff --git a/packages/extract/src/markdown-scan.test.ts b/packages/extract/src/markdown-scan.test.ts index f8aa7b8..1462ed6 100644 --- a/packages/extract/src/markdown-scan.test.ts +++ b/packages/extract/src/markdown-scan.test.ts @@ -1,21 +1,21 @@ /** - * Tests for the shared Markdown scanning primitives: fence tracking by marker - * character and length, and pipe-row splitting that respects escaped pipes and - * inline code spans. These lock the Codex-review fixes in place. + * 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, splitTableRow } from './markdown-scan.js'; +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. + // A shorter run does not close a longer fence: still inside, not closed. expect(tracker.feed('```')).toBe('inside'); - expect(tracker.isOpen).toBe(true); + expect(tracker.feed('text')).toBe('inside'); expect(tracker.feed('````')).toBe('close'); - expect(tracker.isOpen).toBe(false); + // 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', () => { @@ -23,21 +23,3 @@ describe('FenceTracker', () => { expect(states).toEqual(['open', 'inside', 'inside', 'inside', 'close', 'outside']); }); }); - -describe('splitTableRow', () => { - it('drops leading and trailing pipes and trims cells', () => { - expect(splitTableRow('| ID | Requirement |')).toEqual(['ID', 'Requirement']); - }); - - it('accepts rows without a leading pipe (GFM)', () => { - expect(splitTableRow('ID | Requirement')).toEqual(['ID', 'Requirement']); - }); - - it('does not split on an escaped pipe', () => { - expect(splitTableRow('| a \\| b | c |')).toEqual(['a \\| b', 'c']); - }); - - it('does not split on a pipe inside an inline code span', () => { - expect(splitTableRow('| `a | b` | c |')).toEqual(['`a | b`', 'c']); - }); -}); diff --git a/packages/extract/src/markdown-scan.ts b/packages/extract/src/markdown-scan.ts index f94d8c1..d15bcfe 100644 --- a/packages/extract/src/markdown-scan.ts +++ b/packages/extract/src/markdown-scan.ts @@ -2,17 +2,12 @@ * Shared Markdown scanning primitives for `@earsyntax/extract`. Not part of the * public API. * - * Two structures are shared by the legacy Markdown extractor and the - * profile-driven locator so fenced-code and table handling stay consistent: - * - * 1. {@link FenceTracker}, a CommonMark-aware fenced-code-block tracker that - * 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. - * 2. {@link splitTableRow}, a GFM pipe-row splitter that ignores escaped pipes - * (`\|`) and pipes inside inline code spans (`` `a | b` ``), and tolerates - * rows with or without leading and trailing pipes. + * {@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. */ @@ -65,11 +60,6 @@ export class FenceTracker { } return 'inside'; } - - /** Whether the tracker is currently inside an unclosed fence. */ - get isOpen(): boolean { - return this.open !== null; - } } /** @@ -86,66 +76,3 @@ export function classifyFences(lines: readonly string[]): FenceState[] { const tracker = new FenceTracker(); return lines.map((line) => tracker.feed(line)); } - -/** - * Split a GFM pipe-table row into trimmed cells. - * - * Pipes escaped as `\|` and pipes inside inline code spans are not treated as - * cell separators. A single leading and a single trailing pipe (the common GFM - * form) are dropped; rows without them are still split correctly. - * - * @param row The trimmed row text. - * @returns The row's cells, trimmed, in order. - */ -export function splitTableRow(row: string): string[] { - const cells: string[] = []; - let current = ''; - let codeRun = 0; // length of the backtick run that opened the current code span - let i = 0; - - while (i < row.length) { - const ch = row[i]; - - if (ch === '\\' && codeRun === 0 && i + 1 < row.length) { - // A backslash escape outside code spans: keep the escaped character whole - // so an escaped pipe never splits the cell. - current += ch + row[i + 1]; - i += 2; - continue; - } - - if (ch === '`') { - let run = 0; - while (i + run < row.length && row[i + run] === '`') { - run++; - } - if (codeRun === 0) { - codeRun = run; - } else if (run === codeRun) { - codeRun = 0; - } - current += '`'.repeat(run); - i += run; - continue; - } - - if (ch === '|' && codeRun === 0) { - cells.push(current); - current = ''; - i++; - continue; - } - - current += ch; - i++; - } - cells.push(current); - - if (cells.length > 0 && cells[0].trim() === '' && row.startsWith('|')) { - cells.shift(); - } - if (cells.length > 0 && cells[cells.length - 1].trim() === '' && row.endsWith('|')) { - cells.pop(); - } - return cells.map((cell) => cell.trim()); -} diff --git a/packages/extract/src/markdown.test.ts b/packages/extract/src/markdown.test.ts deleted file mode 100644 index e3eee17..0000000 --- a/packages/extract/src/markdown.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractMarkdown } from './markdown.js'; - -describe('extractMarkdown', () => { - it('extracts bullet lists with and without ids', () => { - const content = [ - '# Billing requirements', - '', - 'Some prose describing the webhook flow that must be ignored.', - '', - '- REQ-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.', - '* REQ-003: The billing service shall retain receipts for seven years.', - ].join('\n'); - - const { items, errors } = extractMarkdown(content, 'requirements.md'); - - 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.md', line: 5 }, - }, - { - text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', - source: { file: 'requirements.md', line: 6 }, - }, - { - id: 'REQ-003', - text: 'The billing service shall retain receipts for seven years.', - source: { file: 'requirements.md', line: 7 }, - }, - ]); - }); - - it('extracts numbered lists', () => { - const content = [ - '1. REQ-001: The billing service shall verify the HMAC signature.', - '2) The billing service shall reject invalid webhooks.', - ].join('\n'); - - const { items } = extractMarkdown(content); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify the HMAC signature.', - source: { line: 1 }, - }, - { text: 'The billing service shall reject invalid webhooks.', source: { line: 2 } }, - ]); - }); - - it('extracts an ID/Requirement table, taking ids from the id column', () => { - const content = [ - '| ID | Requirement |', - '| ------- | ---------------------------------------------------------------------------------------- |', - '| 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. |', - ].join('\n'); - - const { items } = extractMarkdown(content, 'reqs.md'); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', - source: { file: 'reqs.md', line: 3 }, - }, - { - id: 'REQ-002', - text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', - source: { file: 'reqs.md', line: 4 }, - }, - ]); - }); - - it('extracts a single-column requirement table and lifts an inline id prefix', () => { - const content = [ - '| Requirement |', - '| --- |', - '| REQ-001: The billing service shall verify the HMAC signature. |', - '| The billing service shall reject invalid webhooks. |', - ].join('\n'); - - const { items } = extractMarkdown(content); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify the HMAC signature.', - source: { line: 3 }, - }, - { text: 'The billing service shall reject invalid webhooks.', source: { line: 4 } }, - ]); - }); - - it('ignores fenced code blocks entirely', () => { - const content = [ - '- REQ-001: The billing service shall verify the HMAC signature.', - '', - '```ears', - '- REQ-999: This bullet lives inside a code fence and must be ignored.', - '| ID | Requirement |', - '| -- | ----------- |', - '| REQ-998 | Also ignored. |', - '```', - '', - '~~~', - '- REQ-997: Ignored under a tilde fence.', - '~~~', - '', - '- REQ-002: The billing service shall reject invalid webhooks.', - ].join('\n'); - - const { items } = extractMarkdown(content); - - expect(items.map((item) => item.id)).toEqual(['REQ-001', 'REQ-002']); - expect(items[1].source).toEqual({ line: 14 }); - }); - - it('ignores prose, headings, and horizontal rules', () => { - const content = [ - '# Heading', - '', - 'Just a paragraph of prose.', - '', - '---', - '', - '- REQ-001: The billing service shall verify signatures.', - ].join('\n'); - - const { items } = extractMarkdown(content); - - expect(items).toHaveLength(1); - expect(items[0].id).toBe('REQ-001'); - }); - - it('parses the metadata prefix form in bullets, using the declared source reference', () => { - const content = [ - '- 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.', - ].join('\n'); - - const { items } = extractMarkdown(content, 'requirements.md'); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'When a payment webhook is received, the billing service shall verify the HMAC signature.', - source: { file: 'specs/checkout.md', line: 14 }, - }, - { - id: 'REQ-002', - text: 'If the HMAC signature is invalid, then the billing service shall reject the webhook.', - source: { file: 'specs/checkout.md', line: 12 }, - }, - ]); - }); - - it('parses the metadata prefix form inside a single-column table cell', () => { - const content = [ - '| Requirement |', - '| --- |', - '| REQ-001 [source: specs/checkout.md:14]: The billing service shall verify the HMAC signature. |', - ].join('\n'); - - const { items } = extractMarkdown(content, 'requirements.md'); - - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify the HMAC signature.', - source: { file: 'specs/checkout.md', line: 14 }, - }, - ]); - }); - - it('strips a malformed [source:] segment in a bullet and falls back to the physical location', () => { - const content = - '- REQ-003 [source: not-a-real-ref]: The billing service shall retain receipts.'; - - const { items } = extractMarkdown(content, 'requirements.md'); - - expect(items).toEqual([ - { - id: 'REQ-003', - text: 'The billing service shall retain receipts.', - source: { file: 'requirements.md', line: 1 }, - }, - ]); - }); - - it('returns no items for an empty document', () => { - expect(extractMarkdown('')).toEqual({ items: [], errors: [] }); - }); - - it('joins indented continuation lines of a list item', () => { - const content = [ - '- When a payment webhook is received, the billing service', - ' shall verify the HMAC signature.', - '- The billing service shall retain receipts.', - ].join('\n'); - const { items } = extractMarkdown(content, 'requirements.md'); - expect(items.map((item) => item.text)).toEqual([ - 'When a payment webhook is received, the billing service shall verify the HMAC signature.', - 'The billing service shall retain receipts.', - ]); - }); - - it('accepts a GFM table without leading or trailing pipes', () => { - const content = [ - 'ID | Requirement', - '--- | ---', - 'REQ-001 | The billing service shall verify the HMAC signature.', - ].join('\n'); - const { items } = extractMarkdown(content, 'requirements.md'); - expect(items).toEqual([ - { - id: 'REQ-001', - text: 'The billing service shall verify the HMAC signature.', - source: { file: 'requirements.md', line: 3 }, - }, - ]); - }); -}); diff --git a/packages/extract/src/markdown.ts b/packages/extract/src/markdown.ts deleted file mode 100644 index 4d7457f..0000000 --- a/packages/extract/src/markdown.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Markdown extractor (legacy, non-profile surface). - * - * Extracts requirements from three Markdown structures: - * - * - bullet lists (`-`, `*`, `+`), including indented continuation lines - * - numbered lists (`1.`, `1)`), including indented continuation lines - * - GFM pipe tables (`| ID | Requirement |` and single-column requirement - * tables), with or without leading and trailing pipes - * - * Everything else is ignored: prose paragraphs, headings, and fenced code blocks - * (both ` ``` ` and `~~~`, tracked by opening marker character and length). A - * metadata prefix inside a bullet, list item, or single-column table cell - * (`REQ-001:` or `REQ-001 [source: path:line]:`) is lifted into the item's `id`, - * and a `[source: ...]` reference becomes the item's source location. Tables with - * an explicit `ID` column take the id from that column instead. - * - * This is the non-profile extraction surface consumed by callers that want every - * list item and table row. The profile-driven host-native locator lives in - * `./locator.ts` and does not do table extraction (see `docs/input-formats.md`). - */ - -import type { RequirementInput } from '@earsyntax/core'; -import type { ExtractResult } from './types.js'; -import { splitId, type SourceRef } from './internal.js'; -import { classifyFences, splitTableRow } from './markdown-scan.js'; -import { stripBom } from './normalize.js'; - -const BULLET = /^(\s*)[-*+](\s+)(.+)$/; -const NUMBERED = /^(\s*)\d+[.)](\s+)(.+)$/; -const SEPARATOR_CELL = /^:?-{1,}:?$/; -const REQUIREMENT_HEADERS = new Set(['requirement', 'requirements', 'text', 'statement']); - -interface TableRow { - line: number; - cells: string[]; -} - -/** - * Extract requirements from Markdown content. - * - * @param rawContent Raw file contents. - * @param file Optional source path, echoed onto each item's source location. - */ -export function extractMarkdown(rawContent: string, file?: string): ExtractResult { - const items: RequirementInput[] = []; - const lines = stripBom(rawContent).split('\n'); - const fenceStates = classifyFences(lines); - - const push = ( - id: string | undefined, - text: string, - line: number, - ref: SourceRef | undefined, - ): void => { - const source = - ref === undefined - ? { ...(file === undefined ? {} : { file }), line } - : { file: ref.file, line: ref.line }; - items.push({ - ...(id === undefined || id === '' ? {} : { id }), - text, - source, - }); - }; - - let i = 0; - while (i < lines.length) { - const raw = lines[i]; - if (fenceStates[i] !== 'outside') { - // A fence delimiter or a fenced-code line: never a requirement. - i++; - continue; - } - - const trimmed = raw.trim(); - if (trimmed === '' || trimmed.startsWith('#')) { - i++; - continue; - } - - const bullet = BULLET.exec(raw); - if (bullet) { - const { text: itemText, next } = gatherListItem(lines, fenceStates, i, bullet[1].length); - const { id, ref, text } = splitId(itemText); - push(id, text, i + 1, ref); - i = next; - continue; - } - - const numbered = NUMBERED.exec(raw); - if (numbered) { - const { text: itemText, next } = gatherListItem(lines, fenceStates, i, numbered[1].length); - const { id, ref, text } = splitId(itemText); - push(id, text, i + 1, ref); - i = next; - continue; - } - - if (trimmed.includes('|')) { - const block: TableRow[] = []; - let j = i; - while (j < lines.length && fenceStates[j] === 'outside') { - const rowTrimmed = lines[j].trim(); - if (!rowTrimmed.includes('|') || rowTrimmed.startsWith('#') || rowTrimmed === '') { - break; - } - block.push({ line: j + 1, cells: splitTableRow(rowTrimmed) }); - j++; - } - if (block.length >= 2) { - extractTable(block, push); - i = j; - continue; - } - // Not a table (single pipe line): treat as prose and skip just this line. - i++; - continue; - } - - // Prose paragraph: ignored. - i++; - } - - return { items, errors: [] }; -} - -/** - * Gather a list item's text starting at `start`, absorbing indented continuation - * lines that are more indented than the marker and are not themselves a new list - * item, heading, blank line, or fenced-code delimiter. Continuation lines are - * joined to the first line with single spaces. - * - * @returns The joined item text and the index of the first unconsumed line. - */ -function gatherListItem( - lines: string[], - fenceStates: readonly string[], - start: number, - markerIndent: number, -): { text: string; next: number } { - const first = lines[start]; - const firstBody = (BULLET.exec(first) ?? NUMBERED.exec(first))?.[3] ?? first.trim(); - const parts = [firstBody.trim()]; - - let j = start + 1; - while (j < lines.length) { - const line = lines[j]; - if (line.trim() === '' || fenceStates[j] !== 'outside') { - break; - } - const indent = line.length - line.trimStart().length; - if (indent <= markerIndent) { - break; - } - if (BULLET.test(line) || NUMBERED.test(line) || line.trim().startsWith('#')) { - break; - } - parts.push(line.trim()); - j++; - } - - return { text: parts.join(' '), next: j }; -} - -/** - * Turn a contiguous block of pipe rows into requirements. A valid table needs a - * separator row (`| --- |`) with a header row directly above it; anything else - * is ignored. - */ -function extractTable( - block: TableRow[], - push: (id: string | undefined, text: string, line: number, ref: SourceRef | undefined) => void, -): void { - if (block.length < 2) { - return; - } - const separatorIndex = block.findIndex( - (row) => row.cells.length > 0 && row.cells.every((cell) => SEPARATOR_CELL.test(cell)), - ); - if (separatorIndex < 1) { - return; - } - - const header = block[separatorIndex - 1].cells.map((cell) => cell.toLowerCase()); - const idColumn = header.indexOf('id'); - let requirementColumn = header.findIndex((cell) => REQUIREMENT_HEADERS.has(cell)); - if (requirementColumn === -1) { - if (header.length === 1) { - requirementColumn = 0; - } else { - requirementColumn = idColumn === header.length - 1 ? header.length - 2 : header.length - 1; - } - } - - for (const row of block.slice(separatorIndex + 1)) { - const rawText = (row.cells[requirementColumn] ?? '').trim(); - if (idColumn >= 0) { - if (rawText === '') { - continue; - } - const id = (row.cells[idColumn] ?? '').trim(); - push(id, rawText, row.line, undefined); - } else { - const { id, ref, text } = splitId(rawText); - if (text === '') { - continue; - } - push(id, text, row.line, ref); - } - } -} From 6af836bf9a57f22eb7e8aaad78346e8e3774be5e Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 2 Aug 2026 18:48:23 +0300 Subject: [PATCH 20/20] remove --- .design-research/readme-examples/SOURCES.md | 32 - .../readme-examples/astro.README.md | 103 --- .../readme-examples/biome.README.md | 183 ---- .../readme-examples/dbt-core.README.md | 78 -- .../readme-examples/eslint.README.md | 366 -------- .../readme-examples/fiber.README.md | 873 ------------------ .../github-changelog-generator.README.md | 367 -------- .../readme-examples/httpie-cli.README.md | 107 --- .../readme-examples/playwright.README.md | 318 ------- .../readme-examples/typescript.README.md | 61 -- .../readme-examples/vite.README.md | 66 -- 11 files changed, 2554 deletions(-) delete mode 100644 .design-research/readme-examples/SOURCES.md delete mode 100644 .design-research/readme-examples/astro.README.md delete mode 100644 .design-research/readme-examples/biome.README.md delete mode 100644 .design-research/readme-examples/dbt-core.README.md delete mode 100644 .design-research/readme-examples/eslint.README.md delete mode 100644 .design-research/readme-examples/fiber.README.md delete mode 100644 .design-research/readme-examples/github-changelog-generator.README.md delete mode 100644 .design-research/readme-examples/httpie-cli.README.md delete mode 100644 .design-research/readme-examples/playwright.README.md delete mode 100644 .design-research/readme-examples/typescript.README.md delete mode 100644 .design-research/readme-examples/vite.README.md diff --git a/.design-research/readme-examples/SOURCES.md b/.design-research/readme-examples/SOURCES.md deleted file mode 100644 index 3cf6c89..0000000 --- a/.design-research/readme-examples/SOURCES.md +++ /dev/null @@ -1,32 +0,0 @@ -# README Example Sources - -These README files were copied for structural research while shaping the -`earsyntax` README. Use them to compare layout, sequencing, tone, command -examples, status boundaries, and project positioning. - -Do not copy prose verbatim into `earsyntax`. Reuse structure and documentation -patterns, and check each source repository's license before reusing any text. - -| Local file | Repository | Source README | License family | -| -------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------- | -| `astro.README.md` | `withastro/astro` | | MIT | -| `biome.README.md` | `biomejs/biome` | | MIT / Apache-2.0 | -| `dbt-core.README.md` | `dbt-labs/dbt-core` | | Apache-2.0 | -| `eslint.README.md` | `eslint/eslint` | | MIT | -| `fiber.README.md` | `gofiber/fiber` | | MIT | -| `github-changelog-generator.README.md` | `github-changelog-generator/github-changelog-generator` | | MIT | -| `httpie-cli.README.md` | `httpie/cli` | | BSD-3-Clause | -| `playwright.README.md` | `microsoft/playwright` | | Apache-2.0 | -| `typescript.README.md` | `microsoft/TypeScript` | | Apache-2.0 | -| `vite.README.md` | `vitejs/vite` | | MIT | - -## What To Compare - -- First screen: purpose, audience, and first useful command. -- Quickstart: how quickly a user reaches a working example. -- Expected output: whether the README shows what success looks like. -- CLI shape: how commands, flags, and configuration are summarized. -- Status boundaries: how alpha, experimental, or unsupported behavior is named. -- Routing: what stays in the README versus what moves to docs. -- Contribution/support links: how maintainers direct issue reports and community - usage. diff --git a/.design-research/readme-examples/astro.README.md b/.design-research/readme-examples/astro.README.md deleted file mode 100644 index 6a29188..0000000 --- a/.design-research/readme-examples/astro.README.md +++ /dev/null @@ -1,103 +0,0 @@ -![Build the web you want](.github/assets/banner.jpg 'Build the web you want') - -

-
- Astro is a website build tool for the modern web — -
- powerful developer experience meets lightweight output. -

-

- -
- -[![main](https://github.com/withastro/astro/actions/workflows/ci.yml/badge.svg)](https://github.com/withastro/astro/actions/workflows/ci.yml) -[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/withastro/astro/blob/main/LICENSE) -[![npm version](https://badge.fury.io/js/astro.svg)](https://badge.fury.io/js/astro) - -
- -## Install - -The **recommended** way to install the latest version of Astro is by running the command below: - -```bash -npm create astro@latest -``` - -You can also install Astro **manually** by running this command instead: - -```bash -npm install astro -``` - -Looking for help? Start with our [Getting Started](https://docs.astro.build/en/getting-started/) guide. - -Looking for quick examples? [Open a starter project](https://astro.new/) right in your browser. - -## Documentation - -Visit our [official documentation](https://docs.astro.build/). - -## Support - -Having trouble? Get help in the official [Astro Discord](https://astro.build/chat). - -## Contributing - -**New contributors welcome!** Check out our [Contributors Guide](CONTRIBUTING.md) for help getting started. - -Join us on [Discord](https://astro.build/chat) to meet other maintainers. We'll help you get your first contribution in no time! - -## Directory - -| Package | Release Notes | -| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [astro](packages/astro) | [![astro version](https://img.shields.io/npm/v/astro.svg?label=%20)](packages/astro/CHANGELOG.md) | -| [create-astro](packages/create-astro) | [![create-astro version](https://img.shields.io/npm/v/create-astro.svg?label=%20)](packages/create-astro/CHANGELOG.md) | -| [@astrojs/react](packages/integrations/react) | [![@astrojs/react version](https://img.shields.io/npm/v/@astrojs/react.svg?label=%20)](packages/integrations/react/CHANGELOG.md) | -| [@astrojs/preact](packages/integrations/preact) | [![@astrojs/preact version](https://img.shields.io/npm/v/@astrojs/preact.svg?label=%20)](packages/integrations/preact/CHANGELOG.md) | -| [@astrojs/solid-js](packages/integrations/solid) | [![@astrojs/solid version](https://img.shields.io/npm/v/@astrojs/solid-js.svg?label=%20)](packages/integrations/solid/CHANGELOG.md) | -| [@astrojs/svelte](packages/integrations/svelte) | [![@astrojs/svelte version](https://img.shields.io/npm/v/@astrojs/svelte.svg?label=%20)](packages/integrations/svelte/CHANGELOG.md) | -| [@astrojs/vue](packages/integrations/vue) | [![@astrojs/vue version](https://img.shields.io/npm/v/@astrojs/vue.svg?label=%20)](packages/integrations/vue/CHANGELOG.md) | -| [@astrojs/node](packages/integrations/node) | [![@astrojs/node version](https://img.shields.io/npm/v/@astrojs/node.svg?label=%20)](packages/integrations/node/CHANGELOG.md) | -| [@astrojs/vercel](packages/integrations/vercel) | [![@astrojs/vercel version](https://img.shields.io/npm/v/@astrojs/vercel.svg?label=%20)](packages/integrations/vercel/CHANGELOG.md) | -| [@astrojs/cloudflare](packages/integrations/cloudflare) | [![@astrojs/cloudflare version](https://img.shields.io/npm/v/@astrojs/cloudflare.svg?label=%20)](packages/integrations/cloudflare/CHANGELOG.md) | -| [@astrojs/partytown](packages/integrations/partytown) | [![@astrojs/partytown version](https://img.shields.io/npm/v/@astrojs/partytown.svg?label=%20)](packages/integrations/partytown/CHANGELOG.md) | -| [@astrojs/sitemap](packages/integrations/sitemap) | [![@astrojs/sitemap version](https://img.shields.io/npm/v/@astrojs/sitemap.svg?label=%20)](packages/integrations/sitemap/CHANGELOG.md) | -| [@astrojs/alpinejs](packages/integrations/alpinejs) | [![@astrojs/alpinejs version](https://img.shields.io/npm/v/@astrojs/alpinejs.svg?label=%20)](packages/integrations/alpinejs/CHANGELOG.md) | -| [@astrojs/mdx](packages/integrations/mdx) | [![@astrojs/mdx version](https://img.shields.io/npm/v/@astrojs/mdx.svg?label=%20)](packages/integrations/mdx/CHANGELOG.md) | -| [@astrojs/rss](packages/astro-rss) | [![@astrojs/rss version](https://img.shields.io/npm/v/@astrojs/rss.svg?label=%20)](packages/astro-rss/CHANGELOG.md) | -| [@astrojs/netlify](packages/integrations/netlify) | [![@astrojs/netlify version](https://img.shields.io/npm/v/@astrojs/netlify.svg?label=%20)](packages/integrations/netlify/CHANGELOG.md) | -| [@astrojs/check](packages/language-tools/astro-check) | [![astro-check version](https://img.shields.io/npm/v/@astrojs/check.svg?label=%20)](packages/language-tools/astro-check/CHANGELOG.md) | -| [@astrojs/language-server](packages/language-tools/language-server) | [![@astrojs/language-server version](https://img.shields.io/npm/v/@astrojs/language-server.svg?label=%20)](packages/language-tools/language-server/CHANGELOG.md) | -| [@astrojs/ts-plugin](packages/language-tools/ts-plugin) | [![@astrojs/ts-plugin version](https://img.shields.io/npm/v/@astrojs/ts-plugin.svg?label=%20)](packages/language-tools/ts-plugin/CHANGELOG.md) | -| [astro-vscode](packages/language-tools/vscode) | [![astro-vscode version](https://img.shields.io/npm/v/astro-vscode.svg?label=%20)](packages/language-tools/vscode/CHANGELOG.md) | - -[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/6178/badge)](https://bestpractices.coreinfrastructure.org/projects/6178) - -Several official projects are maintained outside of this repo: - -| Project | Repository | -| ---------------------------------------------------------- | ------------------------------------------------------------- | -| [@astrojs/compiler](https://github.com/withastro/compiler) | [withastro/compiler](https://github.com/withastro/compiler) | -| [Starlight](https://github.com/withastro/starlight) | [withastro/starlight](https://github.com/withastro/starlight) | - -## Links - -- [License (MIT)](LICENSE) -- [Code of Conduct](https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md) -- [Open Governance & Voting](https://github.com/withastro/.github/blob/main/GOVERNANCE.md) -- [Project Funding](https://github.com/withastro/.github/blob/main/FUNDING.md) -- [Website](https://astro.build/) - -## Sponsors - -Astro is free, open source software made possible by these wonderful sponsors. - -[❤️ Sponsor Astro! ❤️](https://github.com/withastro/.github/blob/main/FUNDING.md) - -

- - Sponsor logos including the current Astro Sponsors, Gold Sponsors, and Exclusive Partner Sponsors: Netlify, Sentry, and Project IDX. - -

diff --git a/.design-research/readme-examples/biome.README.md b/.design-research/readme-examples/biome.README.md deleted file mode 100644 index 7d8ec57..0000000 --- a/.design-research/readme-examples/biome.README.md +++ /dev/null @@ -1,183 +0,0 @@ -
- - - - Shows the banner of Biome, with its logo and the phrase 'Biome - Toolchain of the web'. - - -
-
- -[![CI on main][ci-badge]][ci-url] -[![Discord chat][discord-badge]][discord-url] -[![npm version][npm-badge]][npm-url] -[![VSCode version][vscode-badge]][vscode-url] -[![Open VSX version][open-vsx-badge]][open-vsx-url] - -[ci-badge]: https://github.com/biomejs/biome/actions/workflows/main.yml/badge.svg -[ci-url]: https://github.com/biomejs/biome/actions/workflows/main.yml -[discord-badge]: https://badgen.net/discord/online-members/BypW39g6Yc?icon=discord&label=discord&color=60a5fa -[discord-url]: https://biomejs.dev/chat -[npm-badge]: https://npmx.dev/api/registry/badge/version/@biomejs/biome?color=60a5fa&style=shieldsio&label=%40biomejs%2Fbiome -[npm-url]: https://npmx.dev/package/@biomejs/biome -[vscode-badge]: https://img.shields.io/visual-studio-marketplace/v/biomejs.biome?label=Visual%20Studio%20Marketplace&labelColor=374151&color=60a5fa -[vscode-url]: https://marketplace.visualstudio.com/items?itemName=biomejs.biome -[open-vsx-badge]: https://img.shields.io/visual-studio-marketplace/v/biomejs.biome?label=Open%20VSX%20Registry&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2aWV3Qm94PSI0LjYgNSA5Ni4yIDEyMi43IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Ik0zMCA0NC4yTDUyLjYgNUg3LjN6TTQuNiA4OC41aDQ1LjNMMjcuMiA0OS40em01MSAwbDIyLjYgMzkuMiAyMi42LTM5LjJ6IiBmaWxsPSIjYzE2MGVmIi8+CiAgPHBhdGggZD0iTTUyLjYgNUwzMCA0NC4yaDQ1LjJ6TTI3LjIgNDkuNGwyMi43IDM5LjEgMjIuNi0zOS4xem01MSAwTDU1LjYgODguNWg0NS4yeiIgZmlsbD0iI2E2MGVlNSIvPgo8L3N2Zz4=&labelColor=374151&color=60a5fa -[open-vsx-url]: https://open-vsx.org/extension/biomejs/biome - - - -[हिन्दी](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.hi.md) | English | [Español](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.es.md) | [Français](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.fr.md) | [繁體中文](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.zh-TW.md) | [简体中文](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.zh-CN.md) | [日本語](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.ja.md) | [Polski](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.pl.md) | [Português do Brasil](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.pt-BR.md) | [한국어](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.kr.md) | [Русский](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.ru.md) | [Українська](https://github.com/biomejs/biome/blob/main/packages/%40biomejs/biome/README.uk.md) -
- -
- -**Biome** is a performant toolchain for web projects, it aims to provide developer tools to maintain the health of said projects. - -**Biome is a [fast formatter](https://github.com/biomejs/benchmark#formatting)** for _JavaScript_, _TypeScript_, _JSX_, _JSON_, _CSS_ and _GraphQL_ that scores **[97% compatibility with _Prettier_](https://algora.io/challenges/prettier)**. - -**Biome is a [performant linter](https://github.com/biomejs/benchmark#linting)** for _JavaScript_, _TypeScript_, _JSX_, _JSON_, _CSS_, and _GraphQL_ that features **[more than 500 rules](https://biomejs.dev/linter/javascript/rules/)** from ESLint, typescript-eslint, and [other sources](https://github.com/biomejs/biome/discussions/3). -It **outputs detailed and contextualized diagnostics** that help you to improve your code and become a better programmer! - -**Biome** is designed from the start to be used [interactively within an editor](https://biomejs.dev/editors/first-party-extensions/). -It can format and lint malformed code as you are writing it. - -### Installation - -```shell -npm install --save-dev --save-exact @biomejs/biome -``` - -### Usage - -```shell -# format files -npx @biomejs/biome format --write - -# lint files and apply the safe fixes -npx @biomejs/biome lint --write - -# run format, lint, etc. and apply the safe fixes -npx @biomejs/biome check --write - -# check all files against format, lint, etc. in CI environments -npx @biomejs/biome ci -``` - -If you want to give Biome a run without installing it, use the [online playground](https://biomejs.dev/playground/), compiled to WebAssembly. - -## Documentation - -Check out our [homepage][biomejs] to learn more about Biome, -or directly head to the [Getting Started guide][getting-started] to start using Biome. - -## More about Biome - -**Biome** has sane defaults and it doesn't require configuration. - -**Biome** aims to support [all main languages][language-support] of modern web development. - -**Biome** [doesn't require Node.js](https://biomejs.dev/guides/manual-installation/) to function. - -**Biome** has first-class LSP support, with a sophisticated parser that represents the source text in full fidelity and top-notch error recovery. - -**Biome** wants to offer a high-quality _Developer Experience_, with descriptive diagnostics and great performance. - -**Biome** unifies functionalities that have previously been separate tools. Building upon a shared base allows us to provide a cohesive experience for processing code, displaying errors, parallelize work, caching, and configuration. - -Read more about our [project philosophy][biome-philosophy]. - -**Biome** is [MIT licensed](https://github.com/biomejs/biome/tree/main/LICENSE-MIT) or [Apache 2.0 licensed](https://github.com/biomejs/biome/tree/main/LICENSE-APACHE) and moderated under the [Contributor Covenant Code of Conduct](https://github.com/biomejs/biome/tree/main/CODE_OF_CONDUCT.md). - -## Funding - -You can fund the project in different ways - -### Project sponsorship and funding - -You can sponsor or fund the project via [Open collective](https://opencollective.com/biome) or [GitHub sponsors](https://github.com/sponsors/biomejs) - -Biome offers a simple sponsorship program that allows companies to get visibility and recognition among various developers. - -Biome offers [enterprise support](https://biomejs.dev/enterprise), where Core Contributors can be employed to work on company-focused projects. - -## Sponsors - -### Platinum Sponsors - - - - - - - -
- - - - - Depot logo - - -
- -### Silver Sponsors - - - - - - - - -
- Lokalise logo - - Latitude logo -
- -### Bronze Sponsors - - - - - - - - - - - - - - - - -
- Vital logo - - CodeRabbit logo - - Forge42 logo - - RStudio logo - - Pennylane logo - - JetBrains logo - - EGSTOCK, Inc. logo - - Convex logo - - Graphite logo - - Kraken Tech logo -
- -[biomejs]: https://biomejs.dev/ -[biome-philosophy]: https://biomejs.dev/internals/philosophy/ -[language-support]: https://biomejs.dev/internals/language-support/ -[getting-started]: https://biomejs.dev/guides/getting-started/ diff --git a/.design-research/readme-examples/dbt-core.README.md b/.design-research/readme-examples/dbt-core.README.md deleted file mode 100644 index 712dd1a..0000000 --- a/.design-research/readme-examples/dbt-core.README.md +++ /dev/null @@ -1,78 +0,0 @@ -

- dbt logo -

- -> [!WARNING] -> **dbt Core v1 development has moved to the [`1.latest`](https://github.com/dbt-labs/dbt-core/tree/1.latest) branch.** -> The `main` branch now hosts dbt Core v2.0 (alpha) — a ground-up rewrite in Rust that is the foundation of the Fusion engine. If you're looking for the Python implementation of dbt Core, switch to [`1.latest`](https://github.com/dbt-labs/dbt-core/tree/1.latest). - -**[dbt](https://www.getdbt.com/)** enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications. - -![architecture](https://raw.githubusercontent.com/dbt-labs/dbt-core/202cb7e51e218c7b29eb3b11ad058bd56b7739de/etc/dbt-transform.png) - -## About dbt Core v2.0 - -> 🚧 dbt Core v2.0 is in alpha. Behavior, APIs, and on-disk formats may change before the stable release. - -dbt Core v2.0 is engineered for performance at scale — parsing, compiling, and running projects in a fraction of the time compared to v1. It's released under the Apache 2.0 license and is the foundation of the [Fusion engine](https://docs.getdbt.com/docs/fusion/about-fusion). - -The big shifts from v1: - -- **Faster** — parse and compile times are dramatically improved, especially on the largest dbt projects. -- **Stricter** — a tightly-defined language specification enforces correctness at parse time. -- **More scalable artifacts** — v2.0 produces Parquet artifacts that can be easily queried, joined, and analyzed to understand your dbt project. The artifacts encompass everything in the JSON artifacts (e.g. `manifest.json`), which continue to be produced for backwards compatibility. -- **Easier to install** — distributed as a single self-contained binary, with no Python runtime or dependency management required. -- **A completely revamped local documentation experience** — dbt docs is now powered by those new artifacts and capable of scaling to large projects. - -### Supported operating systems and architectures - -dbt Core v2.0 and its drivers are compiled per operating system and architecture. - -Legend: - -- 🟢 — Supported today -- 🟡 — Not yet supported - -| Operating system | x86-64 | ARM | -| ---------------- | ------ | --- | -| macOS | 🟢 | 🟢 | -| Linux | 🟢 | 🟢 | -| Windows | 🟢 | 🟡 | - -## Understanding dbt - -Analysts using dbt can transform their data by simply writing select statements, while dbt handles turning these statements into tables and views in a data warehouse. - -These select statements, or "models", form a dbt project. Models frequently build on top of one another – dbt makes it easy to [manage relationships](https://docs.getdbt.com/docs/ref) between models, and [visualize these relationships](https://docs.getdbt.com/docs/documentation), as well as assure the quality of your transformations through [testing](https://docs.getdbt.com/docs/testing). - -![dbt dag](https://raw.githubusercontent.com/dbt-labs/dbt-core/6c6649f9129d5d108aa3b0526f634cd8f3a9d1ed/etc/dbt-dag.png) - -## Getting started - -Start by choosing a distribution. dbt Core is the baseline distribution of dbt. Fusion extends dbt Core with additional SQL comprehension abilities. Both distributions are free to install and can run locally. - -- **If you need an Apache 2.0 licensed tool** and the ability to review every line of code inside of it, [install dbt Core](https://docs.getdbt.com/docs/local/install-dbt#dbt-core). -- **If you need a free CLI you can use locally**, [install Fusion](https://docs.getdbt.com/docs/local/install-dbt#dbt-fusion-engine-recommended). It can do more than dbt Core out of the box and you can seamlessly enable other advanced features over time if you choose to. - -Regardless of the distribution you choose, each is part of a single framework with a single language specification, meaning your business logic is portable in both directions. - -Explore the [dbt platform](https://docs.getdbt.com/docs/cloud/about-cloud/dbt-cloud-features) for an enhanced collaboration experience. -Read the [introduction](https://docs.getdbt.com/docs/introduction/) and [viewpoint](https://docs.getdbt.com/docs/about/viewpoint/) - -## Join the dbt Community - -- Be part of the conversation in the [dbt Community Slack](http://community.getdbt.com/) -- Read more on the [dbt Community Discourse](https://discourse.getdbt.com) - -## Reporting bugs and contributing code - -- Want to report a bug or request a feature? Let us know and open [an issue](https://github.com/dbt-labs/dbt-core/issues/new/choose) -- Want to help us build dbt? Check out the [Contributing Guide](https://github.com/dbt-labs/dbt-core/blob/HEAD/CONTRIBUTING.md) - -## Code of Conduct - -Everyone interacting in the dbt project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [dbt Code of Conduct](https://docs.getdbt.com/community/resources/code-of-conduct). - -## License - -dbt Core is licensed under the [Apache License 2.0](LICENSE). diff --git a/.design-research/readme-examples/eslint.README.md b/.design-research/readme-examples/eslint.README.md deleted file mode 100644 index cae057e..0000000 --- a/.design-research/readme-examples/eslint.README.md +++ /dev/null @@ -1,366 +0,0 @@ -[![npm version](https://img.shields.io/npm/v/eslint.svg)](https://www.npmjs.com/package/eslint) -[![Downloads](https://img.shields.io/npm/dm/eslint.svg)](https://www.npmjs.com/package/eslint) -[![Build Status](https://github.com/eslint/eslint/workflows/CI/badge.svg)](https://github.com/eslint/eslint/actions) -
-[![Open Collective Backers](https://img.shields.io/opencollective/backers/eslint)](https://opencollective.com/eslint) -[![Open Collective Sponsors](https://img.shields.io/opencollective/sponsors/eslint)](https://opencollective.com/eslint) - -# ESLint - -[Website](https://eslint.org) | -[Configure ESLint](https://eslint.org/docs/latest/use/configure) | -[Rules](https://eslint.org/docs/rules/) | -[Contribute to ESLint](https://eslint.org/docs/latest/contribute) | -[Report Bugs](https://eslint.org/docs/latest/contribute/report-bugs) | -[Code of Conduct](https://eslint.org/conduct) | -[X](https://x.com/geteslint) | -[Discord](https://eslint.org/chat) | -[Mastodon](https://fosstodon.org/@eslint) | -[Bluesky](https://bsky.app/profile/eslint.org) - -ESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code. In many ways, it is similar to JSLint and JSHint with a few exceptions: - -- ESLint uses [Espree](https://github.com/eslint/js/tree/main/packages/espree) for JavaScript parsing. -- ESLint uses an AST to evaluate patterns in code. -- ESLint is completely pluggable, every single rule is a plugin and you can add more at runtime. - -## Table of Contents - -1. [Installation and Usage](#installation-and-usage) -1. [Configuration](#configuration) -1. [Version Support](#version-support) -1. [Code of Conduct](#code-of-conduct) -1. [Filing Issues](#filing-issues) -1. [Frequently Asked Questions](#frequently-asked-questions) -1. [Releases](#releases) -1. [Security Policy](#security-policy) -1. [Semantic Versioning Policy](#semantic-versioning-policy) -1. [ESM Dependencies](#esm-dependencies) -1. [License](#license) -1. [Team](#team) -1. [Sponsors](#sponsors) -1. [Technology Sponsors](#technology-sponsors) - -## Installation and Usage - -### Prerequisites - -To use ESLint, you must have [Node.js](https://nodejs.org/) (`^20.19.0`, `^22.13.0`, or `>=24`) installed and built with SSL and ICU support. (If you are using an official Node.js distribution, both SSL and ICU are always built in.) - -If you use ESLint's TypeScript type definitions, TypeScript 5.3 or later is required. - -### npm Installation - -You can install and configure ESLint using this command: - -```shell -npm init @eslint/config@latest -``` - -After that, you can run ESLint on any file or directory like this: - -```shell -npx eslint yourfile.js -``` - -### pnpm Installation - -To use ESLint with pnpm, we recommend setting up a `.npmrc` file with at least the following settings: - -```text -auto-install-peers=true -node-linker=hoisted -``` - -This ensures that pnpm installs dependencies in a way that is more compatible with npm and is less likely to produce errors. - -## Configuration - -You can configure rules in your `eslint.config.js` files as in this example: - -```js -import { defineConfig } from 'eslint/config'; - -export default defineConfig([ - { - files: ['**/*.js', '**/*.cjs', '**/*.mjs'], - rules: { - 'prefer-const': 'warn', - 'no-constant-binary-expression': 'error', - }, - }, -]); -``` - -The names `"prefer-const"` and `"no-constant-binary-expression"` are the names of [rules](https://eslint.org/docs/rules) in ESLint. The first value is the error level of the rule and can be one of these values: - -- `"off"` or `0` - turn the rule off -- `"warn"` or `1` - turn the rule on as a warning (doesn't affect exit code) -- `"error"` or `2` - turn the rule on as an error (exit code will be 1) - -The three error levels allow you fine-grained control over how ESLint applies rules (for more configuration options and details, see the [configuration docs](https://eslint.org/docs/latest/use/configure)). - -## Version Support - -The ESLint team provides ongoing support for the current version and six months of limited support for the previous version. Limited support includes critical bug fixes, security issues, and compatibility issues only. - -ESLint offers commercial support for both current and previous versions through our partners, [Tidelift][tidelift] and [HeroDevs][herodevs]. - -See [Version Support](https://eslint.org/version-support) for more details. - -## Code of Conduct - -ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). - -## Filing Issues - -Before filing an issue, please be sure to read the guidelines for what you're reporting: - -- [Bug Report](https://eslint.org/docs/latest/contribute/report-bugs) -- [Propose a New Rule](https://eslint.org/docs/latest/contribute/propose-new-rule) -- [Proposing a Rule Change](https://eslint.org/docs/latest/contribute/propose-rule-change) -- [Request a Change](https://eslint.org/docs/latest/contribute/request-change) - -## Frequently Asked Questions - -### Does ESLint support JSX? - -Yes, ESLint natively supports parsing JSX syntax (this must be enabled in [configuration](https://eslint.org/docs/latest/use/configure)). Please note that supporting JSX syntax _is not_ the same as supporting React. React applies specific semantics to JSX syntax that ESLint doesn't recognize. We recommend using [eslint-plugin-react](https://www.npmjs.com/package/eslint-plugin-react) if you are using React and want React semantics. - -### Does Prettier replace ESLint? - -No, ESLint and Prettier have different jobs: ESLint is a linter (looking for problematic patterns) and Prettier is a code formatter. Using both tools is common, refer to [Prettier's documentation](https://prettier.io/docs/en/install#eslint-and-other-linters) to learn how to configure them to work well with each other. - -### What ECMAScript versions does ESLint support? - -ESLint has full support for ECMAScript 3, 5, and every year from 2015 up until the most recent stage 4 specification (the default). You can set your desired ECMAScript syntax and other settings (like global variables) through [configuration](https://eslint.org/docs/latest/use/configure). - -### What about experimental features? - -ESLint's parser only officially supports the latest final ECMAScript standard. We will make changes to core rules in order to avoid crashes on stage 3 ECMAScript syntax proposals (as long as they are implemented using the correct experimental ESTree syntax). We may make changes to core rules to better work with language extensions (such as JSX, Flow, and TypeScript) on a case-by-case basis. - -In other cases (including if rules need to warn on more or fewer cases due to new syntax, rather than just not crashing), we recommend you use other parsers and/or rule plugins. If you are using Babel, you can use [@babel/eslint-parser](https://www.npmjs.com/package/@babel/eslint-parser) and [@babel/eslint-plugin](https://www.npmjs.com/package/@babel/eslint-plugin) to use any option available in Babel. - -Once a language feature has been adopted into the ECMAScript standard (stage 4 according to the [TC39 process](https://tc39.github.io/process-document/)), we will accept issues and pull requests related to the new feature, subject to our [contributing guidelines](https://eslint.org/docs/latest/contribute). Until then, please use the appropriate parser and plugin(s) for your experimental feature. - -### Which Node.js versions does ESLint support? - -ESLint updates the supported Node.js versions with each major release of ESLint. At that time, ESLint's supported Node.js versions are updated to be: - -1. The most recent maintenance release of Node.js -1. The lowest minor version of the Node.js LTS release that includes the features the ESLint team wants to use. -1. The Node.js Current release - -ESLint is also expected to work with Node.js versions released after the Node.js Current release. - -Refer to the [Quick Start Guide](https://eslint.org/docs/latest/use/getting-started#prerequisites) for the officially supported Node.js versions for a given ESLint release. - -### Where to ask for help? - -Open a [discussion](https://github.com/eslint/eslint/discussions) or stop by our [Discord server](https://eslint.org/chat). - -### Why doesn't ESLint lock dependency versions? - -Lock files like `package-lock.json` are helpful for deployed applications. They ensure that dependencies are consistent between environments and across deployments. - -Packages like `eslint` that get published to the npm registry do not include lock files. `npm install eslint` as a user will respect version constraints in ESLint's `package.json`. ESLint and its dependencies will be included in the user's lock file if one exists, but ESLint's own lock file would not be used. - -We intentionally don't lock dependency versions so that we have the latest compatible dependency versions in development and CI that our users get when installing ESLint in a project. - -The Twilio blog has a [deeper dive](https://www.twilio.com/blog/lockfiles-nodejs) to learn more. - -## Releases - -We have scheduled releases every two weeks on Friday or Saturday. You can follow a [release issue](https://github.com/eslint/eslint/issues?q=is%3Aopen+is%3Aissue+label%3Arelease) for updates about the scheduling of any particular release. - -## Security Policy - -ESLint takes security seriously. We work hard to ensure that ESLint is safe for everyone and that security issues are addressed quickly and responsibly. Read the full [security policy](https://github.com/eslint/.github/blob/master/SECURITY.md). - -## Semantic Versioning Policy - -ESLint follows [semantic versioning](https://semver.org). However, due to the nature of ESLint as a code quality tool, it's not always clear when a minor or major version bump occurs. To help clarify this for everyone, we've defined the following semantic versioning policy for ESLint: - -- Patch release (intended to not break your lint build) - - A bug fix in a rule that results in ESLint reporting fewer linting errors. - - A bug fix to the CLI or core (including formatters). - - Improvements to documentation. - - Non-user-facing changes such as refactoring code, adding, deleting, or modifying tests, and increasing test coverage. - - Re-releasing after a failed release (i.e., publishing a release that doesn't work for anyone). -- Minor release (might break your lint build) - - A bug fix that results in ESLint reporting more linting errors (e.g., fixing false negatives in a core rule, or linting additional files that were previously incorrectly skipped). - - A new rule is created. - - A new option to an existing rule that does not result in ESLint reporting more linting errors by default. - - A new addition to an existing rule to support a newly-added language feature (within the last 12 months) that will result in ESLint reporting more linting errors by default. - - An existing rule is deprecated. - - A new CLI capability is created. - - New capabilities to the public API are added (new classes, new methods, new arguments to existing methods, etc.). - - A new formatter is created. - - `eslint:recommended` is updated and will result in strictly fewer linting errors (e.g., rule removals). -- Major release (likely to break your lint build) - - `eslint:recommended` is updated and may result in new linting errors (e.g., rule additions, most rule option updates). - - A new option to an existing rule that results in ESLint reporting more linting errors by default. - - An existing formatter is removed. - - Part of the public API is removed or changed in an incompatible way. The public API includes: - - Rule schemas - - Configuration schema - - Command-line options - - Node.js API - - Rule, formatter, parser, plugin APIs - -According to our policy, any minor update may report more linting errors than the previous release (ex: from a bug fix). As such, we recommend using the tilde (`~`) in `package.json` e.g. `"eslint": "~3.1.0"` to guarantee the results of your builds. - -## ESM Dependencies - -Since ESLint is a CommonJS package, there are restrictions on which ESM-only packages can be used as dependencies. - -Packages that are controlled by the ESLint team and have no external dependencies can be safely loaded synchronously using [`require(esm)`](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require) and therefore used in any contexts. - -For external packages, we don't use `require(esm)` because a package could add a top-level `await` and thus break ESLint. We can use an external ESM-only package only in case it is needed only in asynchronous code, in which case it can be loaded using dynamic `import()`. - -These policies don't apply to packages intended for our own usage only, such as `eslint-config-eslint`. - -## License - -MIT License - -Copyright OpenJS Foundation and other contributors, - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -## Team - -These folks keep the project moving and are resources for help. - - - - - -### Technical Steering Committee (TSC) - -The people who manage releases, review feature requests, and meet regularly to ensure ESLint is properly maintained. - -
- -Nicholas C. Zakas's Avatar
-Nicholas C. Zakas -
-
- -Francesco Trotta's Avatar
-Francesco Trotta -
-
- -Milos Djermanovic's Avatar
-Milos Djermanovic -
-
- -### Reviewers - -The people who review and implement new features. - -
- -唯然's Avatar
-唯然 -
-
- -Nitin Kumar's Avatar
-Nitin Kumar -
-
- -### Committers - -The people who review and fix bugs and help triage issues. - -
- -fnx's Avatar
-fnx -
-
- -Sweta Tanwar's Avatar
-Sweta Tanwar -
-
- -Tanuj Kanti's Avatar
-Tanuj Kanti -
-
- -lumir's Avatar
-lumir -
-
- -Pixel's Avatar
-Pixel -
-
- -### Website Team - -Team members who focus specifically on eslint.org - -
- -Amaresh  S M's Avatar
-Amaresh S M -
-
- -Harish's Avatar
-Harish -
-
- -Percy Ma's Avatar
-Percy Ma -
-
- - - - - - -## Sponsors - -The following companies, organizations, and individuals support ESLint's ongoing maintenance and development. [Become a Sponsor](https://eslint.org/donate) -to get your logo on our READMEs and [website](https://eslint.org/sponsors). - -

Platinum Sponsors

-

Automattic

Gold Sponsors

-

Qlty Software Shopify CodeRabbit

Silver Sponsors

-

Vite Liftoff StackBlitz

Bronze Sponsors

-

Cybozu SAP apuesdeportivas.es Icons8 Discord GitBook Citadel AI

-

Technology Sponsors

-Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work. -

Netlify Algolia 1Password

- - - -[tidelift]: https://tidelift.com/funding/github/npm/eslint -[herodevs]: https://www.herodevs.com/support/eslint-nes?utm_source=ESLintWebsite&utm_medium=ESLintWebsite&utm_campaign=ESLintNES&utm_id=ESLintNES diff --git a/.design-research/readme-examples/fiber.README.md b/.design-research/readme-examples/fiber.README.md deleted file mode 100644 index ed33477..0000000 --- a/.design-research/readme-examples/fiber.README.md +++ /dev/null @@ -1,873 +0,0 @@ -

- - - - Fiber - - -
- - - - - - - - Codecov - - - - - - - - - - - - - - - - -

- -

- Fiber is an Express inspired web framework built on top of Fasthttp, the fastest HTTP engine for Go. Designed to ease things up for fast development with zero memory allocation and performance in mind. -

- ---- - - -

- - - - Official Sponsors - - -

-

- - - - CodeRabbit - - -

-

- - Blacksmith - -

- ---- - - - -## ⚙️ Installation - -Fiber requires **Go version `1.25` or higher** to run. If you need to install or upgrade Go, visit the [official Go download page](https://go.dev/dl/). To start setting up your project, create a new directory for your project and navigate into it. Then, initialize your project with Go modules by executing the following command in your terminal: - -```bash -go mod init github.com/your/repo -``` - -To learn more about Go modules and how they work, you can check out the [Using Go Modules](https://go.dev/blog/using-go-modules) blog post. - -After setting up your project, you can install Fiber with the `go get` command: - -```bash -go get -u github.com/gofiber/fiber/v3 -``` - -This command fetches the Fiber package and adds it to your project's dependencies, allowing you to start building your web applications with Fiber. - -## ⚡️ Quickstart - -Getting started with Fiber is easy. Here's a basic example to create a simple web server that responds with "Hello, World 👋!" on the root path. This example demonstrates initializing a new Fiber app, setting up a route, and starting the server. - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - // Initialize a new Fiber app - app := fiber.New() - - // Define a route for the GET method on the root path '/' - app.Get("/", func(c fiber.Ctx) error { - // Send a string response to the client - return c.SendString("Hello, World 👋!") - }) - - // Start the server on port 3000 - log.Fatal(app.Listen(":3000")) -} -``` - -This simple server is easy to set up and run. It introduces the core concepts of Fiber: app initialization, route definition, and starting the server. Just run this Go program, and visit `http://localhost:3000` in your browser to see the message. - -## Zero Allocation - -Fiber is optimized for **high-performance**, meaning values returned from **fiber.Ctx** are **not** immutable by default and **will** be reused across requests. As a rule of thumb, you **must** only use context values within the handler and **must not** keep any references. Once you return from the handler, any values obtained from the context will be reused in future requests. Visit our [documentation](https://docs.gofiber.io/#zero-allocation) to learn more. - -## 🤖 Benchmarks - -These tests are performed by [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r19&hw=ph&test=plaintext). If you want to see all the results, please visit our [Wiki](https://docs.gofiber.io/extra/benchmarks). - -

- - -

- -## 🎯 Features - -- Robust [Routing](https://docs.gofiber.io/guide/routing) -- Serve [Static Files](https://docs.gofiber.io/api/app#static) -- Extreme [Performance](https://docs.gofiber.io/extra/benchmarks) -- [Low Memory](https://docs.gofiber.io/extra/benchmarks) footprint -- [API Endpoints](https://docs.gofiber.io/api/ctx) -- [Middleware](https://docs.gofiber.io/category/-middleware) & [Next](https://docs.gofiber.io/api/ctx#next) support -- [Rapid](https://dev.to/koddr/welcome-to-fiber-an-express-js-styled-fastest-web-framework-written-with-on-golang-497) server-side programming -- [Template Engines](https://github.com/gofiber/template) -- [WebSocket Support](https://github.com/gofiber/contrib/tree/main/websocket) -- [Socket.io Support](https://github.com/gofiber/contrib/tree/main/socketio) -- [Server-Sent Events](https://github.com/gofiber/recipes/tree/master/sse) -- [Rate Limiter](https://docs.gofiber.io/api/middleware/limiter) -- And much more, [explore Fiber](https://docs.gofiber.io/) - -## 💡 Philosophy - -New gophers that make the switch from [Node.js](https://nodejs.org/en/about/) to [Go](https://go.dev/doc/) are dealing with a learning curve before they can start building their web applications or microservices. Fiber, as a **web framework**, was created with the idea of **minimalism** and follows the **UNIX way**, so that new gophers can quickly enter the world of Go with a warm and trusted welcome. - -Fiber is **inspired** by Express, the most popular web framework on the Internet. We combined the **ease** of Express and **raw performance** of Go. If you have ever implemented a web application in Node.js (_using Express or similar_), then many methods and principles will seem **very common** to you. - -We **listen** to our users in [issues](https://github.com/gofiber/fiber/issues), Discord [channel](https://gofiber.io/discord) _and all over the Internet_ to create a **fast**, **flexible** and **friendly** Go web framework for **any** task, **deadline** and developer **skill**! Just like Express does in the JavaScript world. - -## ⚠️ Limitations - -- Due to Fiber's usage of unsafe, the library may not always be compatible with the latest Go version. Fiber v3 has been tested with Go version 1.25 or higher. -- Fiber automatically adapts common `net/http` handler shapes when you register them on the router, and you can still use the [adaptor middleware](https://docs.gofiber.io/next/middleware/adaptor/) when you need to bridge entire apps or `net/http` middleware. - -### net/http compatibility - -Fiber can run side by side with the standard library. The router accepts existing `net/http` handlers directly and even works with native `fasthttp.RequestHandler` callbacks, so you can plug in legacy endpoints without wrapping them manually: - -```go -package main - -import ( - "log" - "net/http" - - "github.com/gofiber/fiber/v3" -) - -func main() { - httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if _, err := w.Write([]byte("served by net/http")); err != nil { - panic(err) - } - }) - - app := fiber.New() - app.Get("/", httpHandler) - - // Start the server on port 3000 - log.Fatal(app.Listen(":3000")) -} -``` - -When you need to convert entire applications or reuse `net/http` middleware chains, rely on the [adaptor middleware](https://docs.gofiber.io/next/middleware/adaptor/). It converts handlers and middlewares in both directions and even lets you mount a Fiber app in a `net/http` server. - -### Express-style handlers - -Fiber also adapts Express-style callbacks that operate on the lightweight `fiber.Req` and `fiber.Res` helper interfaces. This lets you port middleware and route handlers from Express-inspired codebases while keeping Fiber's router features: - -```go -// Request/response handlers (2-argument) -app.Get("/", func(req fiber.Req, res fiber.Res) error { - return res.SendString("Hello from Express-style handlers!") -}) - -// Middleware with an error-returning next callback (3-argument) -app.Use(func(req fiber.Req, res fiber.Res, next func() error) error { - if req.IP() == "192.168.1.254" { - return res.SendStatus(fiber.StatusForbidden) - } - return next() -}) - -// Middleware with a no-arg next callback (3-argument) -app.Use(func(req fiber.Req, res fiber.Res, next func()) { - if req.Get("X-Skip") == "true" { - return // stop the chain without calling next - } - next() -}) -``` - -> **Note:** Adapted `net/http` handlers continue to operate with the standard-library semantics. They don't get access to `fiber.Ctx` features and incur the overhead of the compatibility layer, so native `fiber.Handler` callbacks still provide the best performance. - -## 👀 Examples - -Listed below are some of the common examples. If you want to see more code examples, please visit our [Recipes repository](https://github.com/gofiber/recipes) or visit our hosted [API documentation](https://docs.gofiber.io). - -### 📖 [**Basic Routing**](https://docs.gofiber.io/#basic-routing) - -```go title="Example" -package main - -import ( - "fmt" - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - app := fiber.New() - - // GET /api/register - app.Get("/api/*", func(c fiber.Ctx) error { - msg := fmt.Sprintf("✋ %s", c.Params("*")) - return c.SendString(msg) // => ✋ register - }) - - // GET /flights/LAX-SFO - app.Get("/flights/:from-:to", func(c fiber.Ctx) error { - msg := fmt.Sprintf("💸 From: %s, To: %s", c.Params("from"), c.Params("to")) - return c.SendString(msg) // => 💸 From: LAX, To: SFO - }) - - // GET /dictionary.txt - app.Get("/:file.:ext", func(c fiber.Ctx) error { - msg := fmt.Sprintf("📃 %s.%s", c.Params("file"), c.Params("ext")) - return c.SendString(msg) // => 📃 dictionary.txt - }) - - // GET /john/75 - app.Get("/:name/:age/:gender?", func(c fiber.Ctx) error { - msg := fmt.Sprintf("👴 %s is %s years old", c.Params("name"), c.Params("age")) - return c.SendString(msg) // => 👴 john is 75 years old - }) - - // GET /john - app.Get("/:name", func(c fiber.Ctx) error { - msg := fmt.Sprintf("Hello, %s 👋!", c.Params("name")) - return c.SendString(msg) // => Hello john 👋! - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -#### 📖 [**Route Naming**](https://docs.gofiber.io/api/app#name) - -```go title="Example" -package main - -import ( - "encoding/json" - "fmt" - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - app := fiber.New() - - app.Get("/api/*", func(c fiber.Ctx) error { - msg := fmt.Sprintf("✋ %s", c.Params("*")) - return c.SendString(msg) // => ✋ register - }).Name("api") - - route := app.GetRoute("api") - - data, _ := json.MarshalIndent(route, "", " ") - fmt.Println(string(data)) - // Prints: - // { - // "method": "GET", - // "name": "api", - // "path": "/api/*", - // "params": [ - // "*1" - // ] - // } - - log.Fatal(app.Listen(":3000")) -} -``` - -#### 📖 [**Serving Static Files**](https://docs.gofiber.io/api/app#static) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/static" -) - -func main() { - app := fiber.New() - - // Serve static files from the "./public" directory - app.Get("/*", static.New("./public")) - // => http://localhost:3000/js/script.js - // => http://localhost:3000/css/style.css - - app.Get("/prefix*", static.New("./public")) - // => http://localhost:3000/prefix/js/script.js - // => http://localhost:3000/prefix/css/style.css - - // Serve a single file for any unmatched routes - app.Get("*", static.New("./public/index.html")) - // => http://localhost:3000/any/path/shows/index.html - - log.Fatal(app.Listen(":3000")) -} -``` - -#### 📖 [**Middleware & Next**](https://docs.gofiber.io/api/ctx#next) - -```go title="Example" -package main - -import ( - "fmt" - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - app := fiber.New() - - // Middleware that matches any route - app.Use(func(c fiber.Ctx) error { - fmt.Println("🥇 First handler") - return c.Next() - }) - - // Middleware that matches all routes starting with /api - app.Use("/api", func(c fiber.Ctx) error { - fmt.Println("🥈 Second handler") - return c.Next() - }) - - // GET /api/list - app.Get("/api/list", func(c fiber.Ctx) error { - fmt.Println("🥉 Last handler") - return c.SendString("Hello, World 👋!") - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -
- 📚 Show more code examples - -### Views Engines - -📖 [Config](https://docs.gofiber.io/api/fiber#config) -📖 [Engines](https://github.com/gofiber/template) -📖 [Render](https://docs.gofiber.io/api/ctx#render) - -Fiber defaults to the [html/template](https://pkg.go.dev/html/template/) when no view engine is set. - -If you want to execute partials or use a different engine like [amber](https://github.com/eknkc/amber), [handlebars](https://github.com/aymerick/raymond), [mustache](https://github.com/cbroglie/mustache), or [pug](https://github.com/Joker/jade), etc., check out our [Template](https://github.com/gofiber/template) package that supports multiple view engines. - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/template/pug" -) - -func main() { - // Initialize a new Fiber app with Pug template engine - app := fiber.New(fiber.Config{ - Views: pug.New("./views", ".pug"), - }) - - // Define a route that renders the "home.pug" template - app.Get("/", func(c fiber.Ctx) error { - return c.Render("home", fiber.Map{ - "title": "Homepage", - "year": 1999, - }) - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### Grouping Routes into Chains - -📖 [Group](https://docs.gofiber.io/api/app#group) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" -) - -func middleware(c fiber.Ctx) error { - log.Println("Middleware executed") - return c.Next() -} - -func handler(c fiber.Ctx) error { - return c.SendString("Handler response") -} - -func main() { - app := fiber.New() - - // Root API group with middleware - api := app.Group("/api", middleware) // /api - - // API v1 routes - v1 := api.Group("/v1", middleware) // /api/v1 - v1.Get("/list", handler) // /api/v1/list - v1.Get("/user", handler) // /api/v1/user - - // API v2 routes - v2 := api.Group("/v2", middleware) // /api/v2 - v2.Get("/list", handler) // /api/v2/list - v2.Get("/user", handler) // /api/v2/user - - log.Fatal(app.Listen(":3000")) -} -``` - -### Middleware Logger - -📖 [Logger](https://docs.gofiber.io/api/middleware/logger) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/logger" -) - -func main() { - app := fiber.New() - - // Use Logger middleware - app.Use(logger.New()) - - // Define routes - app.Get("/", func(c fiber.Ctx) error { - return c.SendString("Hello, Logger!") - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### Cross-Origin Resource Sharing (CORS) - -📖 [CORS](https://docs.gofiber.io/api/middleware/cors) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/cors" -) - -func main() { - app := fiber.New() - - // Use CORS middleware with default settings - app.Use(cors.New()) - - // Define routes - app.Get("/", func(c fiber.Ctx) error { - return c.SendString("CORS enabled!") - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -Check CORS by passing any domain in `Origin` header: - -```bash -curl -H "Origin: http://example.com" --verbose http://localhost:3000 -``` - -### Custom 404 Response - -📖 [HTTP Methods](https://docs.gofiber.io/api/ctx#status) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - app := fiber.New() - - // Define routes - app.Get("/", static.New("./public")) - - app.Get("/demo", func(c fiber.Ctx) error { - return c.SendString("This is a demo page!") - }) - - app.Post("/register", func(c fiber.Ctx) error { - return c.SendString("Registration successful!") - }) - - // Middleware to handle 404 Not Found - app.Use(func(c fiber.Ctx) error { - return c.SendStatus(fiber.StatusNotFound) // => 404 "Not Found" - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### JSON Response - -📖 [JSON](https://docs.gofiber.io/api/ctx#json) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" -) - -type User struct { - Name string `json:"name"` - Age int `json:"age"` -} - -func main() { - app := fiber.New() - - // Route that returns a JSON object - app.Get("/user", func(c fiber.Ctx) error { - return c.JSON(&User{"John", 20}) - // => {"name":"John", "age":20} - }) - - // Route that returns a JSON map - app.Get("/json", func(c fiber.Ctx) error { - return c.JSON(fiber.Map{ - "success": true, - "message": "Hi John!", - }) - // => {"success":true, "message":"Hi John!"} - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### WebSocket Upgrade - -📖 [Websocket](https://github.com/gofiber/websocket) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/websocket" -) - -func main() { - app := fiber.New() - - // WebSocket route - app.Get("/ws", websocket.New(func(c *websocket.Conn) { - defer c.Close() - for { - // Read message from client - mt, msg, err := c.ReadMessage() - if err != nil { - log.Println("read:", err) - break - } - log.Printf("recv: %s", msg) - - // Write message back to client - err = c.WriteMessage(mt, msg) - if err != nil { - log.Println("write:", err) - break - } - } - })) - - log.Fatal(app.Listen(":3000")) - // Connect via WebSocket at ws://localhost:3000/ws -} -``` - -### Server-Sent Events - -📖 [More Info](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) - -```go title="Example" -package main - -import ( - "bufio" - "fmt" - "log" - "time" - - "github.com/gofiber/fiber/v3" - "github.com/valyala/fasthttp" -) - -func main() { - app := fiber.New() - - // Server-Sent Events route - app.Get("/sse", func(c fiber.Ctx) error { - c.Set("Content-Type", "text/event-stream") - c.Set("Cache-Control", "no-cache") - c.Set("Connection", "keep-alive") - c.Set("Transfer-Encoding", "chunked") - - c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { - var i int - for { - i++ - msg := fmt.Sprintf("%d - the time is %v", i, time.Now()) - fmt.Fprintf(w, "data: Message: %s\n\n", msg) - fmt.Println(msg) - - w.Flush() - time.Sleep(5 * time.Second) - } - }) - - return nil - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### Recover Middleware - -📖 [Recover](https://docs.gofiber.io/api/middleware/recover) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/recover" -) - -func main() { - app := fiber.New() - - // Use Recover middleware to handle panics gracefully - app.Use(recover.New()) - - // Route that intentionally panics - app.Get("/", func(c fiber.Ctx) error { - panic("normally this would crash your app") - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -### Using Trusted Proxy - -📖 [Config](https://docs.gofiber.io/api/fiber#config) - -```go title="Example" -package main - -import ( - "log" - - "github.com/gofiber/fiber/v3" -) - -func main() { - app := fiber.New(fiber.Config{ - // Configure trusted proxies - WARNING: Only trust proxies you control - // Using TrustProxy: true with unrestricted IPs can lead to IP spoofing - TrustProxy: true, - TrustProxyConfig: fiber.TrustProxyConfig{ - Proxies: []string{"10.0.0.0/8", "172.16.0.0/12"}, // Example: Internal network ranges only - }, - ProxyHeader: fiber.HeaderXForwardedFor, - }) - - // Define routes - app.Get("/", func(c fiber.Ctx) error { - return c.SendString("Trusted Proxy Configured!") - }) - - log.Fatal(app.Listen(":3000")) -} -``` - -
- -## 🧬 Internal Middleware - -Here is a list of middleware that are included within the Fiber framework. - -| Middleware | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| [adaptor](https://github.com/gofiber/fiber/tree/main/middleware/adaptor) | Converter for net/http handlers to/from Fiber request handlers. | -| [basicauth](https://github.com/gofiber/fiber/tree/main/middleware/basicauth) | Provides HTTP basic authentication. It calls the next handler for valid credentials and 401 Unauthorized for missing or invalid credentials. | -| [cache](https://github.com/gofiber/fiber/tree/main/middleware/cache) | Intercept and cache HTTP responses. | -| [compress](https://github.com/gofiber/fiber/tree/main/middleware/compress) | Compression middleware for Fiber, with support for `deflate`, `gzip`, `brotli` and `zstd`. | -| [cors](https://github.com/gofiber/fiber/tree/main/middleware/cors) | Enable cross-origin resource sharing (CORS) with various options. | -| [csrf](https://github.com/gofiber/fiber/tree/main/middleware/csrf) | Protect from CSRF exploits. | -| [earlydata](https://github.com/gofiber/fiber/tree/main/middleware/earlydata) | Adds support for TLS 1.3's early data ("0-RTT") feature. | -| [encryptcookie](https://github.com/gofiber/fiber/tree/main/middleware/encryptcookie) | Encrypt middleware which encrypts cookie values. | -| [envvar](https://github.com/gofiber/fiber/tree/main/middleware/envvar) | Expose environment variables with providing an optional config. | -| [etag](https://github.com/gofiber/fiber/tree/main/middleware/etag) | Allows for caches to be more efficient and save bandwidth, as a web server does not need to resend a full response if the content has not changed. | -| [expvar](https://github.com/gofiber/fiber/tree/main/middleware/expvar) | Serves via its HTTP server runtime exposed variables in the JSON format. | -| [favicon](https://github.com/gofiber/fiber/tree/main/middleware/favicon) | Ignore favicon from logs or serve from memory if a file path is provided. | -| [healthcheck](https://github.com/gofiber/fiber/tree/main/middleware/healthcheck) | Liveness and Readiness probes for Fiber. | -| [helmet](https://github.com/gofiber/fiber/tree/main/middleware/helmet) | Helps secure your apps by setting various HTTP headers. | -| [hostauthorization](https://github.com/gofiber/fiber/tree/main/middleware/hostauthorization) | Validates the Host header against a configurable allowlist, protecting against DNS rebinding attacks. | -| [idempotency](https://github.com/gofiber/fiber/tree/main/middleware/idempotency) | Allows for fault-tolerant APIs where duplicate requests do not erroneously cause the same action performed multiple times on the server-side. | -| [keyauth](https://github.com/gofiber/fiber/tree/main/middleware/keyauth) | Adds support for key based authentication. | -| [limiter](https://github.com/gofiber/fiber/tree/main/middleware/limiter) | Adds Rate-limiting support to Fiber. Use to limit repeated requests to public APIs and/or endpoints such as password reset. | -| [logger](https://github.com/gofiber/fiber/tree/main/middleware/logger) | HTTP request/response logger. | -| [paginate](https://github.com/gofiber/fiber/tree/main/middleware/paginate) | Extracts pagination parameters from query strings. Supports page-based, offset-based, and cursor-based pagination with multi-field sorting. | -| [pprof](https://github.com/gofiber/fiber/tree/main/middleware/pprof) | Serves runtime profiling data in pprof format. | -| [proxy](https://github.com/gofiber/fiber/tree/main/middleware/proxy) | Allows you to proxy requests to multiple servers. | -| [recover](https://github.com/gofiber/fiber/tree/main/middleware/recover) | Recovers from panics anywhere in the stack chain and handles the control to the centralized ErrorHandler. | -| [redirect](https://github.com/gofiber/fiber/tree/main/middleware/redirect) | Redirect middleware. | -| [requestid](https://github.com/gofiber/fiber/tree/main/middleware/requestid) | Adds a request ID to every request. | -| [responsetime](https://github.com/gofiber/fiber/tree/main/middleware/responsetime) | Measures request handling duration and writes it to a configurable response header. | -| [rewrite](https://github.com/gofiber/fiber/tree/main/middleware/rewrite) | Rewrites the URL path based on provided rules. It can be helpful for backward compatibility or just creating cleaner and more descriptive links. | -| [session](https://github.com/gofiber/fiber/tree/main/middleware/session) | Session middleware. NOTE: This middleware uses our Storage package. | -| [skip](https://github.com/gofiber/fiber/tree/main/middleware/skip) | Skip middleware that skips a wrapped handler if a predicate is true. | -| [static](https://github.com/gofiber/fiber/tree/main/middleware/static) | Static middleware for Fiber that serves static files such as **images**, **CSS**, and **JavaScript**. | -| [timeout](https://github.com/gofiber/fiber/tree/main/middleware/timeout) | Adds a max time for a request and forwards to ErrorHandler if it is exceeded. | - -## 🧬 External Middleware - -List of externally hosted middleware modules and maintained by the [Fiber team](https://github.com/orgs/gofiber/people). - -| Middleware | Description | -| :---------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | -| [contrib](https://github.com/gofiber/contrib) | Third-party middlewares | -| [storage](https://github.com/gofiber/storage) | Premade storage drivers that implement the Storage interface, designed to be used with various Fiber middlewares. | -| [template](https://github.com/gofiber/template) | This package contains 9 template engines that can be used with Fiber. | - -## 🕶️ Awesome List - -For more articles, middlewares, examples, or tools, check our [awesome list](https://github.com/gofiber/awesome-fiber). - -## 👍 Contribute - -If you want to say **Thank You** and/or support the active development of `Fiber`: - -1. Add a [GitHub Star](https://github.com/gofiber/fiber/stargazers) to the project. -2. Tweet about the project [on your 𝕏 (Twitter)](https://x.com/intent/tweet?text=Fiber%20is%20an%20Express%20inspired%20%23web%20%23framework%20built%20on%20top%20of%20Fasthttp%2C%20the%20fastest%20HTTP%20engine%20for%20%23Go.%20Designed%20to%20ease%20things%20up%20for%20%23fast%20development%20with%20zero%20memory%20allocation%20and%20%23performance%20in%20mind%20%F0%9F%9A%80%20https%3A%2F%2Fgithub.com%2Fgofiber%2Ffiber). -3. Write a review or tutorial on [Medium](https://medium.com/), [Dev.to](https://dev.to/) or your personal blog. -4. Support the project by becoming a [GitHub Sponsor](https://github.com/sponsors/gofiber). - -## 💻 Development - -To ensure your contributions are ready for a Pull Request, please use the following `Makefile` commands. These tools help maintain code quality and consistency. - -- **make help**: Display available commands. -- **make audit**: Conduct quality checks. -- **make benchmark**: Benchmark code performance. -- **make coverage**: Generate test coverage report. -- **make format**: Automatically format code. -- **make lint**: Run lint checks. -- **make test**: Execute all tests. -- **make tidy**: Tidy dependencies. - -Run these commands to ensure your code adheres to project standards and best practices. - - - -## ☕ Supporters - -Fiber is an open-source project that runs on donations to pay the bills, e.g., our domain name, GitBook, Netlify, and serverless hosting. If you want to support Fiber, please sponsor the organization via ⭐ [**GitHub Sponsors**](https://github.com/sponsors/gofiber). - - - -### 📅 Monthly Sponsors - - - - - -
🔥 Fiber Guardian@coderabbitai
☕ Fiber Supporter@NdoleStudio @petercool
🪴 Fiber Friend@simonheisstpeter
- -### 🎁 One-time Sponsors - - - - -
🚀 Fiber Hero@thnxdev
🪴 Fiber Friend@Gl1tchedPixzl
- - - -## 💻 Code Contributors - - - Code Contributors - - -## ⭐️ Stargazers - - - - - Stargazers over time - - -## 🧾 License - -Copyright (c) 2019-present [Fenny](https://github.com/fenny) and [Contributors](https://github.com/gofiber/fiber/graphs/contributors). `Fiber` is free and open-source software licensed under the [MIT License](https://github.com/gofiber/fiber/blob/main/LICENSE). Official logo was created by [Vic Shóstak](https://github.com/koddr) and distributed under [Creative Commons](https://creativecommons.org/licenses/by-sa/4.0/) license (CC BY-SA 4.0 International). diff --git a/.design-research/readme-examples/github-changelog-generator.README.md b/.design-research/readme-examples/github-changelog-generator.README.md deleted file mode 100644 index ea3a6ae..0000000 --- a/.design-research/readme-examples/github-changelog-generator.README.md +++ /dev/null @@ -1,367 +0,0 @@ -[![Gem Version](https://badge.fury.io/rb/github_changelog_generator.svg)](http://badge.fury.io/rb/github_changelog_generator) -[![Ruby CI](https://github.com/github-changelog-generator/github-changelog-generator/actions/workflows/test.yml/badge.svg)](https://github.com/github-changelog-generator/github-changelog-generator/actions/workflows/test.yml) -[![Join the chat at https://gitter.im/github-changelog-generator/chat](https://badges.gitter.im/github-changelog-generator/chat.svg)](https://gitter.im/github-changelog-generator/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -# github-changelog-generator ![GitHub Logo](../master/images/logo.jpg) - - - - - -- [Changelog generation has never been so easy](#changelog-generation-has-never-been-so-easy) -- [_What’s the point of a changelog?_](#whats-the-point-of-a-changelog) -- [_Why should I care?_](#why-should-i-care) -- [Installation](#installation) -- [Usage](#usage) - - [Running with CLI:](#running-with-cli) - - [Running with Docker](#running-with-docker) -- [Output example](#output-example) - - [Params](#params) - - [Params File](#params-file) - - [GitHub token](#github-token) -- [Migrating from a manual changelog](#migrating-from-a-manual-changelog) - - [Rake task](#rake-task) -- [Features and advantages of this project](#features-and-advantages-of-this-project) - - [Using the summary section feature](#using-the-summary-section-feature) - - [Alternatives](#alternatives) - - [Projects using this library](#projects-using-this-library) -- [Practical Use Cases](#practical-use-cases) - - [Creating GitHub Release Notes](#creating-github-release-notes) -- [Am I missing some essential feature?](#am-i-missing-some-essential-feature) -- [FAQ](#faq) -- [Contributing](#contributing) -- [Contact us](#contact-us) -- [License](#license) - - - -### Changelog generation has never been so easy - -**Fully automated changelog generation** - This gem generates a changelog file based on **tags**, **issues** and merged **pull requests** (and splits them into separate lists according to labels) from :octocat: GitHub. - -Since you don't have to fill your `CHANGELOG.md` manually now: just run the script, relax and take a cup of :coffee: before your next release! :tada: - -### _What’s the point of a changelog?_ - -To make it easier for users and contributors to see precisely what notable changes have been made between each release (or version) of the project. - -### _Why should I care?_ - -Because software tools are for _people_. "Changelogs make it easier for users and -contributors to see precisely what notable changes have been made between each -release (or version) of the project." - -:arrow_right: _[https://keepachangelog.com](https://keepachangelog.com)_ - -## Installation - -GitHub Changelog Generator is a [Ruby](https://www.ruby-lang.org/) -program, distributed as a RubyGem. The Ruby language homepage has an [Installation page](https://www.ruby-lang.org/en/documentation/installation/). - -Install the gem like: - - $ gem install github_changelog_generator - -Depending on your system, you _may_ need to run the shell as an Administrator (Windows), -or use `sudo gem install github_changelog_generator` (Linux). - -## Usage - -### Running with CLI: - - github_changelog_generator -u github_project_namespace -p github_project - -(where the project namespace is _likely_ your username if it's a project you own, but it could also be the namespace of the project) - -### Running with Docker - -Using [Docker](https://www.docker.com/products/docker-desktop) is an alternative to installing Ruby and the gem. - -Example invocation: - - $ docker run -it --rm -v "$(pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator - -- For GitHub Enterprise repos, specify _both_ `--github-site` and `--github-api` options: - - $ github_changelog_generator --github-site="https://github.yoursite.com" \ - --github-api="https://github.yoursite.com/api/v3/" - -This generates a `CHANGELOG.md`, with pretty Markdown formatting. - -## Output example - -- Look at **[CHANGELOG.md](https://github.com/github-changelog-generator/github-changelog-generator/blob/master/CHANGELOG.md)** for this project -- [ActionSheetPicker-3.0/CHANGELOG.md](https://github.com/skywinder/ActionSheetPicker-3.0/blob/develop/CHANGELOG.md) was generated by command: - - $ github_changelog_generator -u github-changelog-generator -p ActionSheetPicker-3.0 - -- In general, it looks like this: - -> ## [1.2.5](https://github.com/github-changelog-generator/github-changelog-generator/tree/1.2.5) (2015-01-15) -> -> [Full Changelog](https://github.com/github-changelog-generator/github-changelog-generator/compare/1.2.4...1.2.5) -> -> **Implemented enhancements:** -> -> - Use milestone to specify in which version bug was fixed [\#22](https://github.com/github-changelog-generator/github-changelog-generator/issues/22) -> -> **Fixed bugs:** -> -> - Error when trying to generate log for repo without tags [\#32](https://github.com/github-changelog-generator/github-changelog-generator/issues/32) -> -> **Merged pull requests:** -> -> - PrettyPrint class is included using lowercase 'pp' [\#43](https://github.com/github-changelog-generator/github-changelog-generator/pull/43) ([schwing](https://github.com/schwing)) -> -> - support enterprise github via command line options [\#42](https://github.com/github-changelog-generator/github-changelog-generator/pull/42) ([glenlovett](https://github.com/glenlovett)) - -### Params - -Print help for all command-line options to learn more details: - - $ github_changelog_generator --help - -For more details about params, read the Wiki page: [**Advanced changelog generation examples**](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Advanced-change-log-generation-examples) - -### Params File - -In your project root, you can put a params file named `.github_changelog_generator` to override default params: - -Example: - -``` -unreleased=false -future-release=5.0.0 -since-tag=1.0.0 -``` - -### GitHub token - -GitHub only allows **50 unauthenticated requests per hour**. - -Therefore, it's recommended to run this script with authentication by using a **token**. - -Here's how: - -- [Generate a token here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token) - you only need "repo" scope for private repositories -- Either: - - Run the script with `--token `; **OR** - - Set the `CHANGELOG_GITHUB_TOKEN` environment variable to your 40 digit token - -You can set an environment variable by running the following command at the prompt, or by adding it to your shell profile (e.g., `.env`, `~/.bash_profile`, `~/.zshrc`, etc): - - export CHANGELOG_GITHUB_TOKEN="«your-40-digit-github-token»" - -So, if you get a message like this: - -```markdown -API rate limit exceeded for github_username. -See: https://developer.github.com/v3/#rate-limiting -``` - -It's time to create this token! (Or, wait an hour for GitHub to reset your unauthenticated request limit.) - -## Migrating from a manual changelog - -Knowing how dedicated you are to your project, you probably haven't been waiting for `github-changelog-generator` to keep a changelog. -But you probably don't want your project's open issues and PRs for all past features listed in your historic changelog, either. - -That's where `--base ` comes in handy! -This option lets append your old manual changelog to the end of the generated entries. - -If you have a `HISTORY.md` file in your project, it will automatically be picked as the static historical changelog and appended. - -### Rake task - -You love `rake`? We do, too! So, we've made it even easier for you: -we've provided a `rake` task library for your changelog generation. - -Configure the task in your `Rakefile`: - -```ruby -require 'github_changelog_generator/task' - -GitHubChangelogGenerator::RakeTask.new :changelog do |config| - config.user = 'username' - config.project = 'project-name' - config.since_tag = '0.1.14' - config.future_release = '0.2.0' -end -``` - -All command-line options can be passed to the `rake` task as `config` -parameters. And since you're naming the `rake` task yourself, you can create -as many as you want. - -You can look for params names from the [parser source code (#setup_parser)](https://github.com/github-changelog-generator/github-changelog-generator/blob/master/lib/github_changelog_generator/parser.rb). For example, to translate the bugs label to Portuguese, instead of setting `config.bugs_label`, you have to set `config.bug_prefix`, and so on. - -## Features and advantages of this project - -- Generate canonical, neat changelog file, with default sections that follow [basic changelog guidelines](http://keepachangelog.com) :gem: -- Optionally generate **Unreleased** changes (closed issues that have not released yet) :dizzy: -- **GitHub Enterprise support** via command line options! :factory: -- Flexible format **customization**: - - **Customize** issues that **should be added** to changelog :eight_spoked_asterisk: - - **Custom date formats** supported (but keep [ISO 8601](http://xkcd.com/1179/) in mind!) :date: - - Manually specify the version that fixed an issue (for cases when the issue's Closed date doesn't match) by giving the issue's `milestone` the same name as the tag of version :pushpin: - - Automatically **exclude specific issues** that are irrelevant to your changelog (by default, any issue labeled `question`, `duplicate`, `invalid`, or `wontfix`) :scissors: -- **Distinguish** issues **by labels**. :mag_right: - - Merged pull requests (all merged pull-requests) :twisted_rightwards_arrows: - - Bug fixes (issues labeled `bug`) :beetle: - - Enhancements (issues labeled `enhancement`) :star2: - - Issues (closed issues with no labels) :non-potable_water: - - Plus any additional custom label-to-section mappings you want :gear: - -- Manually include or exclude issues by labels :wrench: -- Customize lots more! Tweak the changelog to fit your preferences :tophat: - (_See `github_changelog_generator --help` for details)_ - -### Using the summary section feature - -For each version, you can add a _release summary_ with text, images, gif animations, -etc, and show new features and notes clearly to the user. This is done using GitHub metadata. - -**Example**: adding the release summary for v1.0.0: - -1. Create a new GitHub Issue -2. In the Issue's _Description_ field, add your release summary content - -``` -![image](https://user-images.githubusercontent.com/12690315/45935880-006a8200-bfeb-11e8-958e-ff742ae66b96.png) - -Hello, World! :tada: -``` - -3. Set the Issue Label `release-summary` and add it to the GitHub Milestone `v1.0.0` -4. Close the Issue and execute `github-changelog-generator` -5. The result looks like this: - -> ## [v1.0.0](https://github.com/github-changelog-generator/github-changelog-generator/tree/1.0.0) (2014-11-07) -> -> [Full Changelog](https://github.com/github-changelog-generator/github-changelog-generator/compare/0.1.0...1.0.0) -> -> ![image](https://user-images.githubusercontent.com/12690315/45935880-006a8200-bfeb-11e8-958e-ff742ae66b96.png) -> -> Hello, World! :tada: -> -> **Implemented enhancements:** -> -> - Add some features - -### Alternatives - -Here is a [wikipage list of alternatives](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Alternatives) that I found. But none satisfied my requirements. - -_If you know other projects, feel free to edit this Wiki page!_ - -### Projects using this library - -Here's a [wikipage list of projects](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Projects-using-GitHub-Changelog-Generator). - -If you've used this project in a live app, please let me know! Nothing makes me happier than seeing someone else take my work and go wild with it. - -_If you are using `github_changelog_generator` to generate your project's changelog, or know of other projects using it, please [add it to this list](https://github.com/github-changelog-generator/github-changelog-generator/wiki/Projects-using-GitHub-Changelog-Generator)._ - -## Practical Use Cases - -### Creating GitHub Release Notes - -`github_changelog_generator` can be used in combination with the [github cli](https://cli.github.com/) to create release notes. Use the `--since-tag` and `--output` options of `github_changelog_generator` to create a changelog for the current release and store the results in a file. In the -example below, version `2.0.0` is the current release and version `1.0.0` is the previous release. - -``` -mkdir -p build -github_changelog_generator \ - --since-tag 1.0.0 \ - --output build/changelog.md -``` - -Then use the [release create](https://cli.github.com/manual/gh_release_create) feature of the github cli to create a new github release - -``` -gh release create 2.0.0 \ - --notes-file build/changelog.md \ - --title 2.0.0 -``` - -## Am I missing some essential feature? - -- **Nothing is impossible!** - -- Open an [issue](https://github.com/github-changelog-generator/github-changelog-generator/issues/new/choose) and let's make the generator better together! - -- _Bug reports, feature requests, patches, and well-wishes are always welcome._ :heavy_exclamation_mark: - -## FAQ - -- _**I already use GitHub Releases. Why do I need this?**_ - -GitHub Releases is a very good thing. And it's very good practice to maintain it. (Not a lot of people are using it yet!) :congratulations: - -_BTW: I would like to support GitHub Releases in [next releases](https://github.com/github-changelog-generator/github-changelog-generator/issues/56) ;)_ - -I'm not trying to compare the quality of handwritten and auto-generated logs. That said.... - -An auto-generated changelog really helps, even if you manually fill in the release notes! - -- **I want to define my own label-to-section mapping, how can I do it?** - -This is possible using either the `add-sections` or `configure-sections` -entry in `.github_changelog_generator`. For example, to add a single new -entry called "Maintenance" that will catch PRs tagged with your `maintenance` -label, you can add to `.github_changelog_generator` the line: - -``` -add-sections= {"maintenance":{"prefix":"**Project maintenance**","labels":["maintenance"]}} -``` - -A similar approach can be used via `configure-sections` to set all section -properties (including adding new ones!). - -- _**My Ruby version is very old, can I use this?**_ - -When your Ruby is old, and you don't want to upgrade, and you want to -control which libraries you use, you can use Bundler. - -In a Gemfile, perhaps in a non-deployed `:development` group, add this -gem: - -```ruby -group :development do - gem 'github_changelog_generator', require: false -end -``` - -Then you can keep back dependencies like rack, which currently is only -compatible with Ruby >= 2.2.2. So, use an older version for your app by -adding a line like this to the Gemfile: - -``` -gem 'rack', '~> 1.6' -``` - -This way, you can keep on using github_changelog_generator, even if you -can't get the latest version of Ruby installed. - -- _**Windows: 1.14.x wants to create a file on an invalid path. Why?**_ - -Windows: [v1.14.0 introduced a bug where it attempts to create /tmp/github_changelog-logger.log... which isn't a valid path on Windows and thus fails](https://github.com/github-changelog-generator/github-changelog-generator/issues/458) - -Workaround: Create a `C:\tmp`. - -## Contributing - -Would you like to contribute to this project? [CONTRIBUTING.md] has all the details on how to do that. - -[CONTRIBUTING.md]: CONTRIBUTING.md - -## Contact us - -[Join the chat at gitter : github-changelog-generator](https://gitter.im/github-changelog-generator/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -## License - -GitHub Changelog Generator is released under the [MIT License](http://www.opensource.org/licenses/MIT). diff --git a/.design-research/readme-examples/httpie-cli.README.md b/.design-research/readme-examples/httpie-cli.README.md deleted file mode 100644 index bb9de55..0000000 --- a/.design-research/readme-examples/httpie-cli.README.md +++ /dev/null @@ -1,107 +0,0 @@ -

- - HTTPie - -
- HTTPie CLI: human-friendly HTTP client for the API era -

- -
- -[![HTTPie for Desktop](https://img.shields.io/static/v1?label=HTTPie&message=Desktop&color=4B78E6)](https://httpie.io/product) -[![](https://img.shields.io/static/v1?label=HTTPie&message=Web%20%26%20Mobile&color=73DC8C)](https://httpie.io/app) -[![](https://img.shields.io/static/v1?label=HTTPie&message=CLI&color=FA9BFA)](https://httpie.io/cli) -[![Twitter](https://img.shields.io/twitter/follow/httpie?style=flat&color=%234B78E6&logoColor=%234B78E6)](https://twitter.com/httpie) -[![Chat](https://img.shields.io/discord/725351238698270761?style=flat&label=Chat%20on%20Discord&color=%23FA9BFA)](https://httpie.io/discord) - -
- -
- -[![Docs](https://img.shields.io/badge/stable%20docs-httpie.io%2Fdocs%2Fcli-brightgreen?style=flat&color=%2373DC8C&label=Docs)](https://httpie.org/docs/cli) -[![Latest version](https://img.shields.io/pypi/v/httpie.svg?style=flat&label=Latest&color=%234B78E6&logo=&logoColor=white)](https://pypi.python.org/pypi/httpie) -[![Build](https://img.shields.io/github/actions/workflow/status/httpie/cli/tests.yml?branch=master&color=%23FA9BFA&label=Build)](https://github.com/httpie/cli/actions) -[![Coverage](https://img.shields.io/codecov/c/github/httpie/cli?style=flat&label=Coverage&color=%2373DC8C)](https://codecov.io/gh/httpie/cli) -[![PyPi downloads](https://img.shields.io/pepy/dt/httpie?style=flat&label=Downloads%20from%20PyPi%20only&color=4B78E6)](https://www.pepy.tech/projects/httpie) - -
- -HTTPie (pronounced _aitch-tee-tee-pie_) is a command-line HTTP client. -Its goal is to make CLI interaction with web services as human-friendly as possible. -HTTPie is designed for testing, debugging, and generally interacting with APIs & HTTP servers. -The `http` & `https` commands allow for creating and sending arbitrary HTTP requests. -They use simple and natural syntax and provide formatted and colorized output. - -
- -HTTPie in action - -
- -## We lost 54k GitHub stars - -Please note we recently accidentally made this repo private for a moment, and GitHub deleted our community that took a decade to build. Read the full story here: https://httpie.io/blog/stardust - -![](docs/stardust.png) - -## Getting started - -- [Installation instructions →](https://httpie.io/docs#installation) -- [Full documentation →](https://httpie.io/docs) - -## Features - -- Expressive and intuitive syntax -- Formatted and colorized terminal output -- Built-in JSON support -- Forms and file uploads -- HTTPS, proxies, and authentication -- Arbitrary request data -- Custom headers -- Persistent sessions -- `wget`-like downloads - -[See all features →](https://httpie.io/docs) - -## Examples - -Hello World: - -```bash -https httpie.io/hello -``` - -Custom [HTTP method](https://httpie.io/docs#http-method), [HTTP headers](https://httpie.io/docs#http-headers) and [JSON](https://httpie.io/docs#json) data: - -```bash -http PUT pie.dev/put X-API-Token:123 name=John -``` - -Build and print a request without sending it using [offline mode](https://httpie.io/docs/cli/offline-mode): - -```bash -http --offline pie.dev/post hello=offline -``` - -Use [GitHub API](https://developer.github.com/v3/issues/comments/#create-a-comment) to post a comment on an [Issue](https://github.com/httpie/cli/issues/83) with [authentication](https://httpie.io/docs#authentication): - -```bash -http -a USERNAME POST https://api.github.com/repos/httpie/cli/issues/83/comments body='HTTPie is awesome! :heart:' -``` - -[See more examples →](https://httpie.io/docs#examples) - -## Community & support - -- Visit the [HTTPie website](https://httpie.io) for full documentation and useful links. -- Join our [Discord server](https://httpie.io/discord) is to ask questions, discuss features, and for general API chat. -- Tweet at [@httpie](https://twitter.com/httpie) on Twitter. -- Use [StackOverflow](https://stackoverflow.com/questions/tagged/httpie) to ask questions and include a `httpie` tag. -- Create [GitHub Issues](https://github.com/httpie/cli/issues) for bug reports and feature requests. -- Subscribe to the [HTTPie newsletter](https://httpie.io) for occasional updates. - -## Contributing - -Have a look through existing [Issues](https://github.com/httpie/cli/issues) and [Pull Requests](https://github.com/httpie/cli/pulls) that you could help with. If you'd like to request a feature or report a bug, please [create a GitHub Issue](https://github.com/httpie/cli/issues) using one of the templates provided. - -[See contribution guide →](https://github.com/httpie/cli/blob/master/CONTRIBUTING.md) diff --git a/.design-research/readme-examples/playwright.README.md b/.design-research/readme-examples/playwright.README.md deleted file mode 100644 index a9791d3..0000000 --- a/.design-research/readme-examples/playwright.README.md +++ /dev/null @@ -1,318 +0,0 @@ -# 🎭 Playwright - -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-152.0.7977.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) - -## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) - -Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents. - -## Get Started - -Choose the path that fits your workflow: - -| | Best for | Install | -| --------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` | -| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` | -| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` | -| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` | -| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | - ---- - -## Playwright Test - -Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions. - -### Install - -```bash -npm init playwright@latest -``` - -Or add manually: - -```bash -npm i -D @playwright/test -npx playwright install -``` - -### Write a test - -```TypeScript -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); - await expect(page).toHaveTitle(/Playwright/); -}); - -test('get started link', async ({ page }) => { - await page.goto('https://playwright.dev/'); - await page.getByRole('link', { name: 'Get started' }).click(); - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); -}); -``` - -### Run tests - -```bash -npx playwright test -``` - -Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead. - -### Key capabilities - -**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met. - -**Locators.** Find elements with resilient locators that mirror how users see the page: - -```TypeScript -page.getByRole('button', { name: 'Submit' }) -page.getByLabel('Email') -page.getByPlaceholder('Search...') -page.getByTestId('login-form') -``` - -**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests: - -```TypeScript -// Save state after login -await page.context().storageState({ path: 'auth.json' }); - -// Reuse in other tests -test.use({ storageState: 'auth.json' }); -``` - -**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer): - -```TypeScript -// playwright.config.ts -export default defineConfig({ - use: { - trace: 'on-first-retry', - }, -}); -``` - -```bash -npx playwright show-trace trace.zip -``` - - - -**Parallelism.** Tests run in parallel by default across all configured browsers. - -[Full testing documentation](https://playwright.dev/docs/intro) - ---- - -## Playwright CLI - -[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context. - -### Install - -```bash -npm install -g @playwright/cli@latest -``` - -Optionally install skills for richer agent integration: - -```bash -playwright-cli install --skills -``` - -### Usage - -Point your coding agent at a task: - -``` -Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli. -Take screenshots for all successful and failing scenarios. -``` - -Or run commands directly: - -```bash -playwright-cli open https://demo.playwright.dev/todomvc/ --headed -playwright-cli type "Buy groceries" -playwright-cli press Enter -playwright-cli screenshot -``` - -### Session monitoring - -Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control. - -```bash -playwright-cli show -``` - - - -[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli) - ---- - -## Playwright MCP - -The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required. - -### Setup - -Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.): - -```json -{ - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest"] - } - } -} -``` - -**One-click install for VS Code:** - -[Install in VS Code](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) - -**For Claude Code:** - -```bash -claude mcp add playwright npx @playwright/mcp@latest -``` - -### How it works - -Ask your AI assistant to interact with any web page: - -``` -Navigate to https://demo.playwright.dev/todomvc and add a few todo items. -``` - -The agent sees the page as a structured accessibility tree: - -``` -- heading "todos" [level=1] -- textbox "What needs to be done?" [ref=e5] -- listitem: - - checkbox "Toggle Todo" [ref=e10] - - text: "Buy groceries" -``` - -It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more. - -[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp) - ---- - -## Playwright Library - -Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner. - -### Install - -```bash -npm i playwright -``` - -### Examples - -**Take a screenshot:** - -```TypeScript -import { chromium } from 'playwright'; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -await page.goto('https://playwright.dev/'); -await page.screenshot({ path: 'screenshot.png' }); -await browser.close(); -``` - -**Generate a PDF:** - -```TypeScript -import { chromium } from 'playwright'; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -await page.goto('https://playwright.dev/'); -await page.pdf({ path: 'page.pdf', format: 'A4' }); -await browser.close(); -``` - -**Emulate a mobile device:** - -```TypeScript -import { chromium, devices } from 'playwright'; - -const browser = await chromium.launch(); -const context = await browser.newContext(devices['iPhone 15']); -const page = await context.newPage(); -await page.goto('https://playwright.dev/'); -await page.screenshot({ path: 'mobile.png' }); -await browser.close(); -``` - -**Intercept network requests:** - -```TypeScript -import { chromium } from 'playwright'; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -await page.route('**/*.{png,jpg,jpeg}', route => route.abort()); -await page.goto('https://playwright.dev/'); -await browser.close(); -``` - -[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright) - ---- - -## VS Code Extension - -The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor. - - - -**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view. - -**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you. - -**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard. - -**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step. - -[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode) - ---- - -## Cross-Browser Support - -| | Linux | macOS | Windows | -| :------------------------------------------------------------------------------ | :----------------: | :----------------: | :----------------: | -| Chromium1 152.0.7977.8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| WebKit 26.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Firefox 153.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | - -Headless and headed execution on all platforms. 1 Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default. - -## Other Languages - -Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro). - -## Resources - -- [Documentation](https://playwright.dev) -- [API reference](https://playwright.dev/docs/api/class-playwright) -- [MCP server](https://github.com/microsoft/playwright-mcp) -- [CLI for coding agents](https://github.com/microsoft/playwright-cli) -- [VS Code extension](https://github.com/microsoft/playwright-vscode) -- [Contribution guide](CONTRIBUTING.md) -- [Changelog](https://github.com/microsoft/playwright/releases) -- [Discord](https://aka.ms/playwright/discord) diff --git a/.design-research/readme-examples/typescript.README.md b/.design-research/readme-examples/typescript.README.md deleted file mode 100644 index e682d29..0000000 --- a/.design-research/readme-examples/typescript.README.md +++ /dev/null @@ -1,61 +0,0 @@ -# TypeScript - - - -[![CI](https://github.com/microsoft/TypeScript/actions/workflows/ci.yml/badge.svg)](https://github.com/microsoft/TypeScript/actions/workflows/ci.yml) -[![npm version](https://badge.fury.io/js/typescript.svg)](https://www.npmjs.com/package/typescript) -[![Downloads](https://img.shields.io/npm/dm/typescript.svg)](https://www.npmjs.com/package/typescript) -[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/microsoft/TypeScript/badge)](https://securityscorecards.dev/viewer/?uri=github.com/microsoft/TypeScript) - -[TypeScript](https://www.typescriptlang.org/) is a language for application-scale JavaScript. TypeScript adds optional types to JavaScript that support tools for large-scale JavaScript applications for any browser, for any host, on any OS. TypeScript compiles to readable, standards-based JavaScript. Try it out at the [playground](https://www.typescriptlang.org/play/), and stay up to date via [our blog](https://blogs.msdn.microsoft.com/typescript) and [Twitter account](https://twitter.com/typescript). - -Find others who are using TypeScript at [our community page](https://www.typescriptlang.org/community/). - -## Installing - -For the latest stable version: - -```bash -npm install -D typescript -``` - -For our nightly builds: - -```bash -npm install -D typescript@next -``` - -## Contribute - -**NOTE: Code changes in this repo are now limited to a small category of fixes**: - -- Crashes that were introduced in 5.9 or 6.0 that _also_ repro in 7.0 _and_ have a portable fix _and_ don't incur other behavioral changes -- Security issues -- Language service crashes that substantially impact mainline usage -- Serious regressions from 5.9 (these must _seriously_ impact a _large_ proportion of users) - -Most bug fixes should be submitted to the [typescript-go](https://github.com/microsoft/TypeScript-go) repository. -Feature additions and behavioral changes are currently on pause until TypeScript 7.0 is completed. - -There are many ways to [contribute](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md) to TypeScript. - -- [Submit bugs](https://github.com/microsoft/TypeScript/issues) and help us verify fixes as they are checked in. -- Review the [source code changes](https://github.com/microsoft/TypeScript/pulls). -- Engage with other TypeScript users and developers on [StackOverflow](https://stackoverflow.com/questions/tagged/typescript). -- Help each other in the [TypeScript Community Discord](https://discord.gg/typescript). -- Join the [#typescript](https://twitter.com/search?q=%23TypeScript) discussion on Twitter. -- [Contribute bug fixes](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md). - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see -the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) -with any additional questions or comments. - -## Documentation - -- [TypeScript in 5 minutes](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) -- [Programming handbook](https://www.typescriptlang.org/docs/handbook/intro.html) -- [Homepage](https://www.typescriptlang.org/) - -## Roadmap - -For details on our planned features and future direction, please refer to our [roadmap](https://github.com/microsoft/TypeScript/wiki/Roadmap). diff --git a/.design-research/readme-examples/vite.README.md b/.design-research/readme-examples/vite.README.md deleted file mode 100644 index 069c468..0000000 --- a/.design-research/readme-examples/vite.README.md +++ /dev/null @@ -1,66 +0,0 @@ -

-
-
- - - - - vite logo - - -
-
-

-
-

- npm package - node compatibility - build status - discord chat -

-
- -# Vite ⚡ - -> Next Generation Frontend Tooling - -- 💡 Instant Server Start -- ⚡️ Lightning Fast HMR -- 🛠️ Rich Features -- 📦 Optimized Build -- 🔩 Universal Plugin Interface -- 🔑 Fully Typed APIs - -Vite (French word for "quick", pronounced [`/viːt/`](https://cdn.jsdelivr.net/gh/vitejs/vite@main/docs/public/vite.mp3), like "veet") is a build tool that aims to provide a faster and leaner development experience for modern web projects. It consists of two major parts: - -- A dev server that provides [rich feature enhancements](https://vite.dev/guide/features) over [native ES modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), for example extremely fast [Hot Module Replacement (HMR)](https://vite.dev/guide/features#hot-module-replacement). - -- A build command that bundles your code with [Rolldown](https://rolldown.rs), pre-configured to output highly optimized static assets for production. - -In addition, Vite is highly extensible via its [Plugin API](https://vite.dev/guide/api-plugin.html) and [JavaScript API](https://vite.dev/guide/api-javascript.html) with full typing support. - -[Read the Docs to Learn More](https://vite.dev). - -## Packages - -| Package | Version (click for changelogs) | -| ----------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | -| [vite](packages/vite) | [![vite version](https://img.shields.io/npm/v/vite.svg?label=%20)](packages/vite/CHANGELOG.md) | -| [@vitejs/plugin-legacy](packages/plugin-legacy) | [![plugin-legacy version](https://img.shields.io/npm/v/@vitejs/plugin-legacy.svg?label=%20)](packages/plugin-legacy/CHANGELOG.md) | -| [create-vite](packages/create-vite) | [![create-vite version](https://img.shields.io/npm/v/create-vite.svg?label=%20)](packages/create-vite/CHANGELOG.md) | - -## Contribution - -See [Contributing Guide](CONTRIBUTING.md). - -## License - -[MIT](LICENSE). - -## Sponsors - -

- - sponsors - -