@popovych.co/witness 0.4.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/LICENSE +21 -0
- package/LICENSES/frontend-design-Apache-2.0.txt +177 -0
- package/LICENSES/mattpocock-skills-MIT.txt +21 -0
- package/LICENSES/no-mistakes-MIT.txt +21 -0
- package/LICENSES/pr-review-toolkit-Apache-2.0.txt +202 -0
- package/LICENSES/superpowers-MIT.txt +21 -0
- package/NOTICE.md +58 -0
- package/README.md +174 -0
- package/calibration/reviewers/code-reviewer/base/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/base/tests/cart.test.mjs +27 -0
- package/calibration/reviewers/code-reviewer/inject/i1-float-injected/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/inject/i1-float-injected/overlay/src/cart.mjs +31 -0
- package/calibration/reviewers/code-reviewer/seeds/s1-float-money/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s1-float-money/overlay/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/overlay/src/cart.mjs +33 -0
- package/calibration/reviewers/code-reviewer/seeds/s3-boundary/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s3-boundary/overlay/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/seeds/s4-toctou/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s4-toctou/overlay/src/cart.mjs +26 -0
- package/calibration/reviewers/code-reviewer/suite.json +1 -0
- package/calibration/reviewers/design-critic/base/README +3 -0
- package/calibration/reviewers/design-critic/base/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/base/booking-form.md +20 -0
- package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/expect.json +1 -0
- package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/overlay/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/booking-form-design.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/design-language.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/overlay/booking-form-design.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s3-unrealizable/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s3-unrealizable/overlay/booking-form-design.md +19 -0
- package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/overlay/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/suite.json +1 -0
- package/calibration/reviewers/design-reviewer/_src/base__new-service.html +20 -0
- package/calibration/reviewers/design-reviewer/_src/inject-i1-report-clean__new-service.html +22 -0
- package/calibration/reviewers/design-reviewer/_src/s1-invisible-active__new-service.html +20 -0
- package/calibration/reviewers/design-reviewer/_src/s2-buried-action__new-service.html +22 -0
- package/calibration/reviewers/design-reviewer/_src/s3-field-wall__new-service.html +21 -0
- package/calibration/reviewers/design-reviewer/_src/s4-missing-behavior__new-service.html +19 -0
- package/calibration/reviewers/design-reviewer/base/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/note.txt +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/suite.json +1 -0
- package/calibration/reviewers/drift-reviewer/base/spec.md +19 -0
- package/calibration/reviewers/drift-reviewer/base/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/base/tests/token.test.mjs +9 -0
- package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/overlay/src/token.mjs +19 -0
- package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/overlay/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/overlay/src/token.mjs +19 -0
- package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/overlay/src/token.mjs +9 -0
- package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/overlay/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/suite.json +1 -0
- package/calibration/reviewers/plan-critic/base/delta.md +5 -0
- package/calibration/reviewers/plan-critic/base/parent-spec.md +23 -0
- package/calibration/reviewers/plan-critic/base/plan.md +27 -0
- package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/expect.json +1 -0
- package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/overlay/plan.md +28 -0
- package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/overlay/plan.md +26 -0
- package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/overlay/plan.md +26 -0
- package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/overlay/plan.md +29 -0
- package/calibration/reviewers/plan-critic/seeds/s4-broken-order/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s4-broken-order/overlay/plan.md +28 -0
- package/calibration/reviewers/plan-critic/suite.json +1 -0
- package/calibration/reviewers/pr-test/base/src/discount.mjs +8 -0
- package/calibration/reviewers/pr-test/base/tests/discount.test.mjs +16 -0
- package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/expect.json +1 -0
- package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/overlay/tests/discount.test.mjs +15 -0
- package/calibration/reviewers/pr-test/seeds/s1-assertion-free/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s1-assertion-free/overlay/tests/discount.test.mjs +12 -0
- package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/overlay/tests/discount.test.mjs +14 -0
- package/calibration/reviewers/pr-test/seeds/s3-missing-branch/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s3-missing-branch/overlay/tests/discount.test.mjs +14 -0
- package/calibration/reviewers/pr-test/seeds/s4-skipped-red/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s4-skipped-red/overlay/tests/discount.test.mjs +17 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/src/banner.mjs +29 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/tests/banner.test.mjs +61 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-page.mjs +24 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-server.mjs +36 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/tests/redeem.e2e.test.mjs +56 -0
- package/calibration/reviewers/pr-test/suite.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/base/src/sync.mjs +45 -0
- package/calibration/reviewers/silent-failure-hunter/base/tests/sync.test.mjs +7 -0
- package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/overlay/src/sync.mjs +50 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/overlay/src/sync.mjs +42 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/overlay/src/sync.mjs +49 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/overlay/src/sync.mjs +38 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/overlay/src/sync.mjs +49 -0
- package/calibration/reviewers/silent-failure-hunter/suite.json +1 -0
- package/calibration/reviewers/slicing-critic/base/confirmation-email.md +21 -0
- package/calibration/reviewers/slicing-critic/base/coverage.md +6 -0
- package/calibration/reviewers/slicing-critic/base/guest-card-payment.md +26 -0
- package/calibration/reviewers/slicing-critic/base/recap.json +14 -0
- package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/overlay/guest-card-payment.md +29 -0
- package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/overlay/guest-card-payment.md +23 -0
- package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/overlay/guest-card-payment.md +27 -0
- package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/overlay/confirmation-email.md +19 -0
- package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/overlay/confirmation-email.md +19 -0
- package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/overlay/guest-card-payment.md +28 -0
- package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/overlay/confirmation-email.md +22 -0
- package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/overlay/confirmation-email.md +24 -0
- package/calibration/reviewers/slicing-critic/suite.json +1 -0
- package/calibration/reviewers/type-design/base/src/order.ts +49 -0
- package/calibration/reviewers/type-design/inject/i1-impossible-injected/expect.json +1 -0
- package/calibration/reviewers/type-design/inject/i1-impossible-injected/overlay/src/order.ts +21 -0
- package/calibration/reviewers/type-design/seeds/s1-stringly/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s1-stringly/overlay/src/order.ts +11 -0
- package/calibration/reviewers/type-design/seeds/s2-impossible-states/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s2-impossible-states/overlay/src/order.ts +20 -0
- package/calibration/reviewers/type-design/seeds/s3-public-mutable/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s3-public-mutable/overlay/src/order.ts +26 -0
- package/calibration/reviewers/type-design/seeds/s4-any-leak/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s4-any-leak/overlay/src/order.ts +29 -0
- package/calibration/reviewers/type-design/suite.json +1 -0
- package/calibration/skills/decompose/seeds/s1-plain-feature/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s1-plain-feature/recap.json +11 -0
- package/calibration/skills/decompose/seeds/s2-nongoal-bait/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s2-nongoal-bait/recap.json +8 -0
- package/calibration/skills/decompose/seeds/s3-duplicated-fact/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s3-duplicated-fact/recap.json +11 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/checkout-flow.md +19 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/payment-retry.md +20 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/recap.json +8 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/canon/checkout-flow.md +19 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/recap.json +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/plan.body.md +5 -0
- package/calibration/skills/implement/seeds/s1-add-function/plan.meta.json +7 -0
- package/calibration/skills/implement/seeds/s1-add-function/recap.json +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s1-add-function/repo/src/clamp.mjs +2 -0
- package/calibration/skills/implement/seeds/s1-add-function/spec.body.md +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/spec.meta.json +8 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/plan.body.md +6 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/plan.meta.json +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/recap.json +8 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/src/slug.mjs +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/tests/slug.test.mjs +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/spec.body.md +9 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/spec.meta.json +8 -0
- package/calibration/skills/implement/seeds/s3-two-steps/plan.body.md +11 -0
- package/calibration/skills/implement/seeds/s3-two-steps/plan.meta.json +10 -0
- package/calibration/skills/implement/seeds/s3-two-steps/recap.json +8 -0
- package/calibration/skills/implement/seeds/s3-two-steps/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s3-two-steps/repo/src/pad.mjs +2 -0
- package/calibration/skills/implement/seeds/s3-two-steps/spec.body.md +9 -0
- package/calibration/skills/implement/seeds/s3-two-steps/spec.meta.json +11 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/expect.json +1 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/parent.body.md +10 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/parent.meta.json +11 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/recap.json +8 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/expect.json +1 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/parent.body.md +11 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/parent.meta.json +14 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/recap.json +8 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/expect.json +1 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/parent.body.md +10 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/parent.meta.json +11 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/recap.json +8 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/expect.json +1 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.body.md +9 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.meta.json +10 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/recap.json +8 -0
- package/calibration.yaml +5 -0
- package/dist/abandon.js +154 -0
- package/dist/adopt.js +42 -0
- package/dist/allowlist.js +29 -0
- package/dist/bin.js +32 -0
- package/dist/calibrate.js +623 -0
- package/dist/cli.js +98 -0
- package/dist/config.js +155 -0
- package/dist/criteria.js +85 -0
- package/dist/design.js +104 -0
- package/dist/drift.js +280 -0
- package/dist/dsl.js +68 -0
- package/dist/evidence.js +221 -0
- package/dist/fm.js +51 -0
- package/dist/gate.js +371 -0
- package/dist/gates/decompose.js +90 -0
- package/dist/gates/design.js +84 -0
- package/dist/gates/implement.js +144 -0
- package/dist/gates/index.js +6 -0
- package/dist/gates/plan.js +86 -0
- package/dist/gates/ship.js +98 -0
- package/dist/gitio.js +97 -0
- package/dist/glob.js +32 -0
- package/dist/harness.js +229 -0
- package/dist/history.js +35 -0
- package/dist/install.js +133 -0
- package/dist/journal.js +43 -0
- package/dist/junit.js +54 -0
- package/dist/lock.js +37 -0
- package/dist/matcher.js +52 -0
- package/dist/model.js +73 -0
- package/dist/needs.js +34 -0
- package/dist/opener.js +38 -0
- package/dist/pin.js +28 -0
- package/dist/probe.js +14 -0
- package/dist/refusal.js +16 -0
- package/dist/reviewed.js +69 -0
- package/dist/reviewer.js +133 -0
- package/dist/rounds.js +112 -0
- package/dist/runner.js +73 -0
- package/dist/scan.js +67 -0
- package/dist/schemas.js +119 -0
- package/dist/sha.js +27 -0
- package/dist/ship.js +257 -0
- package/dist/stamp.js +104 -0
- package/dist/toon.js +16 -0
- package/dist/txn.js +70 -0
- package/dist/verbs/abandon.js +105 -0
- package/dist/verbs/adopt.js +118 -0
- package/dist/verbs/calibrate.js +130 -0
- package/dist/verbs/check.js +233 -0
- package/dist/verbs/clean.js +34 -0
- package/dist/verbs/dashboard.js +172 -0
- package/dist/verbs/decide.js +309 -0
- package/dist/verbs/design.js +203 -0
- package/dist/verbs/diff.js +60 -0
- package/dist/verbs/dispatch.js +102 -0
- package/dist/verbs/evidence.js +118 -0
- package/dist/verbs/gate.js +12 -0
- package/dist/verbs/index-verb.js +34 -0
- package/dist/verbs/init.js +190 -0
- package/dist/verbs/log.js +151 -0
- package/dist/verbs/next.js +437 -0
- package/dist/verbs/recap.js +145 -0
- package/dist/verbs/recover.js +52 -0
- package/dist/verbs/rename.js +99 -0
- package/dist/verbs/satisfy.js +69 -0
- package/dist/verbs/ship.js +11 -0
- package/dist/verbs/start.js +133 -0
- package/dist/verbs/sync.js +45 -0
- package/dist/verbs/write.js +349 -0
- package/dist/verdict.js +257 -0
- package/dist/worktree.js +73 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +19 -0
- package/plugin/commands/witness.md +46 -0
- package/plugin/hooks/canon-guard.mjs +105 -0
- package/plugin/hooks/guard-state.mjs +34 -0
- package/plugin/hooks/hooks.json +17 -0
- package/plugin/hooks/session-dashboard.sh +6 -0
- package/plugin/hooks/witness-pi.ts +67 -0
- package/plugin/skills/witness-brainstorm/SKILL.md +70 -0
- package/plugin/skills/witness-decompose/SKILL.md +99 -0
- package/plugin/skills/witness-design/SKILL.md +83 -0
- package/plugin/skills/witness-implement/SKILL.md +86 -0
- package/plugin/skills/witness-plan/SKILL.md +93 -0
- package/plugin/skills/witness-ship/SKILL.md +63 -0
- package/prompts/code-reviewer.md +64 -0
- package/prompts/design-critic.md +35 -0
- package/prompts/design-reviewer.md +70 -0
- package/prompts/drift-reviewer.md +40 -0
- package/prompts/plan-critic.md +37 -0
- package/prompts/pr-test.md +97 -0
- package/prompts/silent-failure-hunter.md +145 -0
- package/prompts/slicing-critic.md +46 -0
- package/prompts/type-design.md +113 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { EXIT } from '../cli.js';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { designPending, designRel, designStamp } from '../design.js';
|
|
7
|
+
import { ID_RE } from '../dsl.js';
|
|
8
|
+
import { serializeDoc, splitDoc, writeDoc } from '../fm.js';
|
|
9
|
+
import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
|
|
10
|
+
import { appendEntry, entryLine, journalRel, latestRecap, readStream, streamExists } from '../journal.js';
|
|
11
|
+
import { acquireLock } from '../lock.js';
|
|
12
|
+
import { renderRefusal, v } from '../refusal.js';
|
|
13
|
+
import { baseForSpec, contentAtSha } from '../history.js';
|
|
14
|
+
import { findById, findCycle, loadCanon } from '../scan.js';
|
|
15
|
+
import { canonicalJson, canonicalSha, short } from '../sha.js';
|
|
16
|
+
import { kv } from '../toon.js';
|
|
17
|
+
import { crashPoint, guardTxn, withTxn } from '../txn.js';
|
|
18
|
+
import { validateDoc } from '../schemas.js';
|
|
19
|
+
function buildSpecMeta(root, id, manifest, recap, canon, existing, body) {
|
|
20
|
+
const violations = [];
|
|
21
|
+
const type = manifest.type;
|
|
22
|
+
if (recap.class === 'chore') {
|
|
23
|
+
violations.push(v('type', 'class-tripwire', `chore effort writing ${type} content`, 'chores never change state — reclassify via recap --amend, or write a plan'));
|
|
24
|
+
}
|
|
25
|
+
const covers = Array.isArray(manifest.covers) ? manifest.covers : [];
|
|
26
|
+
const goalIds = new Set(recap.goals.map((g) => g.id));
|
|
27
|
+
covers.forEach((g, i) => {
|
|
28
|
+
if (!goalIds.has(g))
|
|
29
|
+
violations.push(v(`covers[${i}]`, 'unknown-goal', String(g), `a goal id from the latest recap: ${[...goalIds].join(' ')}`));
|
|
30
|
+
});
|
|
31
|
+
const meta = {
|
|
32
|
+
id,
|
|
33
|
+
type,
|
|
34
|
+
status: 'draft',
|
|
35
|
+
...(type === 'spec' ? { summary: manifest.summary } : {}),
|
|
36
|
+
...(type === 'spec' && manifest.ui !== undefined ? { ui: manifest.ui } : {}),
|
|
37
|
+
depends: manifest.depends ?? [],
|
|
38
|
+
needs: manifest.needs ?? [],
|
|
39
|
+
...(type === 'spec' ? { criteria: manifest.criteria } : {}),
|
|
40
|
+
...(manifest.supersedes !== undefined ? { supersedes: manifest.supersedes } : {}),
|
|
41
|
+
};
|
|
42
|
+
if (existing) {
|
|
43
|
+
for (const volatile of ['drift', 'pr', 'design']) {
|
|
44
|
+
if (existing.meta[volatile] !== undefined)
|
|
45
|
+
meta[volatile] = existing.meta[volatile];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
violations.push(...validateDoc(meta, body));
|
|
49
|
+
const depends = Array.isArray(meta.depends) ? meta.depends : [];
|
|
50
|
+
depends.forEach((dep, i) => {
|
|
51
|
+
if (dep !== id && !findById(canon, dep))
|
|
52
|
+
violations.push(v(`depends[${i}]`, 'unknown-dep', dep, 'an existing doc id'));
|
|
53
|
+
});
|
|
54
|
+
const cycle = findCycle(canon, { id, depends });
|
|
55
|
+
if (cycle)
|
|
56
|
+
violations.push(v('depends', 'cycle', cycle.join(' -> '), 'an acyclic depends graph'));
|
|
57
|
+
if (typeof manifest.supersedes === 'string' && !findById(canon, manifest.supersedes) && !streamExists(root, manifest.supersedes)) {
|
|
58
|
+
violations.push(v('supersedes', 'unknown-ref', manifest.supersedes, 'an existing spec id or frozen journal stream'));
|
|
59
|
+
}
|
|
60
|
+
return { meta, covers, violations, warnings: [] };
|
|
61
|
+
}
|
|
62
|
+
export async function run(ctx, argv) {
|
|
63
|
+
const { values, positionals } = parseArgs({
|
|
64
|
+
args: argv,
|
|
65
|
+
options: { effort: { type: 'string' }, meta: { type: 'string' }, body: { type: 'string' } },
|
|
66
|
+
allowPositionals: true,
|
|
67
|
+
});
|
|
68
|
+
const id = positionals[0];
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!id || !ID_RE.test(id))
|
|
71
|
+
missing.push(v('id', 'id-charset', String(id ?? 'absent'), 'witness write <id> with [a-z0-9-]+'));
|
|
72
|
+
for (const flag of ['effort', 'meta', 'body']) {
|
|
73
|
+
if (!values[flag])
|
|
74
|
+
missing.push(v(`--${flag}`, 'required', 'absent', `--${flag} <value>`));
|
|
75
|
+
}
|
|
76
|
+
if (missing.length) {
|
|
77
|
+
renderRefusal(missing).forEach(ctx.err);
|
|
78
|
+
return EXIT.REFUSED;
|
|
79
|
+
}
|
|
80
|
+
const rootRes = primaryRoot(ctx.cwd);
|
|
81
|
+
if (!rootRes.ok) {
|
|
82
|
+
renderRefusal(rootRes.violations).forEach(ctx.err);
|
|
83
|
+
return EXIT.REFUSED;
|
|
84
|
+
}
|
|
85
|
+
const root = rootRes.value;
|
|
86
|
+
const blocked = guardTxn(ctx, root);
|
|
87
|
+
if (blocked !== undefined)
|
|
88
|
+
return blocked;
|
|
89
|
+
const cfg = loadConfig(root);
|
|
90
|
+
if (!cfg.ok) {
|
|
91
|
+
renderRefusal(cfg.violations).forEach(ctx.err);
|
|
92
|
+
return EXIT.REFUSED;
|
|
93
|
+
}
|
|
94
|
+
if (cfg.value.warning)
|
|
95
|
+
ctx.err(`warn: ${cfg.value.warning}`);
|
|
96
|
+
const effort = values.effort;
|
|
97
|
+
if (!streamExists(root, effort)) {
|
|
98
|
+
renderRefusal([v('--effort', 'unknown-effort', effort, 'an effort born by witness recap')]).forEach(ctx.err);
|
|
99
|
+
return EXIT.REFUSED;
|
|
100
|
+
}
|
|
101
|
+
const recap = latestRecap(root, effort);
|
|
102
|
+
if (!recap) {
|
|
103
|
+
renderRefusal([v('--effort', 'unknown-effort', `${effort} has no recap entry`, 'an effort born by witness recap')]).forEach(ctx.err);
|
|
104
|
+
return EXIT.REFUSED;
|
|
105
|
+
}
|
|
106
|
+
let manifest;
|
|
107
|
+
let body;
|
|
108
|
+
try {
|
|
109
|
+
// resolve, not join: absolute --meta/--body paths (scratch dirs) must be honored as-is
|
|
110
|
+
manifest = JSON.parse(readFileSync(resolve(ctx.cwd, values.meta), 'utf8'));
|
|
111
|
+
body = readFileSync(resolve(ctx.cwd, values.body), 'utf8');
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
renderRefusal([v('--meta/--body', 'input-unreadable', String(e.message).slice(0, 120), 'readable manifest JSON and body file')]).forEach(ctx.err);
|
|
115
|
+
return EXIT.REFUSED;
|
|
116
|
+
}
|
|
117
|
+
const lock = acquireLock(root);
|
|
118
|
+
if (!lock.ok) {
|
|
119
|
+
renderRefusal(lock.violations).forEach(ctx.err);
|
|
120
|
+
return EXIT.BLOCKED;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const canon = loadCanon(root);
|
|
124
|
+
const existing = findById(canon, id);
|
|
125
|
+
const refuseWrite = (viol) => {
|
|
126
|
+
journalRefusal(ctx, root, effort, id, viol);
|
|
127
|
+
renderRefusal(viol).forEach(ctx.err);
|
|
128
|
+
return EXIT.REFUSED;
|
|
129
|
+
};
|
|
130
|
+
const built = manifest.type === 'plan'
|
|
131
|
+
? buildPlanMeta(root, id, manifest, recap, canon, existing, body)
|
|
132
|
+
: buildSpecMeta(root, id, manifest, recap, canon, existing, body);
|
|
133
|
+
if (existing && existing.meta.type !== manifest.type) {
|
|
134
|
+
built.violations.push(v('type', 'type-immutable', `${id} is a ${existing.meta.type}`, 'amendments keep the original type'));
|
|
135
|
+
}
|
|
136
|
+
if (built.violations.length)
|
|
137
|
+
return refuseWrite(built.violations);
|
|
138
|
+
built.warnings.forEach((w) => ctx.err(`warn: ${w}`));
|
|
139
|
+
// supersedes effects (re-slice): resolved after validation, before the txn, so a
|
|
140
|
+
// refusal here never touches disk. Applies only to specs — buildPlanMeta never sets
|
|
141
|
+
// meta.supersedes, so this is a no-op for plan writes.
|
|
142
|
+
const supersedes = built.meta.supersedes === undefined ? undefined : String(built.meta.supersedes);
|
|
143
|
+
let reslice;
|
|
144
|
+
if (supersedes !== undefined) {
|
|
145
|
+
if (supersedes === id) {
|
|
146
|
+
return refuseWrite([v('supersedes', 'self-supersede', id, 'a different spec')]);
|
|
147
|
+
}
|
|
148
|
+
if ((manifest.depends ?? []).includes(supersedes)) {
|
|
149
|
+
return refuseWrite([v('depends', 'supersedes-in-depends', supersedes, 'a re-slice must not depend on the doc it deletes')]);
|
|
150
|
+
}
|
|
151
|
+
const target = findById(canon, supersedes);
|
|
152
|
+
if (!target) {
|
|
153
|
+
const alreadyTerminal = readStream(root, supersedes)
|
|
154
|
+
.some((e) => e.t === 'status' && e.to === 'superseded');
|
|
155
|
+
if (!alreadyTerminal) {
|
|
156
|
+
return refuseWrite([v('supersedes', 'unknown-supersedes', supersedes, 'an existing spec (or one already journal-terminal)')]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const dependents = canon.docs
|
|
161
|
+
.filter((d) => String(d.meta.id) !== id)
|
|
162
|
+
.filter((d) => (d.meta.depends ?? []).includes(supersedes))
|
|
163
|
+
.map((d) => String(d.meta.id));
|
|
164
|
+
if (dependents.length > 0) {
|
|
165
|
+
return refuseWrite([v('supersedes', 'dangling-depends', dependents.join(' '), 'dependents rewritten first — the re-slice rewrites them before deleting')]);
|
|
166
|
+
}
|
|
167
|
+
const entry = {
|
|
168
|
+
v: 1, t: 'status', artifact: supersedes, from: String(target.meta.status),
|
|
169
|
+
to: 'superseded', cause: 'supersede', by: id,
|
|
170
|
+
};
|
|
171
|
+
reslice = { doc: target, entry, line: entryLine(entry) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const rel = existing ? existing.rel : `${manifest.type === 'plan' ? cfg.value.paths.plans : cfg.value.paths.specs}/${id}.md`;
|
|
175
|
+
const stream = journalRel(effort);
|
|
176
|
+
const unrelated = dirtyStatePaths(root).filter((p) => p !== rel && p !== stream);
|
|
177
|
+
if (unrelated.length) {
|
|
178
|
+
renderRefusal(unrelated.map((p) => v(p, 'unrelated-dirty', 'uncommitted change on a state path', 'revert or re-apply via witness write, then re-run'))).forEach(ctx.err);
|
|
179
|
+
return EXIT.REFUSED;
|
|
180
|
+
}
|
|
181
|
+
// serializeDoc normalizes body whitespace (leading/trailing) on write, and splitDoc
|
|
182
|
+
// reflects that on every future read — hashing the raw pre-write body here would
|
|
183
|
+
// make this entry's sha permanently disagree with canonicalSha(reReadDoc), even
|
|
184
|
+
// when nothing was ever amended. Round-trip through the same normalization so the
|
|
185
|
+
// journaled sha matches what every later loadCanon()/readDoc() will compute.
|
|
186
|
+
const roundTripped = splitDoc(serializeDoc({ meta: built.meta, body }));
|
|
187
|
+
const sha = roundTripped.ok
|
|
188
|
+
? canonicalSha(roundTripped.value.meta, roundTripped.value.body)
|
|
189
|
+
: canonicalSha(built.meta, body);
|
|
190
|
+
const entry = {
|
|
191
|
+
t: 'write', effort, artifact: id, sha,
|
|
192
|
+
...(manifest.type === 'plan' ? {} : { covers: built.covers }),
|
|
193
|
+
...(existing === undefined ? { created: true } : {}),
|
|
194
|
+
...(supersedes !== undefined ? { supersedes } : {}),
|
|
195
|
+
};
|
|
196
|
+
const line = JSON.stringify({ v: 1, ...entry });
|
|
197
|
+
const supersededDesignRel = reslice ? designRel(cfg.value.paths, reslice.entry.artifact) : undefined;
|
|
198
|
+
const files = [rel, stream,
|
|
199
|
+
...(reslice ? [reslice.doc.rel, journalRel(reslice.entry.artifact)] : []),
|
|
200
|
+
...(supersededDesignRel && existsSync(join(root, supersededDesignRel)) ? [supersededDesignRel] : []),
|
|
201
|
+
];
|
|
202
|
+
const res = withTxn(root, {
|
|
203
|
+
op: `write(${id})`, files, journal: { stream: effort, line },
|
|
204
|
+
...(reslice ? { journalMulti: [{ stream: reslice.entry.artifact, line: reslice.line }] } : {}),
|
|
205
|
+
}, () => {
|
|
206
|
+
writeDoc(join(root, rel), { meta: built.meta, body });
|
|
207
|
+
crashPoint(ctx.env, 'artifact-write');
|
|
208
|
+
if (reslice) {
|
|
209
|
+
unlinkSync(join(root, reslice.doc.rel));
|
|
210
|
+
const supersededDesign = designRel(cfg.value.paths, reslice.entry.artifact);
|
|
211
|
+
if (existsSync(join(root, supersededDesign))) {
|
|
212
|
+
unlinkSync(join(root, supersededDesign));
|
|
213
|
+
}
|
|
214
|
+
appendEntry(root, reslice.entry.artifact, reslice.entry);
|
|
215
|
+
crashPoint(ctx.env, 'reslice-commit');
|
|
216
|
+
}
|
|
217
|
+
appendEntry(root, effort, entry);
|
|
218
|
+
crashPoint(ctx.env, 'journal-append');
|
|
219
|
+
return stateCommit(root, files, `write(${id}): ${existing ? 'amend' : 'create'} ${manifest.type}`);
|
|
220
|
+
});
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
renderRefusal(res.violations).forEach(ctx.err);
|
|
223
|
+
return EXIT.REFUSED;
|
|
224
|
+
}
|
|
225
|
+
ctx.out(kv('written', id));
|
|
226
|
+
ctx.out(kv('path', rel));
|
|
227
|
+
ctx.out(kv('sha', short(sha)));
|
|
228
|
+
ctx.out(kv('status', 'draft'));
|
|
229
|
+
ctx.out('next: witness check · witness index');
|
|
230
|
+
return EXIT.OK;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
lock.value();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function journalRefusal(ctx, root, effort, artifact, violations) {
|
|
237
|
+
const entry = {
|
|
238
|
+
t: 'write-refused', effort, artifact,
|
|
239
|
+
rules: violations.map(({ field, rule }) => ({ field, rule })),
|
|
240
|
+
};
|
|
241
|
+
const line = JSON.stringify({ v: 1, ...entry });
|
|
242
|
+
const stream = journalRel(effort);
|
|
243
|
+
const res = withTxn(root, { op: `write-refused(${artifact})`, files: [stream], journal: { stream: effort, line } }, () => {
|
|
244
|
+
appendEntry(root, effort, entry);
|
|
245
|
+
return stateCommit(root, [stream], `write-refused(${artifact})`);
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok)
|
|
248
|
+
ctx.err('warn: refusal could not be journaled (state paths dirty)');
|
|
249
|
+
}
|
|
250
|
+
function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
|
|
251
|
+
const violations = [];
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const parentId = typeof manifest.parent === 'string' ? manifest.parent : '';
|
|
254
|
+
const parent = parentId ? findById(canon, parentId) : undefined;
|
|
255
|
+
if (!parent) {
|
|
256
|
+
violations.push(v('parent', 'unknown-parent', parentId || 'absent', 'an existing spec id (or principles for chores)'));
|
|
257
|
+
return { meta: {}, covers: [], violations, warnings };
|
|
258
|
+
}
|
|
259
|
+
if (parent.meta.type === 'principles' && recap.class !== 'chore') {
|
|
260
|
+
violations.push(v('parent', 'class-mismatch', `principles parent on a ${recap.class} effort`, 'feature and fix plans derive from a spec'));
|
|
261
|
+
}
|
|
262
|
+
else if (parent.meta.type !== 'spec' && parent.meta.type !== 'principles') {
|
|
263
|
+
violations.push(v('parent', 'kind', String(parent.meta.type), 'a spec (or principles for chores)'));
|
|
264
|
+
}
|
|
265
|
+
if (parent.meta.status === 'draft') {
|
|
266
|
+
violations.push(v('parent', 'parent-not-approved', String(parentId), 'a parent stamped approved by its gate — run: witness gate decompose --effort <slug>'));
|
|
267
|
+
}
|
|
268
|
+
const computed = canonicalSha(parent.meta, parent.body);
|
|
269
|
+
const supplied = manifest['derives-from'];
|
|
270
|
+
if (typeof supplied === 'string' && supplied !== computed) {
|
|
271
|
+
violations.push(v('derives-from', 'stale-derivation', short(supplied), `current parent content is ${short(computed)} — re-derive from witness diff ${parentId}`));
|
|
272
|
+
}
|
|
273
|
+
const parentUi = parent.meta.ui === true;
|
|
274
|
+
const suppliedDesignFrom = manifest['design-from'];
|
|
275
|
+
if (!parentUi && suppliedDesignFrom !== undefined) {
|
|
276
|
+
violations.push(v('design-from', 'not-ui-parent', String(parentId), 'omit design-from — the parent spec is not ui-flagged'));
|
|
277
|
+
}
|
|
278
|
+
let designFrom;
|
|
279
|
+
if (parentUi) {
|
|
280
|
+
const stamp = designStamp(parent);
|
|
281
|
+
if (!stamp || designPending(root, parent)) {
|
|
282
|
+
violations.push(v('design-from', 'design-not-approved', String(parentId), `an approved, current design — run: witness design ${parentId} --file <html> && witness gate design ${parentId}`));
|
|
283
|
+
}
|
|
284
|
+
else if (suppliedDesignFrom !== stamp.sha) {
|
|
285
|
+
violations.push(v('design-from', 'stale-design', short(String(suppliedDesignFrom ?? 'absent')), `the parent's current design sha ${short(stamp.sha)} — pin it in the manifest`));
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
designFrom = stamp.sha;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const meta = {
|
|
292
|
+
id,
|
|
293
|
+
type: 'plan',
|
|
294
|
+
status: 'draft',
|
|
295
|
+
parent: parentId,
|
|
296
|
+
'derives-from': computed,
|
|
297
|
+
...(designFrom !== undefined ? { 'design-from': designFrom } : {}),
|
|
298
|
+
depends: manifest.depends ?? [],
|
|
299
|
+
needs: manifest.needs ?? [],
|
|
300
|
+
steps: manifest.steps,
|
|
301
|
+
};
|
|
302
|
+
if (existing?.meta.pr !== undefined)
|
|
303
|
+
meta.pr = existing.meta.pr;
|
|
304
|
+
violations.push(...validateDoc(meta, body));
|
|
305
|
+
const parentCriteria = Array.isArray(parent.meta.criteria)
|
|
306
|
+
? parent.meta.criteria
|
|
307
|
+
: [];
|
|
308
|
+
const currentIds = new Set(parentCriteria.map((c) => String(c.id)));
|
|
309
|
+
const steps = Array.isArray(manifest.steps) ? manifest.steps : [];
|
|
310
|
+
const referenced = new Set();
|
|
311
|
+
steps.forEach((s, i) => {
|
|
312
|
+
const refs = Array.isArray(s.criteria) ? s.criteria : [];
|
|
313
|
+
refs.forEach((ref, j) => {
|
|
314
|
+
if (!currentIds.has(ref)) {
|
|
315
|
+
violations.push(v(`steps[${i}].criteria[${j}]`, 'unknown-criterion', ref, `a criterion id on ${parentId}: ${[...currentIds].join(' ') || '(none)'}`));
|
|
316
|
+
}
|
|
317
|
+
referenced.add(ref);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
const delta = deltaCriteriaIds(root, canon, parent, id);
|
|
321
|
+
const uncovered = [...delta].filter((c) => !referenced.has(c));
|
|
322
|
+
if (uncovered.length) {
|
|
323
|
+
violations.push(v('steps', 'criteria-uncovered', uncovered.join('+'), 'every delta criterion realized by >=1 step'));
|
|
324
|
+
}
|
|
325
|
+
const depends = Array.isArray(meta.depends) ? meta.depends : [];
|
|
326
|
+
depends.forEach((dep, i) => {
|
|
327
|
+
if (dep !== id && !findById(canon, dep))
|
|
328
|
+
violations.push(v(`depends[${i}]`, 'unknown-dep', dep, 'an existing doc id'));
|
|
329
|
+
});
|
|
330
|
+
const cycle = findCycle(canon, { id, depends });
|
|
331
|
+
if (cycle)
|
|
332
|
+
violations.push(v('depends', 'cycle', cycle.join(' -> '), 'an acyclic depends graph'));
|
|
333
|
+
return { meta, covers: [], violations, warnings };
|
|
334
|
+
}
|
|
335
|
+
function deltaCriteriaIds(root, canon, parent, excludePlanId) {
|
|
336
|
+
const current = Array.isArray(parent.meta.criteria)
|
|
337
|
+
? parent.meta.criteria
|
|
338
|
+
: [];
|
|
339
|
+
const all = new Set(current.map((c) => String(c.id)));
|
|
340
|
+
const base = baseForSpec(root, canon, String(parent.meta.id), excludePlanId);
|
|
341
|
+
if (base.kind === 'empty')
|
|
342
|
+
return all;
|
|
343
|
+
const baseDoc = contentAtSha(root, parent.rel, base.sha);
|
|
344
|
+
if (!baseDoc)
|
|
345
|
+
return all;
|
|
346
|
+
const baseById = new Map((Array.isArray(baseDoc.meta.criteria) ? baseDoc.meta.criteria : [])
|
|
347
|
+
.map((c) => [String(c.id), canonicalJson(c)]));
|
|
348
|
+
return new Set(current.filter((c) => baseById.get(String(c.id)) !== canonicalJson(c)).map((c) => String(c.id)));
|
|
349
|
+
}
|
package/dist/verdict.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, normalize } from 'node:path';
|
|
3
|
+
import { ok, refuse, v } from './refusal.js';
|
|
4
|
+
const isOmission = (a) => typeof a === 'object' && a !== null &&
|
|
5
|
+
a.kind === 'omission' &&
|
|
6
|
+
typeof a.scope === 'string';
|
|
7
|
+
const isAnchor = (a) => (typeof a === 'string' && a.trim() !== '') || isOmission(a);
|
|
8
|
+
export function parseVerdict(raw) {
|
|
9
|
+
const violations = [];
|
|
10
|
+
const obj = raw;
|
|
11
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
12
|
+
return refuse([v('verdict', 'not-object', typeof raw, 'a JSON object')]);
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(obj.coverage)) {
|
|
15
|
+
violations.push(v('coverage', 'coverage-missing', String(obj.coverage), 'required [{anchor, note}] — a clean verdict must prove reading'));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
obj.coverage.forEach((c, i) => {
|
|
19
|
+
const item = c;
|
|
20
|
+
if (!isAnchor(item?.anchor))
|
|
21
|
+
violations.push(v(`coverage[${i}].anchor`, 'anchor-shape', JSON.stringify(item?.anchor), 'a heading path, file[#symbol], or {kind: "omission", scope}'));
|
|
22
|
+
if (typeof item?.note !== 'string')
|
|
23
|
+
violations.push(v(`coverage[${i}].note`, 'note-missing', String(item?.note), 'a short note per coverage anchor'));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(obj.findings)) {
|
|
27
|
+
violations.push(v('findings', 'findings-missing', String(obj.findings), 'an array (empty means clean)'));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
obj.findings.forEach((f, i) => {
|
|
31
|
+
const item = f;
|
|
32
|
+
if (typeof item?.blocking !== 'boolean')
|
|
33
|
+
violations.push(v(`findings[${i}].blocking`, 'blocking-shape', String(item?.blocking), 'true | false — the one calibrated bit'));
|
|
34
|
+
if (!isAnchor(item?.anchor))
|
|
35
|
+
violations.push(v(`findings[${i}].anchor`, 'anchor-shape', JSON.stringify(item?.anchor), 'a heading path, file[#symbol], or {kind: "omission", scope}'));
|
|
36
|
+
if (typeof item?.claim !== 'string' || item.claim.trim() === '')
|
|
37
|
+
violations.push(v(`findings[${i}].claim`, 'claim-missing', String(item?.claim), 'a one-sentence claim'));
|
|
38
|
+
const cp = item.contradicts_pin;
|
|
39
|
+
if (cp !== undefined && (!Number.isInteger(cp) || cp < 1)) {
|
|
40
|
+
violations.push(v(`findings[${i}].contradicts_pin`, 'contradicts-pin-shape', String(cp), 'a pin ordinal (integer ≥1), or omit'));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (violations.length > 0)
|
|
45
|
+
return refuse(violations);
|
|
46
|
+
return ok({ coverage: obj.coverage, findings: obj.findings });
|
|
47
|
+
}
|
|
48
|
+
const HEADING_RE = /^#{1,6}\s+.*$/;
|
|
49
|
+
const collapse = (s) => s.trim().replace(/\s+/g, ' ');
|
|
50
|
+
function headingLines(body) {
|
|
51
|
+
return body.split('\n').filter((l) => HEADING_RE.test(l)).map(collapse);
|
|
52
|
+
}
|
|
53
|
+
// every line is a verbatim-copyable anchor that resolveAnchor accepts — the menu
|
|
54
|
+
// exists because reviewers paraphrasing headings was the top malformed-verdict cause
|
|
55
|
+
export function anchorMenu(reviewed) {
|
|
56
|
+
if (reviewed.kind === 'design') {
|
|
57
|
+
const lines = [
|
|
58
|
+
...reviewed.artifact.ids.map((id) => `design#${id}`),
|
|
59
|
+
reviewed.spec.id,
|
|
60
|
+
...headingLines(reviewed.spec.body).map((h) => `${reviewed.spec.id} > ${h}`),
|
|
61
|
+
];
|
|
62
|
+
return [
|
|
63
|
+
'## Valid anchors',
|
|
64
|
+
'',
|
|
65
|
+
'Copy one line VERBATIM per coverage/finding item. `design#<id>` names an element in the design artifact; `' +
|
|
66
|
+
reviewed.spec.id + ' > ## Heading` names a section of the parent spec. Anything else rejects the whole verdict.',
|
|
67
|
+
'',
|
|
68
|
+
...lines.map((l) => `- ${l}`),
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
if (reviewed.kind === 'screens') {
|
|
72
|
+
return [
|
|
73
|
+
'## Valid anchors',
|
|
74
|
+
'',
|
|
75
|
+
'Every capture below is a resolvable anchor — copy one VERBATIM (a bare filename) per coverage/finding item. Anything else rejects the whole verdict.',
|
|
76
|
+
'',
|
|
77
|
+
...reviewed.captures.map((c) => `- ${c.name}`),
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
if (reviewed.kind !== 'docs')
|
|
81
|
+
return '';
|
|
82
|
+
const lines = reviewed.docs.flatMap((d) => [d.id, ...headingLines(d.body).map((h) => `${d.id} > ${h}`)]);
|
|
83
|
+
return [
|
|
84
|
+
'## Valid anchors',
|
|
85
|
+
'',
|
|
86
|
+
'Every line below is a resolvable anchor — copy one VERBATIM per coverage/finding item. Deeper ` > ` chains of one doc\'s headings in document order also resolve; anything else rejects the whole verdict.',
|
|
87
|
+
'',
|
|
88
|
+
...lines.map((l) => `- ${l}`),
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
91
|
+
function resolveDocPath(segments, body) {
|
|
92
|
+
const heads = headingLines(body);
|
|
93
|
+
let cursor = -1;
|
|
94
|
+
for (const seg of segments) {
|
|
95
|
+
const want = collapse(seg);
|
|
96
|
+
const idx = heads.findIndex((h, i) => i > cursor && h === want);
|
|
97
|
+
if (idx === -1)
|
|
98
|
+
return `no heading "${seg}" (in order) in the reviewed doc`;
|
|
99
|
+
cursor = idx;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function safeRel(rel) {
|
|
104
|
+
return !isAbsolute(rel) && !normalize(rel).split(/[\\/]/).includes('..');
|
|
105
|
+
}
|
|
106
|
+
function resolveCodeAnchor(anchor, root) {
|
|
107
|
+
if (/[:#]L?\d+$/.test(anchor))
|
|
108
|
+
return 'line numbers refused — they drift across revisions; use file#symbol';
|
|
109
|
+
const [file = '', symbol] = anchor.split('#', 2);
|
|
110
|
+
if (!safeRel(file))
|
|
111
|
+
return `path escapes the reviewed tree: ${file}`;
|
|
112
|
+
const abs = join(root, file);
|
|
113
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
114
|
+
return `no file ${file} in the reviewed tree`;
|
|
115
|
+
if (symbol !== undefined) {
|
|
116
|
+
const re = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
117
|
+
if (!re.test(readFileSync(abs, 'utf8')))
|
|
118
|
+
return `symbol "${symbol}" not found in ${file}`;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
function resolveDesignAnchor(anchor, reviewed) {
|
|
123
|
+
const asElement = (scope) => scope.startsWith('design#') && reviewed.artifact.ids.includes(scope.slice('design#'.length));
|
|
124
|
+
const asSpec = (scope) => {
|
|
125
|
+
const segments = scope.split(' > ');
|
|
126
|
+
if (segments[0] === reviewed.spec.id)
|
|
127
|
+
return resolveDocPath(segments.slice(1), reviewed.spec.body);
|
|
128
|
+
if (segments[0].startsWith('#'))
|
|
129
|
+
return resolveDocPath(segments, reviewed.spec.body);
|
|
130
|
+
return `no reviewed anchor "${scope}" (want design#<id> or ${reviewed.spec.id} > ## Heading)`;
|
|
131
|
+
};
|
|
132
|
+
if (isOmission(anchor)) {
|
|
133
|
+
const scope = anchor.scope;
|
|
134
|
+
if (scope === '.' || asElement(scope) || asSpec(scope) === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
return `omission scope "${scope}" resolves to no element id or spec heading`;
|
|
137
|
+
}
|
|
138
|
+
if (anchor.startsWith('design#')) {
|
|
139
|
+
return reviewed.artifact.ids.includes(anchor.slice('design#'.length))
|
|
140
|
+
? undefined
|
|
141
|
+
: `no element id "${anchor.slice('design#'.length)}" in the design artifact`;
|
|
142
|
+
}
|
|
143
|
+
return asSpec(anchor);
|
|
144
|
+
}
|
|
145
|
+
export function resolveAnchor(anchor, reviewed) {
|
|
146
|
+
if (reviewed.kind === 'design')
|
|
147
|
+
return resolveDesignAnchor(anchor, reviewed);
|
|
148
|
+
if (reviewed.kind === 'screens') {
|
|
149
|
+
const target = isOmission(anchor) ? anchor.scope : anchor;
|
|
150
|
+
if (reviewed.captures.some((c) => c.name === target))
|
|
151
|
+
return undefined;
|
|
152
|
+
return `${isOmission(anchor) ? 'omission scope' : 'anchor'} "${target}" names no reviewed capture`;
|
|
153
|
+
}
|
|
154
|
+
if (isOmission(anchor)) {
|
|
155
|
+
const scope = anchor.scope;
|
|
156
|
+
if (reviewed.kind === 'docs') {
|
|
157
|
+
if (reviewed.docs.some((d) => d.id === scope))
|
|
158
|
+
return undefined;
|
|
159
|
+
const seg = scope.split(' > ');
|
|
160
|
+
const scoped = reviewed.docs.find((d) => d.id === seg[0]);
|
|
161
|
+
const target = scoped ? seg.slice(1) : seg;
|
|
162
|
+
const pool = scoped ? [scoped] : reviewed.docs;
|
|
163
|
+
return pool.some((d) => resolveDocPath(target, d.body) === undefined)
|
|
164
|
+
? undefined
|
|
165
|
+
: `omission scope "${scope}" resolves to no reviewed doc or heading`;
|
|
166
|
+
}
|
|
167
|
+
if (scope === '.')
|
|
168
|
+
return undefined;
|
|
169
|
+
if (!safeRel(scope))
|
|
170
|
+
return `omission scope escapes the reviewed tree: ${scope}`;
|
|
171
|
+
return existsSync(join(reviewed.root, scope))
|
|
172
|
+
? undefined
|
|
173
|
+
: `omission scope "${scope}" is no file or directory in the reviewed tree`;
|
|
174
|
+
}
|
|
175
|
+
if (reviewed.kind === 'tree')
|
|
176
|
+
return resolveCodeAnchor(anchor, reviewed.root);
|
|
177
|
+
const segments = anchor.split(' > ');
|
|
178
|
+
if (!segments[0].startsWith('#')) {
|
|
179
|
+
const doc = reviewed.docs.find((d) => d.id === segments[0]);
|
|
180
|
+
if (!doc)
|
|
181
|
+
return `no reviewed doc "${segments[0]}"`;
|
|
182
|
+
return resolveDocPath(segments.slice(1), doc.body);
|
|
183
|
+
}
|
|
184
|
+
return reviewed.docs.some((d) => resolveDocPath(segments, d.body) === undefined)
|
|
185
|
+
? undefined
|
|
186
|
+
: `no heading path "${anchor}" in any reviewed doc`;
|
|
187
|
+
}
|
|
188
|
+
export function verdictViolations(verdict, reviewed) {
|
|
189
|
+
const violations = [];
|
|
190
|
+
const anchorText = (a) => (typeof a === 'string' ? a : JSON.stringify(a));
|
|
191
|
+
verdict.findings.forEach((f, i) => {
|
|
192
|
+
const why = resolveAnchor(f.anchor, reviewed);
|
|
193
|
+
if (why) {
|
|
194
|
+
const rule = why.startsWith('line numbers') ? 'line-numbers-refused' : 'anchor-unresolvable';
|
|
195
|
+
violations.push(v(`findings[${i}].anchor`, rule, anchorText(f.anchor), why));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
verdict.coverage.forEach((c, i) => {
|
|
199
|
+
const why = resolveAnchor(c.anchor, reviewed);
|
|
200
|
+
if (why)
|
|
201
|
+
violations.push(v(`coverage[${i}].anchor`, 'anchor-unresolvable', anchorText(c.anchor), why));
|
|
202
|
+
});
|
|
203
|
+
if (reviewed.kind === 'design') {
|
|
204
|
+
const strings = verdict.coverage.filter((c) => typeof c.anchor === 'string');
|
|
205
|
+
const artifactRead = strings.some((c) => c.anchor.startsWith('design#') && reviewed.artifact.ids.includes(c.anchor.slice('design#'.length)));
|
|
206
|
+
const specRead = strings.some((c) => resolveDesignAnchor(c.anchor, reviewed) === undefined && !c.anchor.startsWith('design#'));
|
|
207
|
+
if (!artifactRead)
|
|
208
|
+
violations.push(v('coverage', 'coverage-minimum', 'no design#<id> coverage anchor', '>=1 coverage anchor naming a design artifact element (proof the look was read)'));
|
|
209
|
+
if (!specRead)
|
|
210
|
+
violations.push(v('coverage', 'coverage-minimum', 'no parent-spec coverage anchor', '>=1 coverage anchor naming a parent-spec heading (proof the spec was read)'));
|
|
211
|
+
return violations;
|
|
212
|
+
}
|
|
213
|
+
if (reviewed.kind === 'docs') {
|
|
214
|
+
const covered = new Set();
|
|
215
|
+
verdict.coverage.forEach((c, i) => {
|
|
216
|
+
if (typeof c.anchor !== 'string')
|
|
217
|
+
return;
|
|
218
|
+
const [first = ''] = c.anchor.split(' > ');
|
|
219
|
+
if (!first.startsWith('#')) {
|
|
220
|
+
covered.add(first);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (reviewed.docs.length > 1) {
|
|
224
|
+
violations.push(v(`coverage[${i}].anchor`, 'coverage-unscoped', c.anchor, 'multi-doc reviews need doc-scoped coverage anchors: "<doc-id> > ## Heading"'));
|
|
225
|
+
}
|
|
226
|
+
else if (reviewed.docs[0])
|
|
227
|
+
covered.add(reviewed.docs[0].id);
|
|
228
|
+
});
|
|
229
|
+
for (const d of reviewed.docs) {
|
|
230
|
+
if (!covered.has(d.id)) {
|
|
231
|
+
violations.push(v('coverage', 'coverage-minimum', `docs covered: ${[...covered].sort().join(' ') || '(none)'}`, `≥ 1 coverage anchor per reviewed doc — missing ${d.id}`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (reviewed.kind === 'screens') {
|
|
236
|
+
const covered = new Set(verdict.coverage
|
|
237
|
+
.filter((c) => typeof c.anchor === 'string')
|
|
238
|
+
.map((c) => c.anchor)
|
|
239
|
+
.filter((a) => reviewed.captures.some((c) => c.name === a)));
|
|
240
|
+
for (const cap of reviewed.captures) {
|
|
241
|
+
if (!covered.has(cap.name)) {
|
|
242
|
+
violations.push(v('coverage', 'coverage-minimum', `captures covered: ${[...covered].sort().join(' ') || '(none)'}`, `≥ 1 coverage anchor per reviewed capture — missing ${cap.name}`));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const distinct = new Set(verdict.coverage
|
|
248
|
+
.filter((c) => typeof c.anchor === 'string')
|
|
249
|
+
.map((c) => c.anchor.split('#')[0] ?? '')
|
|
250
|
+
.filter((f) => reviewed.files.includes(f)));
|
|
251
|
+
const want = Math.min(5, reviewed.files.length);
|
|
252
|
+
if (distinct.size < want) {
|
|
253
|
+
violations.push(v('coverage', 'coverage-minimum', `${distinct.size} distinct changed files covered`, `≥ ${want} coverage anchors naming distinct changed files`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return violations;
|
|
257
|
+
}
|