mandrel 2.31.0 → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +13 -17
- package/.agents/agents/acceptance-critic.md +1 -2
- package/.agents/docs/SDLC.md +12 -9
- package/.agents/docs/agentrc-reference.json +63 -58
- package/.agents/docs/configuration.md +275 -227
- package/.agents/docs/execution-reference.md +13 -14
- package/.agents/docs/quality-gates.md +195 -23
- package/.agents/instructions.md +2 -5
- package/.agents/rules/git-conventions-reference.md +27 -27
- package/.agents/rules/git-conventions.md +4 -2
- package/.agents/rules/known-tooling-behavior.md +66 -30
- package/.agents/rules/testing-standards.md +35 -71
- package/.agents/runtime-deps.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1955 -1410
- package/.agents/schemas/lifecycle/README.md +21 -14
- package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
- package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
- package/.agents/scripts/README.md +16 -29
- package/.agents/scripts/apply-quality-bootstrap.js +27 -34
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/bootstrap.js +28 -26
- package/.agents/scripts/check-baseline-drift.js +73 -13
- package/.agents/scripts/check-baseline-scope.js +362 -0
- package/.agents/scripts/check-dead-exports.js +9 -1
- package/.agents/scripts/check-gherkin-corpus.js +508 -0
- package/.agents/scripts/check-knip-entries.js +159 -0
- package/.agents/scripts/check-lifecycle-lint.js +108 -124
- package/.agents/scripts/check-schema-references.js +1 -1
- package/.agents/scripts/diagnose-friction.js +7 -4
- package/.agents/scripts/generate-config-docs.js +263 -171
- package/.agents/scripts/install-matrix-assert.js +0 -1
- package/.agents/scripts/lib/ITicketingProvider.js +0 -58
- package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
- package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
- package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -35
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/envelope.js +41 -60
- package/.agents/scripts/lib/baselines/git-base.js +30 -37
- package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
- package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
- package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
- package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
- package/.agents/scripts/lib/bdd-step-index.js +326 -0
- package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
- package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
- package/.agents/scripts/lib/checks/loop-health.js +9 -37
- package/.agents/scripts/lib/child-exec.js +193 -0
- package/.agents/scripts/lib/cli/standard-args.js +1 -1
- package/.agents/scripts/lib/cli-args.js +64 -0
- package/.agents/scripts/lib/close-validation/gates.js +2 -2
- package/.agents/scripts/lib/close-validation/runner.js +3 -3
- package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
- package/.agents/scripts/lib/config/commands.js +3 -5
- package/.agents/scripts/lib/config/explain.js +5 -7
- package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
- package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
- package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
- package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
- package/.agents/scripts/lib/config/gates/index.js +5 -2
- package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
- package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
- package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
- package/.agents/scripts/lib/config/gates/shared.js +58 -9
- package/.agents/scripts/lib/config/github.js +0 -1
- package/.agents/scripts/lib/config/limits.js +3 -48
- package/.agents/scripts/lib/config/qa.js +105 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config/temp-paths.js +6 -5
- package/.agents/scripts/lib/config-settings-schema-delivery.js +245 -56
- package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
- package/.agents/scripts/lib/config-settings-schema.js +386 -39
- package/.agents/scripts/lib/crap-baseline-join.js +126 -9
- package/.agents/scripts/lib/crap-utils.js +84 -520
- package/.agents/scripts/lib/dead-exports-knip.js +79 -10
- package/.agents/scripts/lib/degraded-mode.js +2 -2
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/doc-tiers.js +3 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/fs-walk.js +52 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
- package/.agents/scripts/lib/git-utils.js +16 -36
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +508 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
- package/.agents/scripts/lib/observability/signal-validator.js +5 -85
- package/.agents/scripts/lib/observability/signals-writer.js +19 -62
- package/.agents/scripts/lib/observability/source-classifier.js +5 -7
- package/.agents/scripts/lib/observability/terse-result.js +3 -3
- package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
- package/.agents/scripts/lib/orchestration/code-review.js +2 -2
- package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
- package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
- package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +7 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -13
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
- package/.agents/scripts/lib/orchestration/resolve-stories.js +77 -50
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
- package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
- package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
- package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
- package/.agents/scripts/lib/signals/detectors/common.js +1 -1
- package/.agents/scripts/lib/signals/index.js +8 -6
- package/.agents/scripts/lib/signals/schema.js +20 -25
- package/.agents/scripts/lib/signals/write.js +8 -8
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -81
- package/.agents/scripts/lib/temp-retention.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
- package/.agents/scripts/lib/ticket-body-sections.js +4 -5
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
- package/.agents/scripts/lib/worktree-manager.js +2 -3
- package/.agents/scripts/lint-label-vocabulary.js +2 -24
- package/.agents/scripts/pr-watch-with-update.js +7 -5
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/providers/github/cache.js +2 -2
- package/.agents/scripts/providers/github/comments.js +6 -28
- package/.agents/scripts/providers/github/compose.js +0 -15
- package/.agents/scripts/providers/github/errors.js +10 -27
- package/.agents/scripts/providers/github/request-helpers.js +1 -2
- package/.agents/scripts/providers/github/sub-issues.js +10 -218
- package/.agents/scripts/providers/github.js +4 -7
- package/.agents/scripts/prune-baseline-orphans.js +181 -0
- package/.agents/scripts/resolve-stories.js +21 -7
- package/.agents/scripts/run-lint.js +61 -61
- package/.agents/scripts/run-test-profile.js +6 -6
- package/.agents/scripts/run-verify.js +48 -30
- package/.agents/scripts/single-story-close.js +20 -0
- package/.agents/scripts/single-story-init.js +12 -35
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/scripts/update-dead-exports-baseline.js +321 -0
- package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-documentation.md +5 -6
- package/.agents/workflows/audit-to-stories.md +28 -2
- package/.agents/workflows/helpers/audit-lens-core.md +11 -12
- package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
- package/.agents/workflows/helpers/code-review.md +3 -8
- package/.agents/workflows/helpers/deliver-reference.md +30 -2
- package/.agents/workflows/helpers/deliver-story-reference.md +84 -16
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
- package/.agents/workflows/mandrel-update.md +10 -10
- package/.agents/workflows/qa-assist.md +15 -20
- package/.agents/workflows/qa-explore.md +9 -8
- package/README.md +1 -1
- package/docs/CHANGELOG.md +58 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
- package/package.json +14 -5
- package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
- package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
- package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
- package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
- package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
- package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
- package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
- package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
- package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
- package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
- package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
- package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
- package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
- package/.agents/scripts/check-gherkin-placeholders.js +0 -663
- package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
- package/.agents/scripts/lib/audit-suite/cli.js +0 -64
- package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
- package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
- package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
- package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
- package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
- package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
- package/.agents/scripts/lib/crap-baseline-index.js +0 -46
- package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
- package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
- package/.agents/scripts/lib/mutation/config-detector.js +0 -119
- package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
- package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
- package/.agents/scripts/lib/observability/active-story-env.js +0 -170
- package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
- package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
- package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
- package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
- package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
- package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
- package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
- package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
- package/.agents/scripts/lib/planning-corpus.js +0 -37
- package/.agents/scripts/lib/qa/coverage-report.js +0 -181
- package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
- package/.agents/scripts/lib/signals/detectors/index.js +0 -14
- package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
- package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
- package/.agents/scripts/lib/signals/read.js +0 -268
- package/.agents/scripts/lib/signals/span-tree.js +0 -291
- package/.agents/scripts/lib/story-lifecycle.js +0 -194
- package/.agents/scripts/lib/story-plan.js +0 -379
- package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
- package/.agents/scripts/lib/util/phase-timer.js +0 -163
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
- package/.agents/scripts/lint-baseline.js +0 -507
- package/.agents/scripts/providers/github/prs.js +0 -103
- package/.agents/scripts/signals-view.js +0 -309
- package/.agents/scripts/story-plan.js +0 -370
- package/.agents/scripts/sync-branch-from-base.js +0 -149
- package/.agents/scripts/validate-docs-freshness.js +0 -314
- package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
- package/.agents/workflows/helpers/signals.md +0 -112
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// .agents/scripts/check-gherkin-corpus.js — static gate over a project's
|
|
4
|
+
// Gherkin corpus: must-compile, then must-bind, scoped per step root.
|
|
5
|
+
//
|
|
6
|
+
// The framework ships the bddgen harness but nothing that inspects the corpus
|
|
7
|
+
// it generates from. Two corpus-wide failures are invisible until generation
|
|
8
|
+
// time, and both take the whole acceptance suite dark at once: a `.feature`
|
|
9
|
+
// the parser rejects, and a step no definition claims. This gate catches both
|
|
10
|
+
// offline, in the same `npm run lint` that already guards every other
|
|
11
|
+
// framework-owned surface.
|
|
12
|
+
//
|
|
13
|
+
// Two contracts make the findings trustworthy rather than merely loud:
|
|
14
|
+
//
|
|
15
|
+
// must-compile parses with the REAL `@cucumber/gherkin` parser. A gate that
|
|
16
|
+
// re-implements acceptance is the defect it is trying to prevent: a
|
|
17
|
+
// hand-rolled line reader silently skips what it does not recognise, so a
|
|
18
|
+
// corpus that cannot generate reads clean. Whatever bddgen accepts is what
|
|
19
|
+
// this gate must accept, and the only way to guarantee that is to run the
|
|
20
|
+
// same parser.
|
|
21
|
+
//
|
|
22
|
+
// A file failing must-compile is EXCLUDED from must-bind. A broken file
|
|
23
|
+
// parses as an arbitrary subset of itself, so linting its surviving steps
|
|
24
|
+
// invents unbound findings that bury the one actionable line — the syntax
|
|
25
|
+
// error — under noise.
|
|
26
|
+
//
|
|
27
|
+
// The parser is an optional peer dependency (plus a framework devDependency),
|
|
28
|
+
// resolved through a require path rooted at the consumer project rather than
|
|
29
|
+
// imported by bare specifier. `.agents/` reaches a consumer by plain file
|
|
30
|
+
// copy, so a bare specifier here would resolve against the consumer's own
|
|
31
|
+
// module chain, which under a non-hoisting linker need not hold it at all.
|
|
32
|
+
// This mirrors the `typescript` optional-peer precedent and keeps a consumer
|
|
33
|
+
// with no BDD tier from gaining a runtime dependency.
|
|
34
|
+
//
|
|
35
|
+
// Exit codes:
|
|
36
|
+
// 0 clean, or `qa.gherkinLint` is not configured
|
|
37
|
+
// 1 a parse error, an unbound step, or a fail-closed condition
|
|
38
|
+
|
|
39
|
+
// .agents/scripts/check-gherkin-corpus.js — static gate over a project's
|
|
40
|
+
// Gherkin corpus: must-compile, then must-bind, scoped per step root.
|
|
41
|
+
//
|
|
42
|
+
// The framework ships the bddgen harness but nothing that inspects the corpus
|
|
43
|
+
// it generates from. Two corpus-wide failures are invisible until generation
|
|
44
|
+
// time, and both take the whole acceptance suite dark at once: a `.feature`
|
|
45
|
+
// the parser rejects, and a step no definition claims. This gate catches both
|
|
46
|
+
// offline, in the same `npm run lint` that already guards every other
|
|
47
|
+
// framework-owned surface.
|
|
48
|
+
//
|
|
49
|
+
// Two contracts make the findings trustworthy rather than merely loud:
|
|
50
|
+
//
|
|
51
|
+
// must-compile parses with the REAL `@cucumber/gherkin` parser. A gate that
|
|
52
|
+
// re-implements acceptance is the defect it is trying to prevent: a
|
|
53
|
+
// hand-rolled line reader silently skips what it does not recognise, so a
|
|
54
|
+
// corpus that cannot generate reads clean. Whatever bddgen accepts is what
|
|
55
|
+
// this gate must accept, and the only way to guarantee that is to run the
|
|
56
|
+
// same parser.
|
|
57
|
+
//
|
|
58
|
+
// A file failing must-compile is EXCLUDED from must-bind. A broken file
|
|
59
|
+
// parses as an arbitrary subset of itself, so linting its surviving steps
|
|
60
|
+
// invents unbound findings that bury the one actionable line — the syntax
|
|
61
|
+
// error — under noise.
|
|
62
|
+
//
|
|
63
|
+
// The parser is an optional peer dependency (plus a framework devDependency),
|
|
64
|
+
// resolved through a require path rooted at the consumer project rather than
|
|
65
|
+
// imported by bare specifier. `.agents/` reaches a consumer by plain file
|
|
66
|
+
// copy, so a bare specifier here would resolve against the consumer's own
|
|
67
|
+
// module chain, which under a non-hoisting linker need not hold it at all.
|
|
68
|
+
// This mirrors the `typescript` optional-peer precedent and keeps a consumer
|
|
69
|
+
// with no BDD tier from gaining a runtime dependency.
|
|
70
|
+
//
|
|
71
|
+
// Exit codes:
|
|
72
|
+
// 0 clean, or `qa.gherkinLint` is not configured
|
|
73
|
+
// 1 a parse error, an unbound step, or a fail-closed condition
|
|
74
|
+
|
|
75
|
+
import fs, { readFileSync } from 'node:fs';
|
|
76
|
+
import { createRequire } from 'node:module';
|
|
77
|
+
import path from 'node:path';
|
|
78
|
+
import process from 'node:process';
|
|
79
|
+
import { pathToFileURL } from 'node:url';
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
buildStepIndex,
|
|
83
|
+
listFeatureFiles,
|
|
84
|
+
listStepFiles,
|
|
85
|
+
matchStep,
|
|
86
|
+
} from './lib/bdd-step-index.js';
|
|
87
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
88
|
+
import { getGherkinLint } from './lib/config/qa.js';
|
|
89
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
90
|
+
|
|
91
|
+
const TAG = '[gherkin-corpus]';
|
|
92
|
+
const PARSER_PACKAGE = '@cucumber/gherkin';
|
|
93
|
+
|
|
94
|
+
const HELP = {
|
|
95
|
+
invocation: 'node .agents/scripts/check-gherkin-corpus.js [--cwd <dir>]',
|
|
96
|
+
summary:
|
|
97
|
+
'Static gate over the Gherkin corpus: every in-scope .feature must compile with the real parser, and every active step must bind to a definition under its own scope.',
|
|
98
|
+
flags: [['--cwd <dir>', 'Project root to check. Default: process.cwd().']],
|
|
99
|
+
notes: [
|
|
100
|
+
'Opt-in: the gate runs only when `qa.gherkinLint` is configured in .agentrc.json.',
|
|
101
|
+
'Escapes for a false unbound: `exemptionTags` (default ["@skip"]) and `stepWaivers`.',
|
|
102
|
+
'Exit codes:\n 0 clean, or not configured\n 1 parse error, unbound step, or a fail-closed condition',
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse argv into an options bag.
|
|
108
|
+
*
|
|
109
|
+
* @param {string[]} argv
|
|
110
|
+
* @returns {{ cwd: string | null }}
|
|
111
|
+
*/
|
|
112
|
+
export function parseArgs(argv = []) {
|
|
113
|
+
const out = { cwd: null };
|
|
114
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
115
|
+
if (argv[i] === '--cwd') {
|
|
116
|
+
const next = argv[i + 1];
|
|
117
|
+
if (next && !next.startsWith('--')) {
|
|
118
|
+
out.cwd = next;
|
|
119
|
+
i += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `@cucumber/gherkin` through a require path anchored at the project
|
|
128
|
+
* being checked, falling back to the framework's own installation.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ cwd: string }} params
|
|
131
|
+
* @returns {Promise<{ parse: (source: string) => object }>}
|
|
132
|
+
* @throws {Error} when the package cannot be resolved from either anchor
|
|
133
|
+
*/
|
|
134
|
+
export async function loadGherkinParser({ cwd }) {
|
|
135
|
+
const anchors = [
|
|
136
|
+
createRequire(path.join(path.resolve(cwd), 'noop.cjs')),
|
|
137
|
+
createRequire(import.meta.url),
|
|
138
|
+
];
|
|
139
|
+
let resolved = null;
|
|
140
|
+
for (const anchor of anchors) {
|
|
141
|
+
try {
|
|
142
|
+
resolved = anchor.resolve(PARSER_PACKAGE);
|
|
143
|
+
break;
|
|
144
|
+
} catch {
|
|
145
|
+
// Try the next anchor; the aggregate failure is reported by the caller.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!resolved) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${PARSER_PACKAGE} could not be resolved from ${path.resolve(cwd)} or from the framework's own install — install it with \`npm install --save-dev ${PARSER_PACKAGE}\``,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const { AstBuilder, GherkinClassicTokenMatcher, Parser } = await import(
|
|
154
|
+
pathToFileURL(resolved).href
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
parse(source) {
|
|
158
|
+
// The id counter is supplied locally so `@cucumber/messages`
|
|
159
|
+
// (`IdGenerator.uuid`) is not a second import to resolve.
|
|
160
|
+
let seq = 0;
|
|
161
|
+
const nextId = () => {
|
|
162
|
+
seq += 1;
|
|
163
|
+
return String(seq);
|
|
164
|
+
};
|
|
165
|
+
const parser = new Parser(
|
|
166
|
+
new AstBuilder(nextId),
|
|
167
|
+
new GherkinClassicTokenMatcher(),
|
|
168
|
+
);
|
|
169
|
+
return parser.parse(source);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Normalize a parser exception into one finding per reported position.
|
|
176
|
+
*
|
|
177
|
+
* @param {unknown} error
|
|
178
|
+
* @param {string} file repo-relative path
|
|
179
|
+
* @returns {Array<{ kind: 'parse-error', file: string, line: number, column: number, message: string }>}
|
|
180
|
+
*/
|
|
181
|
+
export function toParseFindings(error, file) {
|
|
182
|
+
const reported = Array.isArray(error?.errors) ? error.errors : [error];
|
|
183
|
+
return reported.map((entry) => ({
|
|
184
|
+
kind: 'parse-error',
|
|
185
|
+
file,
|
|
186
|
+
line: entry?.location?.line ?? 1,
|
|
187
|
+
column: entry?.location?.column ?? 1,
|
|
188
|
+
message: String(entry?.message ?? entry),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Tag names carried by an AST node, as a plain string array. */
|
|
193
|
+
function tagNames(node) {
|
|
194
|
+
return (node?.tags ?? []).map((tag) => tag.name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Expand one step's text against an Examples table. A step with no `<param>`
|
|
199
|
+
* placeholder yields itself; an outline step yields one variant per row.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} text
|
|
202
|
+
* @param {Array<{ tableHeader?: object, tableBody?: object[] }>} examples
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
export function expandStepText(text, examples) {
|
|
206
|
+
if (!text.includes('<')) return [text];
|
|
207
|
+
const variants = [];
|
|
208
|
+
for (const example of examples ?? []) {
|
|
209
|
+
const headers = (example?.tableHeader?.cells ?? []).map((c) => c.value);
|
|
210
|
+
for (const row of example?.tableBody ?? []) {
|
|
211
|
+
const cells = (row?.cells ?? []).map((c) => c.value);
|
|
212
|
+
let expanded = text;
|
|
213
|
+
headers.forEach((header, i) => {
|
|
214
|
+
expanded = expanded.split(`<${header}>`).join(cells[i] ?? '');
|
|
215
|
+
});
|
|
216
|
+
variants.push(expanded);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return variants.length > 0 ? variants : [text];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Flatten a compiled feature into the steps must-bind is responsible for.
|
|
224
|
+
*
|
|
225
|
+
* Background steps run for every scenario in their container, so they are
|
|
226
|
+
* checked once — but only when the container still holds a non-exempt
|
|
227
|
+
* scenario, otherwise exempting every scenario in a file would leave its
|
|
228
|
+
* background as the sole remaining source of findings.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} feature the `document.feature` node
|
|
231
|
+
* @param {string[]} exemptionTags
|
|
232
|
+
* @returns {Array<{ line: number, text: string, examples: object[] }>}
|
|
233
|
+
*/
|
|
234
|
+
export function collectActiveSteps(feature, exemptionTags) {
|
|
235
|
+
const exempt = new Set(exemptionTags);
|
|
236
|
+
const featureTags = tagNames(feature);
|
|
237
|
+
const steps = [];
|
|
238
|
+
|
|
239
|
+
// A background step is reachable from more than one container once Rules are
|
|
240
|
+
// in play, so emitting is keyed on the step's own line to check it exactly
|
|
241
|
+
// once per feature rather than once per container that inherits it.
|
|
242
|
+
const emittedBackgroundLines = new Set();
|
|
243
|
+
const pushBackgroundSteps = (backgrounds) => {
|
|
244
|
+
for (const background of backgrounds) {
|
|
245
|
+
for (const step of background.steps ?? []) {
|
|
246
|
+
if (emittedBackgroundLines.has(step.location.line)) continue;
|
|
247
|
+
emittedBackgroundLines.add(step.location.line);
|
|
248
|
+
steps.push({ line: step.location.line, text: step.text, examples: [] });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const walkContainer = (container, inheritedTags, inheritedBackgrounds) => {
|
|
254
|
+
const backgrounds = [...inheritedBackgrounds];
|
|
255
|
+
const scenarios = [];
|
|
256
|
+
for (const child of container?.children ?? []) {
|
|
257
|
+
if (child.background) backgrounds.push(child.background);
|
|
258
|
+
if (child.scenario) scenarios.push(child.scenario);
|
|
259
|
+
}
|
|
260
|
+
// Gherkin runs a feature-level Background for every scenario in the
|
|
261
|
+
// feature, Rule-nested ones included, so the rule walk inherits the
|
|
262
|
+
// backgrounds collected above. Recursing before the no-active-scenarios
|
|
263
|
+
// return is what lets a feature whose scenarios all live under Rules still
|
|
264
|
+
// have its Background checked.
|
|
265
|
+
for (const child of container?.children ?? []) {
|
|
266
|
+
if (!child.rule) continue;
|
|
267
|
+
walkContainer(
|
|
268
|
+
child.rule,
|
|
269
|
+
[...inheritedTags, ...tagNames(child.rule)],
|
|
270
|
+
backgrounds,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const active = scenarios.filter(
|
|
274
|
+
(scenario) =>
|
|
275
|
+
![...inheritedTags, ...tagNames(scenario)].some((tag) =>
|
|
276
|
+
exempt.has(tag),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
if (active.length === 0) return;
|
|
280
|
+
pushBackgroundSteps(backgrounds);
|
|
281
|
+
for (const scenario of active) {
|
|
282
|
+
for (const step of scenario.steps ?? []) {
|
|
283
|
+
steps.push({
|
|
284
|
+
line: step.location.line,
|
|
285
|
+
text: step.text,
|
|
286
|
+
examples: scenario.examples ?? [],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
walkContainer(feature, featureTags, []);
|
|
293
|
+
return steps;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Score one feature file's active steps against a scope's step index.
|
|
298
|
+
*
|
|
299
|
+
* A step counts as bound when **any** of its Examples expansions matches. The
|
|
300
|
+
* conservative direction is deliberate: a partially-binding outline step is
|
|
301
|
+
* almost always a parameter-type mismatch in the index, and reporting it would
|
|
302
|
+
* spend the operator's attention on the heuristic rather than on the corpus.
|
|
303
|
+
*
|
|
304
|
+
* @returns {Array<{ kind: 'unbound', file: string, line: number, text: string, scope: string }>}
|
|
305
|
+
*/
|
|
306
|
+
function bindFindings({ feature, file, scope, index, exemptionTags, waivers }) {
|
|
307
|
+
const findings = [];
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
for (const step of collectActiveSteps(feature, exemptionTags)) {
|
|
310
|
+
if (waivers.has(step.text)) continue;
|
|
311
|
+
const key = `${step.line}|${step.text}`;
|
|
312
|
+
if (seen.has(key)) continue;
|
|
313
|
+
seen.add(key);
|
|
314
|
+
const variants = expandStepText(step.text, step.examples);
|
|
315
|
+
if (variants.some((variant) => matchStep(index, variant))) continue;
|
|
316
|
+
findings.push({
|
|
317
|
+
kind: 'unbound',
|
|
318
|
+
file,
|
|
319
|
+
line: step.line,
|
|
320
|
+
text: step.text,
|
|
321
|
+
scope: scope.name,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return findings;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run must-compile then must-bind across one scope.
|
|
329
|
+
*
|
|
330
|
+
* @returns {{ findings: object[], featureCount: number, stepDefinitionCount: number }}
|
|
331
|
+
*/
|
|
332
|
+
export function scanScope({
|
|
333
|
+
scope,
|
|
334
|
+
cwd,
|
|
335
|
+
parser,
|
|
336
|
+
exemptionTags,
|
|
337
|
+
waivers,
|
|
338
|
+
readFile,
|
|
339
|
+
}) {
|
|
340
|
+
const featureFiles = listFeatureFiles(
|
|
341
|
+
scope.featureRoots.map((root) => path.resolve(cwd, root)),
|
|
342
|
+
).sort();
|
|
343
|
+
const stepFiles = listStepFiles(
|
|
344
|
+
scope.stepRoots.map((root) => path.resolve(cwd, root)),
|
|
345
|
+
);
|
|
346
|
+
const index = buildStepIndex({ files: stepFiles, readFile });
|
|
347
|
+
const findings = [];
|
|
348
|
+
|
|
349
|
+
for (const absolute of featureFiles) {
|
|
350
|
+
const file = path.relative(cwd, absolute);
|
|
351
|
+
let document;
|
|
352
|
+
try {
|
|
353
|
+
document = parser.parse(
|
|
354
|
+
readFile ? readFile(absolute) : readFileSync(absolute, 'utf8'),
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Excluded from must-bind: a broken file parses as an arbitrary subset
|
|
358
|
+
// of itself, and its invented findings would bury this one.
|
|
359
|
+
findings.push(...toParseFindings(error, file));
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (!document?.feature) continue;
|
|
363
|
+
findings.push(
|
|
364
|
+
...bindFindings({
|
|
365
|
+
feature: document.feature,
|
|
366
|
+
file,
|
|
367
|
+
scope,
|
|
368
|
+
index,
|
|
369
|
+
exemptionTags,
|
|
370
|
+
waivers,
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
findings,
|
|
377
|
+
featureCount: featureFiles.length,
|
|
378
|
+
stepDefinitionCount: index.entries.length,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The featureRoots half of the blackout contract the stepRoots check already
|
|
384
|
+
* covers on the definitions side. A renamed or typo'd root resolves zero
|
|
385
|
+
* features, and "nothing to check" would then report green over a corpus
|
|
386
|
+
* nobody is checking. A root that exists but holds no `.feature` file yet is
|
|
387
|
+
* the legitimate not-written-them-yet case and stays passing — absence of the
|
|
388
|
+
* directory is what separates misconfiguration from an empty corpus.
|
|
389
|
+
*
|
|
390
|
+
* @param {Array<{ name: string, featureRoots: string[] }>} scopes
|
|
391
|
+
* @param {string} root
|
|
392
|
+
* @returns {string | null} the operator-facing message, or null when clean
|
|
393
|
+
*/
|
|
394
|
+
function findMissingFeatureRoots(scopes, root) {
|
|
395
|
+
for (const scope of scopes) {
|
|
396
|
+
const missing = scope.featureRoots.filter(
|
|
397
|
+
(r) => !fs.existsSync(path.resolve(root, r)),
|
|
398
|
+
);
|
|
399
|
+
if (missing.length === 0) continue;
|
|
400
|
+
return (
|
|
401
|
+
`scope "${scope.name}" names featureRoots that do not exist: ${missing.join(', ')} — ` +
|
|
402
|
+
'every feature in this scope would go unchecked, which is a blackout, ' +
|
|
403
|
+
"not a clean run. Point featureRoots at the directory holding this scope's .feature files."
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Render one finding as a single operator-readable line. */
|
|
410
|
+
export function renderFinding(finding) {
|
|
411
|
+
if (finding.kind === 'parse-error') {
|
|
412
|
+
return `${TAG} parse-error ${finding.file}:${finding.line}:${finding.column} ${finding.message}`;
|
|
413
|
+
}
|
|
414
|
+
return `${TAG} unbound [${finding.scope}] ${finding.file}:${finding.line} ${finding.text}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* CLI body. Exported so the suite can drive the whole pipeline against fixture
|
|
419
|
+
* projects, including the unresolvable-parser path via `loadParser`.
|
|
420
|
+
*
|
|
421
|
+
* @returns {Promise<number>} process exit code
|
|
422
|
+
*/
|
|
423
|
+
export async function runCli({
|
|
424
|
+
argv = process.argv.slice(2),
|
|
425
|
+
cwd = process.cwd(),
|
|
426
|
+
stdout = process.stdout,
|
|
427
|
+
stderr = process.stderr,
|
|
428
|
+
loadParser = loadGherkinParser,
|
|
429
|
+
readFile,
|
|
430
|
+
} = {}) {
|
|
431
|
+
const args = parseArgs(argv);
|
|
432
|
+
const root = path.resolve(args.cwd ?? cwd);
|
|
433
|
+
const config = resolveConfig({ cwd: root, bustCache: true });
|
|
434
|
+
const contract = getGherkinLint(config);
|
|
435
|
+
|
|
436
|
+
if (!contract) {
|
|
437
|
+
stdout.write(
|
|
438
|
+
`${TAG} not configured — add a \`qa.gherkinLint\` block to .agentrc.json to enable this gate.\n`,
|
|
439
|
+
);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const blackout = findMissingFeatureRoots(contract.scopes, root);
|
|
444
|
+
if (blackout) {
|
|
445
|
+
stderr.write(`${TAG} ❌ ${blackout}\n`);
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const corpusSize = contract.scopes.reduce(
|
|
450
|
+
(total, scope) =>
|
|
451
|
+
total +
|
|
452
|
+
listFeatureFiles(scope.featureRoots.map((r) => path.resolve(root, r)))
|
|
453
|
+
.length,
|
|
454
|
+
0,
|
|
455
|
+
);
|
|
456
|
+
if (corpusSize === 0) {
|
|
457
|
+
stdout.write(`${TAG} no .feature files in scope — nothing to check.\n`);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let parser;
|
|
462
|
+
try {
|
|
463
|
+
parser = await loadParser({ cwd: root });
|
|
464
|
+
} catch (error) {
|
|
465
|
+
stderr.write(`${TAG} ❌ ${error.message}\n`);
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const waivers = new Set(contract.stepWaivers);
|
|
470
|
+
const findings = [];
|
|
471
|
+
let checked = 0;
|
|
472
|
+
|
|
473
|
+
for (const scope of contract.scopes) {
|
|
474
|
+
const result = scanScope({
|
|
475
|
+
scope,
|
|
476
|
+
cwd: root,
|
|
477
|
+
parser,
|
|
478
|
+
exemptionTags: contract.exemptionTags,
|
|
479
|
+
waivers,
|
|
480
|
+
readFile,
|
|
481
|
+
});
|
|
482
|
+
if (result.featureCount > 0 && result.stepDefinitionCount === 0) {
|
|
483
|
+
stderr.write(
|
|
484
|
+
`${TAG} ❌ scope "${scope.name}" resolved 0 step definitions from stepRoots: ${scope.stepRoots.join(', ')} — every step would report unbound, which is a blackout, not a finding. Point stepRoots at the directory holding this scope's step definitions.\n`,
|
|
485
|
+
);
|
|
486
|
+
return 1;
|
|
487
|
+
}
|
|
488
|
+
checked += result.featureCount;
|
|
489
|
+
findings.push(...result.findings);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (findings.length === 0) {
|
|
493
|
+
stdout.write(`${TAG} ✅ ${checked} feature file(s) compile and bind.\n`);
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
for (const finding of findings) stderr.write(`${renderFinding(finding)}\n`);
|
|
497
|
+
stderr.write(
|
|
498
|
+
`${TAG} ❌ ${findings.length} finding(s) across ${checked} feature file(s).\n`,
|
|
499
|
+
);
|
|
500
|
+
return 1;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
runAsCli(import.meta.url, () => runCli(), {
|
|
504
|
+
source: 'gherkin-corpus',
|
|
505
|
+
propagateExitCode: true,
|
|
506
|
+
errorPrefix: `${TAG} ❌ Fatal error`,
|
|
507
|
+
usage: HELP,
|
|
508
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// .agents/scripts/check-knip-entries.js — the guard #5001 left unbuilt.
|
|
4
|
+
//
|
|
5
|
+
// Story #5001 made two changes that are individually right and jointly unsafe:
|
|
6
|
+
// it replaced knip's blanket `.agents/scripts/*.js!` entry glob with an
|
|
7
|
+
// explicit list (so an uninvoked CLI surfaces as dead), and it promoted knip's
|
|
8
|
+
// `files` rule to `error` (so whole-file death produces baseline rows). The
|
|
9
|
+
// explicit list is hand-maintained; nothing checked it.
|
|
10
|
+
//
|
|
11
|
+
// So any CLI added after #5001 is, by default, invisible to knip: absent from
|
|
12
|
+
// the entry list, it reads as unreachable, emits a `{ file, symbol: '*' }` row,
|
|
13
|
+
// and drags every lib module only it imports into the dead set with it. Story
|
|
14
|
+
// #5012 hit exactly this — 5 false rows — and the ratchet's natural remedy
|
|
15
|
+
// (accept the diff) would have written live operator CLIs into
|
|
16
|
+
// `baselines/dead-exports-production.json` as expected-dead, permanently. It
|
|
17
|
+
// was caught by luck: a base-sync conflict forced a manual read of the diff.
|
|
18
|
+
//
|
|
19
|
+
// This gate removes the luck. It derives the invoked set from the executable
|
|
20
|
+
// surfaces #5001's own acceptance criterion named — package.json scripts, husky
|
|
21
|
+
// hooks, `.github/workflows`, `.agents` workflow/skill/agent/rule markdown, and
|
|
22
|
+
// script-to-script spawns — and asserts it matches the entry array of whatever
|
|
23
|
+
// configuration knip itself would load, in both directions. See
|
|
24
|
+
// `lib/knip-entry-sync.js` for why liveness means *invoked* rather than
|
|
25
|
+
// *present*, and why documentation prose does not count.
|
|
26
|
+
//
|
|
27
|
+
// Analysis-free by construction: knip's config resolver is loaded (a
|
|
28
|
+
// `knip.config.ts` has to be evaluated, not parsed), but nothing is scanned —
|
|
29
|
+
// no knip run, no scorer, no coverage artifact. Past that it is a directory
|
|
30
|
+
// read and a handful of regexes, which is what keeps it cheap enough to sit in
|
|
31
|
+
// the required-check set next to `check-baseline-scope.js`.
|
|
32
|
+
//
|
|
33
|
+
// Not-applicable is not failure (Story #5039). A repository with no knip
|
|
34
|
+
// configuration at all — and one where `knip` is not installed — exits 0 with a
|
|
35
|
+
// skip line, the same opt-in posture `qa.gherkinLint` uses. Without that, the
|
|
36
|
+
// gate could not be wired into a consumer that does not run knip, and #5001's
|
|
37
|
+
// guard stayed unbuilt everywhere it was most needed. A configuration that
|
|
38
|
+
// EXISTS but cannot be resolved still exits 2: absence and breakage are
|
|
39
|
+
// different answers.
|
|
40
|
+
//
|
|
41
|
+
// Exit codes:
|
|
42
|
+
// 0 entry list matches the invoked set, or there is no configuration to check
|
|
43
|
+
// 1 divergence — a missing, stale, phantom, or unsuffixed entry
|
|
44
|
+
// 2 the check could not run (unresolvable configuration, unusable repository)
|
|
45
|
+
|
|
46
|
+
import process from 'node:process';
|
|
47
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
48
|
+
import {
|
|
49
|
+
countDivergences,
|
|
50
|
+
renderEntrySyncReport,
|
|
51
|
+
resolveEntrySync,
|
|
52
|
+
} from './lib/knip-entry-sync.js';
|
|
53
|
+
|
|
54
|
+
const EXIT_PASS = 0;
|
|
55
|
+
const EXIT_DIVERGED = 1;
|
|
56
|
+
const EXIT_CANNOT_RUN = 2;
|
|
57
|
+
|
|
58
|
+
const HELP = {
|
|
59
|
+
invocation:
|
|
60
|
+
'node .agents/scripts/check-knip-entries.js [--cwd <dir>] [--json]',
|
|
61
|
+
summary:
|
|
62
|
+
"Assert the explicit .agents/scripts entry list in knip's resolved configuration matches the set of CLIs something actually invokes.",
|
|
63
|
+
flags: [
|
|
64
|
+
['--cwd <dir>', 'Repository root to check. Default: process.cwd().'],
|
|
65
|
+
['--json', 'Emit the report as JSON instead of text.'],
|
|
66
|
+
],
|
|
67
|
+
notes: [
|
|
68
|
+
'Resolves the config through knip itself, so every location knip supports works\n(knip.json/.jsonc, .knip.json(c), knip.ts/.js, knip.config.ts/.js,\npackage.json#knip). A TS config is evaluated, so a computed entry array reads\ncorrectly, and per-workspace entries count alongside the top-level array.',
|
|
69
|
+
'Exit codes:\n 0 entry list matches, or there is no config to check (skip)\n 1 divergence\n 2 the check could not run — a config that EXISTS but will not resolve',
|
|
70
|
+
'A missing entry is the dangerous one: knip calls the CLI dead, and accepting\nthe dead-exports diff would record a live CLI as expected-dead (Story #5012).',
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The gate's single exit-code decision, shared by the text and `--json` paths.
|
|
76
|
+
*
|
|
77
|
+
* Kept in one place for the same reason `countDivergences` is: a direction
|
|
78
|
+
* added to the report must not be able to change one path's verdict and not
|
|
79
|
+
* the other's. Skip outranks error outranks divergence — a repository with no
|
|
80
|
+
* configuration has nothing to diverge from.
|
|
81
|
+
*
|
|
82
|
+
* @param {Awaited<ReturnType<typeof resolveEntrySync>>} report
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
function exitCodeFor(report) {
|
|
86
|
+
if (report.skipped) return EXIT_PASS;
|
|
87
|
+
if (report.error) return EXIT_CANNOT_RUN;
|
|
88
|
+
return countDivergences(report) > 0 ? EXIT_DIVERGED : EXIT_PASS;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse argv into an options bag. An unknown flag is a config error rather than
|
|
93
|
+
* a silent no-op, matching `check-baseline-scope.js`.
|
|
94
|
+
*
|
|
95
|
+
* @param {string[]} argv
|
|
96
|
+
* @returns {{ cwd: string | null, json: boolean }}
|
|
97
|
+
*/
|
|
98
|
+
export function parseArgs(argv = []) {
|
|
99
|
+
const out = { cwd: null, json: false };
|
|
100
|
+
let i = 0;
|
|
101
|
+
while (i < argv.length) {
|
|
102
|
+
const arg = argv[i];
|
|
103
|
+
i += 1;
|
|
104
|
+
if (arg === '--json') out.json = true;
|
|
105
|
+
else if (arg === '--cwd') {
|
|
106
|
+
out.cwd = argv[i];
|
|
107
|
+
i += 1;
|
|
108
|
+
} else throw new Error(`unknown flag "${arg}" (try --help)`);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Top-level CLI entry. Exported so tests can drive it against a fixture tree
|
|
115
|
+
* without spawning a process.
|
|
116
|
+
*
|
|
117
|
+
* @param {{
|
|
118
|
+
* argv?: string[],
|
|
119
|
+
* cwd?: string,
|
|
120
|
+
* stdout?: { write: (s: string) => void },
|
|
121
|
+
* stderr?: { write: (s: string) => void },
|
|
122
|
+
* }} [opts]
|
|
123
|
+
* @returns {Promise<number>} 0 pass, 1 divergence, 2 cannot run
|
|
124
|
+
*/
|
|
125
|
+
export async function runCli({
|
|
126
|
+
argv = process.argv.slice(2),
|
|
127
|
+
cwd = process.cwd(),
|
|
128
|
+
stdout = process.stdout,
|
|
129
|
+
stderr = process.stderr,
|
|
130
|
+
} = {}) {
|
|
131
|
+
let args;
|
|
132
|
+
try {
|
|
133
|
+
args = parseArgs(argv);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
stderr.write(`[knip-entries] ❌ ${err?.message ?? String(err)}\n`);
|
|
136
|
+
return EXIT_CANNOT_RUN;
|
|
137
|
+
}
|
|
138
|
+
const report = await resolveEntrySync({ repoRoot: args.cwd ?? cwd });
|
|
139
|
+
|
|
140
|
+
if (args.json) {
|
|
141
|
+
stdout.write(
|
|
142
|
+
`${JSON.stringify({ kind: 'knip-entry-sync', ...report }, null, 2)}\n`,
|
|
143
|
+
);
|
|
144
|
+
} else if (report.skipped) {
|
|
145
|
+
stdout.write(`[knip-entries] ⏭️ not applicable: ${report.skipped}\n`);
|
|
146
|
+
} else if (report.error) {
|
|
147
|
+
stderr.write(`[knip-entries] ❌ ${report.error}\n`);
|
|
148
|
+
} else {
|
|
149
|
+
stdout.write(`\n--- knip-entries ---\n${renderEntrySyncReport(report)}\n`);
|
|
150
|
+
}
|
|
151
|
+
return exitCodeFor(report);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
runAsCli(import.meta.url, async () => runCli(), {
|
|
155
|
+
source: 'knip-entries',
|
|
156
|
+
propagateExitCode: true,
|
|
157
|
+
errorPrefix: '[knip-entries] ❌ Fatal error',
|
|
158
|
+
usage: HELP,
|
|
159
|
+
});
|