@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
package/dist/drift.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { EXIT } from './cli.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { runCriteria } from './criteria.js';
|
|
6
|
+
import { serializeDoc, writeDoc } from './fm.js';
|
|
7
|
+
import { acquireLock } from './lock.js';
|
|
8
|
+
import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
|
|
9
|
+
import { primaryRoot, stateCommit } from './gitio.js';
|
|
10
|
+
import { renderRefusal, v, ok } from './refusal.js';
|
|
11
|
+
import { resolveHarness } from './harness.js';
|
|
12
|
+
import { invokeReviewer, parseVerdictText, resolvePrompt } from './reviewer.js';
|
|
13
|
+
import { runFullSuite, runnerConfig } from './runner.js';
|
|
14
|
+
import { findById, loadCanon } from './scan.js';
|
|
15
|
+
import { canonicalSha } from './sha.js';
|
|
16
|
+
import { kv, rows } from './toon.js';
|
|
17
|
+
import { crashPoint, guardTxn, withTxn } from './txn.js';
|
|
18
|
+
import { parseVerdict, verdictViolations } from './verdict.js';
|
|
19
|
+
export const newRunId = () => `r-${randomBytes(4).toString('hex')}`;
|
|
20
|
+
export function driftEntry(run, runId) {
|
|
21
|
+
return {
|
|
22
|
+
t: 'drift-check',
|
|
23
|
+
run_id: runId,
|
|
24
|
+
artifact: run.spec,
|
|
25
|
+
artifact_sha: run.sha,
|
|
26
|
+
ok: run.ok,
|
|
27
|
+
tags: run.tagCount,
|
|
28
|
+
criteria: run.criteria.map((c) => ({ id: c.id, kind: c.kind, ok: c.ok, detail: c.detail })),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function trailingFails(entries) {
|
|
32
|
+
const checks = entries.filter((e) => e.t === 'drift-check');
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (let i = checks.length - 1; i >= 0 && checks[i]?.ok === false; i--)
|
|
35
|
+
n++;
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
export const liveSpecs = (canon) => canon.docs.filter((d) => d.meta.type === 'spec' && d.meta.status === 'live');
|
|
39
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
40
|
+
export function stampDrift(root, doc, sha) {
|
|
41
|
+
writeDoc(join(root, doc.rel), { meta: { ...doc.meta, drift: { sha, at: today() } }, body: doc.body });
|
|
42
|
+
}
|
|
43
|
+
export function clearDrift(root, doc) {
|
|
44
|
+
const meta = { ...doc.meta };
|
|
45
|
+
delete meta.drift;
|
|
46
|
+
writeDoc(join(root, doc.rel), { meta, body: doc.body });
|
|
47
|
+
}
|
|
48
|
+
export function flapScore(entries) {
|
|
49
|
+
const checks = entries.filter((e) => e.t === 'drift-check').slice(-10);
|
|
50
|
+
let flips = 0;
|
|
51
|
+
for (let i = 1; i < checks.length; i++) {
|
|
52
|
+
if (checks[i]?.ok !== checks[i - 1]?.ok)
|
|
53
|
+
flips++;
|
|
54
|
+
}
|
|
55
|
+
return flips;
|
|
56
|
+
}
|
|
57
|
+
export function reconcileRows(root, canon) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const doc of liveSpecs(canon)) {
|
|
60
|
+
const id = String(doc.meta.id);
|
|
61
|
+
const entries = readStream(root, id);
|
|
62
|
+
const flag = doc.meta.drift;
|
|
63
|
+
if (flag)
|
|
64
|
+
out.push({ spec: id, why: 'drift', detail: `${flag.sha.slice(0, 7)} since ${flag.at} — fix the code or amend the spec` });
|
|
65
|
+
else if (trailingFails(entries) === 1)
|
|
66
|
+
out.push({ spec: id, why: 'unconfirmed', detail: 'seen red once — one more red stamps drift' });
|
|
67
|
+
const flips = flapScore(entries);
|
|
68
|
+
if (flips >= 3)
|
|
69
|
+
out.push({ spec: id, why: 'flapping', detail: `${flips} pass/fail flips in the last 10 runs — fix the test or the spec` });
|
|
70
|
+
const last = entries.at(-1);
|
|
71
|
+
if (last?.t === 'adopt' && last.unreviewed_amendment === true) {
|
|
72
|
+
out.push({ spec: id, why: 'unreviewed-amendment', detail: 'prose changed without review — amend properly or accept' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export async function driftSweep(root, ctx, canon) {
|
|
78
|
+
const specs = liveSpecs(canon);
|
|
79
|
+
const cfg = loadConfig(root);
|
|
80
|
+
if (!cfg.ok)
|
|
81
|
+
return cfg;
|
|
82
|
+
const rcRes = runnerConfig(cfg.value);
|
|
83
|
+
if (!rcRes.ok)
|
|
84
|
+
return rcRes;
|
|
85
|
+
let suite;
|
|
86
|
+
if (rcRes.value.mode === 'full-suite' && specs.length > 0) {
|
|
87
|
+
const res = await runFullSuite(root, ctx, rcRes.value);
|
|
88
|
+
if (res.ok)
|
|
89
|
+
suite = res.value.tests;
|
|
90
|
+
// a refusal here (untrusted, no reports) falls through: each runCriteria call
|
|
91
|
+
// re-attempts and reports the same problem as failing criteria — fail-closed either way
|
|
92
|
+
}
|
|
93
|
+
const runs = [];
|
|
94
|
+
for (const doc of specs) {
|
|
95
|
+
const res = await runCriteria(root, ctx, doc, { suite });
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
return res;
|
|
98
|
+
runs.push(res.value);
|
|
99
|
+
}
|
|
100
|
+
return ok(runs);
|
|
101
|
+
}
|
|
102
|
+
export async function deepDrift(root, ctx, canon, specId) {
|
|
103
|
+
if (ctx.env.CI) {
|
|
104
|
+
renderRefusal([v('deep', 'deep-in-ci', 'CI env', 'a local run — CI never invokes reviewers or writes state')])
|
|
105
|
+
.forEach(ctx.err);
|
|
106
|
+
return EXIT.REFUSED;
|
|
107
|
+
}
|
|
108
|
+
const doc = findById(canon, specId);
|
|
109
|
+
if (!doc || doc.meta.type !== 'spec') {
|
|
110
|
+
renderRefusal([v('spec', 'unknown-spec', specId, 'a live spec id')]).forEach(ctx.err);
|
|
111
|
+
return EXIT.REFUSED;
|
|
112
|
+
}
|
|
113
|
+
const lane = await runCriteria(root, ctx, doc, {});
|
|
114
|
+
const lensR = resolvePrompt('drift-reviewer');
|
|
115
|
+
if (!lensR.ok) {
|
|
116
|
+
renderRefusal(lensR.violations).forEach(ctx.err);
|
|
117
|
+
return EXIT.REFUSED;
|
|
118
|
+
}
|
|
119
|
+
// Decision 88: the deep lane is a reviewer lane — it runs on the RESOLVED harness,
|
|
120
|
+
// like every other. A silent claude fallback here would swap the reviewer identity
|
|
121
|
+
// for pure-pi users on the one check that judges live specs.
|
|
122
|
+
const cfgR = loadConfig(root);
|
|
123
|
+
const hxR = resolveHarness(ctx.env, cfgR.ok ? cfgR.value.raw : {});
|
|
124
|
+
if (!hxR.ok) {
|
|
125
|
+
renderRefusal(hxR.violations).forEach(ctx.err);
|
|
126
|
+
return EXIT.REFUSED;
|
|
127
|
+
}
|
|
128
|
+
const prompt = `${lensR.value.contents}\n\n## Reviewed content\n\n### Spec: ${specId}\n${serializeDoc({ meta: doc.meta, body: doc.body })}\n\n(Deterministic lane: ${lane.ok ? lane.value.criteria.map((c) => `${c.id}:${c.ok ? 'ok' : 'fail'}`).join(' · ') : 'unrunnable'})\n`;
|
|
129
|
+
const invoked = invokeReviewer(ctx, hxR.value.harness, { cwd: root, prompt });
|
|
130
|
+
if (!invoked.ok) {
|
|
131
|
+
renderRefusal(invoked.violations).forEach(ctx.err);
|
|
132
|
+
return EXIT.REFUSED;
|
|
133
|
+
}
|
|
134
|
+
const rawR = parseVerdictText(invoked.value.text);
|
|
135
|
+
const parsed = rawR.ok ? parseVerdict(rawR.value) : rawR;
|
|
136
|
+
const violations = parsed.ok
|
|
137
|
+
? verdictViolations(parsed.value, { kind: 'tree', root, files: [] })
|
|
138
|
+
: parsed.violations;
|
|
139
|
+
const entry = {
|
|
140
|
+
v: 1, t: 'drift-check', run_id: newRunId(), artifact: specId,
|
|
141
|
+
artifact_sha: lane.ok ? lane.value.sha : canonicalSha(doc.meta, doc.body),
|
|
142
|
+
ok: lane.ok && lane.value.ok, tags: lane.ok ? lane.value.tagCount : 0,
|
|
143
|
+
criteria: lane.ok ? lane.value.criteria.map((c) => ({ id: c.id, kind: c.kind, ok: c.ok, detail: c.detail })) : [],
|
|
144
|
+
deep: true,
|
|
145
|
+
...(violations.length === 0 && parsed.ok ? { verdict: parsed.value } : { malformed: violations }),
|
|
146
|
+
};
|
|
147
|
+
const lockR = acquireLock(root);
|
|
148
|
+
if (!lockR.ok) {
|
|
149
|
+
renderRefusal(lockR.violations).forEach(ctx.err);
|
|
150
|
+
return EXIT.BLOCKED;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const txn = withTxn(root, {
|
|
154
|
+
op: 'drift-deep', files: [journalRel(specId)],
|
|
155
|
+
journalMulti: [{ stream: specId, line: entryLine(entry) }],
|
|
156
|
+
}, () => {
|
|
157
|
+
appendEntry(root, specId, entry);
|
|
158
|
+
crashPoint(ctx.env, 'drift-journal');
|
|
159
|
+
return stateCommit(root, [journalRel(specId)], `drift-deep(${specId})`);
|
|
160
|
+
});
|
|
161
|
+
if (!txn.ok) {
|
|
162
|
+
renderRefusal(txn.violations).forEach(ctx.err);
|
|
163
|
+
return EXIT.REFUSED;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
lockR.value();
|
|
168
|
+
}
|
|
169
|
+
const blocking = parsed.ok && violations.length === 0
|
|
170
|
+
? parsed.value.findings.filter((f) => f.blocking).length : 1;
|
|
171
|
+
return (lane.ok && lane.value.ok && blocking === 0) ? EXIT.OK : EXIT.FINDINGS;
|
|
172
|
+
}
|
|
173
|
+
export async function runDrift(ctx, argv) {
|
|
174
|
+
if (argv.includes('--deep')) {
|
|
175
|
+
const specId = argv.find((a) => !a.startsWith('--'));
|
|
176
|
+
const rootRes0 = primaryRoot(ctx.cwd);
|
|
177
|
+
if (!rootRes0.ok) {
|
|
178
|
+
renderRefusal(rootRes0.violations).forEach(ctx.err);
|
|
179
|
+
return EXIT.REFUSED;
|
|
180
|
+
}
|
|
181
|
+
if (!specId) {
|
|
182
|
+
renderRefusal([v('spec-id', 'required', 'absent', 'witness check --drift --deep <spec-id>')]).forEach(ctx.err);
|
|
183
|
+
return EXIT.REFUSED;
|
|
184
|
+
}
|
|
185
|
+
if (argv.includes('--ci')) {
|
|
186
|
+
renderRefusal([v('deep', 'deep-in-ci', '--ci', 'a local run — CI never invokes reviewers or writes state')]).forEach(ctx.err);
|
|
187
|
+
return EXIT.REFUSED;
|
|
188
|
+
}
|
|
189
|
+
return deepDrift(rootRes0.value, ctx, loadCanon(rootRes0.value), specId);
|
|
190
|
+
}
|
|
191
|
+
const rootRes = primaryRoot(ctx.cwd);
|
|
192
|
+
if (!rootRes.ok) {
|
|
193
|
+
renderRefusal(rootRes.violations).forEach(ctx.err);
|
|
194
|
+
return EXIT.REFUSED;
|
|
195
|
+
}
|
|
196
|
+
const root = rootRes.value;
|
|
197
|
+
const readonly = argv.includes('--ci') || Boolean(ctx.env.CI);
|
|
198
|
+
if (!readonly) {
|
|
199
|
+
const blocked = guardTxn(ctx, root);
|
|
200
|
+
if (blocked !== undefined)
|
|
201
|
+
return blocked;
|
|
202
|
+
}
|
|
203
|
+
const canon = loadCanon(root);
|
|
204
|
+
const sweep = await driftSweep(root, ctx, canon);
|
|
205
|
+
if (!sweep.ok) {
|
|
206
|
+
renderRefusal(sweep.violations).forEach(ctx.err);
|
|
207
|
+
return EXIT.REFUSED;
|
|
208
|
+
}
|
|
209
|
+
const runs = sweep.value;
|
|
210
|
+
const failing = runs.filter((r) => !r.ok);
|
|
211
|
+
if (runs.length) {
|
|
212
|
+
rows('drift', ['spec', 'ok', 'tags', 'detail'], runs.map((r) => {
|
|
213
|
+
const doc = findById(canon, r.spec);
|
|
214
|
+
const unconfirmed = !r.ok && doc?.meta.drift === undefined && trailingFails(readStream(root, r.spec)) === 0;
|
|
215
|
+
return {
|
|
216
|
+
spec: r.spec, ok: String(r.ok), tags: r.tagCount,
|
|
217
|
+
detail: unconfirmed
|
|
218
|
+
? 'unconfirmed — seen red once — one more red stamps drift'
|
|
219
|
+
: r.criteria.filter((c) => !c.ok).map((c) => `${c.id}: ${c.detail}`).join(' · ') || 'clean',
|
|
220
|
+
};
|
|
221
|
+
})).forEach(ctx.out);
|
|
222
|
+
}
|
|
223
|
+
if (readonly) {
|
|
224
|
+
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing · mode read-only`));
|
|
225
|
+
ctx.out('help: read-only run — the next local check --drift journals and stamps');
|
|
226
|
+
return failing.length ? EXIT.FINDINGS : EXIT.OK;
|
|
227
|
+
}
|
|
228
|
+
const lock = acquireLock(root);
|
|
229
|
+
if (!lock.ok) {
|
|
230
|
+
renderRefusal(lock.violations).forEach(ctx.err);
|
|
231
|
+
return EXIT.BLOCKED;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const runId = newRunId();
|
|
235
|
+
const entries = runs.map((r) => ({ spec: r.spec, entry: driftEntry(r, runId) }));
|
|
236
|
+
const actions = runs.map((run) => {
|
|
237
|
+
const doc = findById(canon, run.spec);
|
|
238
|
+
const flagged = doc?.meta.drift !== undefined;
|
|
239
|
+
const priorFails = trailingFails(readStream(root, run.spec));
|
|
240
|
+
return {
|
|
241
|
+
run, doc,
|
|
242
|
+
stamp: doc && !run.ok && priorFails >= 1 && !flagged,
|
|
243
|
+
clear: doc && run.ok && flagged,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const marker = {
|
|
247
|
+
op: `drift-check ${runId}`,
|
|
248
|
+
files: [...new Set([
|
|
249
|
+
...entries.map((e) => journalRel(e.spec)),
|
|
250
|
+
...actions.filter((a) => a.stamp || a.clear).map((a) => a.doc.rel),
|
|
251
|
+
])],
|
|
252
|
+
journalMulti: entries.map((e) => ({ stream: e.spec, line: entryLine(e.entry) })),
|
|
253
|
+
};
|
|
254
|
+
const committed = withTxn(root, marker, () => {
|
|
255
|
+
for (const { spec, entry } of entries)
|
|
256
|
+
appendEntry(root, spec, entry);
|
|
257
|
+
for (const a of actions) {
|
|
258
|
+
if (a.stamp)
|
|
259
|
+
stampDrift(root, a.doc, a.run.sha);
|
|
260
|
+
else if (a.clear)
|
|
261
|
+
clearDrift(root, a.doc);
|
|
262
|
+
}
|
|
263
|
+
crashPoint(ctx.env, 'drift-journal');
|
|
264
|
+
return stateCommit(root, marker.files, `drift-check: ${failing.length}/${runs.length} failing`);
|
|
265
|
+
});
|
|
266
|
+
if (!committed.ok) {
|
|
267
|
+
renderRefusal(committed.violations).forEach(ctx.err);
|
|
268
|
+
return EXIT.REFUSED;
|
|
269
|
+
}
|
|
270
|
+
const stamped = actions.filter((a) => a.stamp).length;
|
|
271
|
+
const cleared = actions.filter((a) => a.clear).length;
|
|
272
|
+
const suffix = `${stamped ? ` · stamped ${stamped}` : ''}${cleared ? ` · cleared ${cleared}` : ''}`;
|
|
273
|
+
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing · mode local${suffix}`));
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
lock.ok && lock.value();
|
|
277
|
+
}
|
|
278
|
+
ctx.out('help: drift history: witness log <spec-id>');
|
|
279
|
+
return failing.length ? EXIT.FINDINGS : EXIT.OK;
|
|
280
|
+
}
|
package/dist/dsl.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { v } from './refusal.js';
|
|
2
|
+
export const ID_RE = /^[a-z0-9-]+$/;
|
|
3
|
+
export function validateCriteria(raw, specId) {
|
|
4
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
5
|
+
return [v('criteria', 'required', Array.isArray(raw) ? 'empty list' : String(raw ?? 'absent'), '>=1 machine-checkable criterion')];
|
|
6
|
+
}
|
|
7
|
+
const out = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
raw.forEach((c, i) => {
|
|
10
|
+
const at = `criteria[${i}]`;
|
|
11
|
+
if (typeof c !== 'object' || c === null) {
|
|
12
|
+
out.push(v(at, 'shape', String(c), '{id, test|cmd}'));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const e = c;
|
|
16
|
+
if (typeof e.id !== 'string' || !ID_RE.test(e.id)) {
|
|
17
|
+
out.push(v(`${at}.id`, 'id-charset', String(e.id ?? 'absent'), '[a-z0-9-]+'));
|
|
18
|
+
}
|
|
19
|
+
else if (seen.has(e.id)) {
|
|
20
|
+
out.push(v(`${at}.id`, 'id-unique', e.id, 'unique per spec'));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
seen.add(e.id);
|
|
24
|
+
}
|
|
25
|
+
const hasTest = typeof e.test === 'string';
|
|
26
|
+
const hasCmd = typeof e.cmd === 'string';
|
|
27
|
+
if (hasTest === hasCmd) {
|
|
28
|
+
out.push(v(at, 'kind', hasTest ? 'both test and cmd' : 'neither test nor cmd', 'exactly one of test | cmd'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (hasTest && e.test !== `@spec:${specId}`) {
|
|
32
|
+
out.push(v(`${at}.test`, 'tag-format', String(e.test), `@spec:${specId}`));
|
|
33
|
+
}
|
|
34
|
+
if (hasCmd && e.cmd === '') {
|
|
35
|
+
out.push(v(`${at}.cmd`, 'shape', 'empty string', 'non-empty command'));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function validateNeeds(raw) {
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return [];
|
|
43
|
+
if (!Array.isArray(raw)) {
|
|
44
|
+
return [v('needs', 'shape', typeof raw, 'list of {env} | {cmd} | {manual, satisfied}')];
|
|
45
|
+
}
|
|
46
|
+
const out = [];
|
|
47
|
+
raw.forEach((n, i) => {
|
|
48
|
+
const at = `needs[${i}]`;
|
|
49
|
+
if (typeof n !== 'object' || n === null) {
|
|
50
|
+
out.push(v(at, 'shape', String(n), '{env} | {cmd} | {manual, satisfied}'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const e = n;
|
|
54
|
+
const kinds = ['env', 'cmd', 'manual'].filter((k) => e[k] !== undefined);
|
|
55
|
+
if (kinds.length !== 1) {
|
|
56
|
+
out.push(v(at, 'kind', kinds.join('+') || 'none', 'exactly one of env | cmd | manual'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const kind = kinds[0];
|
|
60
|
+
if (typeof e[kind] !== 'string' || e[kind] === '') {
|
|
61
|
+
out.push(v(`${at}.${kind}`, 'shape', String(e[kind]), 'non-empty string'));
|
|
62
|
+
}
|
|
63
|
+
if (kind === 'manual' && typeof e.satisfied !== 'boolean') {
|
|
64
|
+
out.push(v(`${at}.satisfied`, 'required', String(e.satisfied ?? 'absent'), 'boolean'));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return out;
|
|
68
|
+
}
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { entryLine, appendEntry, journalRel, readStream } from './journal.js';
|
|
6
|
+
import { mergeReports, reportFiles } from './junit.js';
|
|
7
|
+
import { extractCanonicalTags, matchesTag } from './matcher.js';
|
|
8
|
+
import { ok, refuse, v } from './refusal.js';
|
|
9
|
+
import { runFiltered, runFullSuite, runnerConfig } from './runner.js';
|
|
10
|
+
import { git, stateCommit, tryGit } from './gitio.js';
|
|
11
|
+
import { withTxn } from './txn.js';
|
|
12
|
+
export function isTestPath(rel) {
|
|
13
|
+
if (/\.(test|spec)\./.test(rel))
|
|
14
|
+
return true;
|
|
15
|
+
return rel.split('/').some((seg) => seg === 'test' || seg === 'tests' || seg === '__tests__');
|
|
16
|
+
}
|
|
17
|
+
export function screensDir(runRoot, planId) {
|
|
18
|
+
return join(runRoot, '.witness', 'screens', planId);
|
|
19
|
+
}
|
|
20
|
+
export function collectCaptures(runRoot, planId) {
|
|
21
|
+
const dir = screensDir(runRoot, planId);
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
return [];
|
|
24
|
+
return readdirSync(dir)
|
|
25
|
+
.filter((n) => n.endsWith('.png') && statSync(join(dir, n)).isFile())
|
|
26
|
+
.sort()
|
|
27
|
+
.map((name) => ({ name, sha: createHash('sha256').update(readFileSync(join(dir, name))).digest('hex') }));
|
|
28
|
+
}
|
|
29
|
+
export function checkoutRoot(cwd) {
|
|
30
|
+
const res = tryGit(cwd, 'rev-parse', '--show-toplevel');
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
return refuse([v('cwd', 'not-a-repo', cwd, 'run inside a git checkout')]);
|
|
33
|
+
return ok(res.out.trim());
|
|
34
|
+
}
|
|
35
|
+
export async function runSpecTests(runRoot, ctx, parentId, trustRoot) {
|
|
36
|
+
const cfg = loadConfig(runRoot);
|
|
37
|
+
if (!cfg.ok)
|
|
38
|
+
return cfg;
|
|
39
|
+
const rcRes = runnerConfig(cfg.value);
|
|
40
|
+
if (!rcRes.ok)
|
|
41
|
+
return rcRes;
|
|
42
|
+
const rc = rcRes.value;
|
|
43
|
+
if (rc.mode === 'filtered') {
|
|
44
|
+
if (rc.reportGlob !== undefined) {
|
|
45
|
+
for (const f of reportFiles(runRoot, rc.reportGlob))
|
|
46
|
+
rmSync(join(runRoot, f));
|
|
47
|
+
}
|
|
48
|
+
const run = await runFiltered(runRoot, ctx, rc.template, parentId, trustRoot);
|
|
49
|
+
if (!run.ok)
|
|
50
|
+
return run;
|
|
51
|
+
if (rc.reportGlob === undefined) {
|
|
52
|
+
const tests = [{ name: `@spec:${parentId}`, ok: run.value.exitZero }];
|
|
53
|
+
return ok({ runner: 'filtered', tests, allOk: run.value.exitZero });
|
|
54
|
+
}
|
|
55
|
+
const merged = mergeReports(runRoot, rc.reportGlob);
|
|
56
|
+
if (!merged.ok)
|
|
57
|
+
return merged;
|
|
58
|
+
const tests = merged.value
|
|
59
|
+
.filter((t) => matchesTag(t.name, parentId))
|
|
60
|
+
.map((t) => ({ name: t.name, ok: t.status === 'passed' }));
|
|
61
|
+
if (tests.length === 0) {
|
|
62
|
+
return refuse([v('criteria.runner', 'filter-matched-nothing', `0 tests matched @spec:${parentId}`, 'a runner whose filter reaches the tagged tests — check the template forwards the pattern')]);
|
|
63
|
+
}
|
|
64
|
+
return ok({ runner: 'filtered', tests, allOk: tests.every((t) => t.ok) });
|
|
65
|
+
}
|
|
66
|
+
const suite = await runFullSuite(runRoot, ctx, rc, trustRoot);
|
|
67
|
+
if (!suite.ok)
|
|
68
|
+
return suite;
|
|
69
|
+
const tests = suite.value.tests
|
|
70
|
+
.filter((t) => matchesTag(t.name, parentId))
|
|
71
|
+
.map((t) => ({ name: t.name, ok: t.status === 'passed' }));
|
|
72
|
+
if (tests.length === 0) {
|
|
73
|
+
return refuse([v('criteria.report', 'filter-matched-nothing', `0 tests matched @spec:${parentId}`, 'tagged tests present in the merged junit reports')]);
|
|
74
|
+
}
|
|
75
|
+
return ok({ runner: 'full-suite', tests, allOk: tests.every((t) => t.ok) });
|
|
76
|
+
}
|
|
77
|
+
export function journalEvidence(stateRoot, planId, phase, run, extra = {}) {
|
|
78
|
+
const entry = { t: 'test-evidence', artifact: planId, phase, runner: run.runner, tests: run.tests, ...extra };
|
|
79
|
+
const marker = {
|
|
80
|
+
op: `test-evidence(${planId}): ${phase}`,
|
|
81
|
+
files: [journalRel(planId)],
|
|
82
|
+
journalMulti: [{ stream: planId, line: entryLine(entry) }],
|
|
83
|
+
};
|
|
84
|
+
return withTxn(stateRoot, marker, () => {
|
|
85
|
+
appendEntry(stateRoot, planId, entry);
|
|
86
|
+
return stateCommit(stateRoot, marker.files, marker.op);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function recordEvidence(runRoot, stateRoot, ctx, planId, parentId, phase, extra = {}) {
|
|
90
|
+
// latest-cycle by construction: a fresh empty dir per cycle means captures can
|
|
91
|
+
// only be this run's. Browser tests screenshot iff WITNESS_SCREENS_DIR is set,
|
|
92
|
+
// so non-UI plans and the gate's own drift lane (which does not set it) write nothing.
|
|
93
|
+
const dir = screensDir(runRoot, planId);
|
|
94
|
+
rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const runCtx = { ...ctx, env: { ...ctx.env, WITNESS_SCREENS_DIR: dir } };
|
|
97
|
+
const run = await runSpecTests(runRoot, runCtx, parentId, stateRoot);
|
|
98
|
+
if (!run.ok)
|
|
99
|
+
return run;
|
|
100
|
+
const vacuous = phase === 'red' && run.value.allOk ? { vacuous: true } : {};
|
|
101
|
+
const captures = collectCaptures(runRoot, planId);
|
|
102
|
+
const capExtra = captures.length ? { captures } : {};
|
|
103
|
+
const committed = journalEvidence(stateRoot, planId, phase, run.value, { ...extra, ...vacuous, ...capExtra });
|
|
104
|
+
if (!committed.ok)
|
|
105
|
+
return committed;
|
|
106
|
+
return run;
|
|
107
|
+
}
|
|
108
|
+
export function diffBase(runRoot, cfg, override) {
|
|
109
|
+
if (override) {
|
|
110
|
+
const res = tryGit(runRoot, 'rev-parse', '--verify', `${override}^{commit}`);
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
return refuse([v('--base', 'no-base', override, 'a resolvable commit-ish')]);
|
|
113
|
+
return ok(res.out.trim());
|
|
114
|
+
}
|
|
115
|
+
const ship = (cfg.raw.ship ?? {});
|
|
116
|
+
const branch = typeof ship.branch === 'string' ? ship.branch : 'main';
|
|
117
|
+
const res = tryGit(runRoot, 'merge-base', 'HEAD', branch);
|
|
118
|
+
if (!res.ok)
|
|
119
|
+
return refuse([v('ship.branch', 'no-base', branch, 'an existing base branch, or pass --base <ref>')]);
|
|
120
|
+
return ok(res.out.trim());
|
|
121
|
+
}
|
|
122
|
+
export function changedFiles(runRoot, base) {
|
|
123
|
+
const tracked = git(runRoot, 'diff', '--name-only', base).split('\n').filter(Boolean);
|
|
124
|
+
const untracked = git(runRoot, 'ls-files', '--others', '--exclude-standard').split('\n').filter(Boolean);
|
|
125
|
+
return [...new Set([...tracked, ...untracked])].sort();
|
|
126
|
+
}
|
|
127
|
+
const inCommit = (runRoot, ref, rel) => tryGit(runRoot, 'cat-file', '-e', `${ref}:${rel}`).ok;
|
|
128
|
+
export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base) {
|
|
129
|
+
const files = changedFiles(runRoot, base);
|
|
130
|
+
if (!files.some(isTestPath)) {
|
|
131
|
+
return refuse([v('diff', 'no-test-changes', `${files.length} changed files, none are tests`, 'a diff that adds or modifies tagged tests')]);
|
|
132
|
+
}
|
|
133
|
+
const nonTest = files.filter((f) => !isTestPath(f));
|
|
134
|
+
const untracked = new Set(git(runRoot, 'ls-files', '--others', '--exclude-standard').split('\n').filter(Boolean));
|
|
135
|
+
const trackedNonTest = nonTest.filter((f) => !untracked.has(f));
|
|
136
|
+
let stashed = false;
|
|
137
|
+
if (nonTest.length) {
|
|
138
|
+
const before = git(runRoot, 'stash', 'list').split('\n').filter(Boolean).length;
|
|
139
|
+
git(runRoot, 'stash', 'push', '--include-untracked', '--', ...nonTest);
|
|
140
|
+
stashed = git(runRoot, 'stash', 'list').split('\n').filter(Boolean).length > before;
|
|
141
|
+
}
|
|
142
|
+
let red;
|
|
143
|
+
try {
|
|
144
|
+
for (const rel of trackedNonTest) {
|
|
145
|
+
if (inCommit(runRoot, base, rel))
|
|
146
|
+
git(runRoot, 'checkout', base, '--', rel);
|
|
147
|
+
else if (existsSync(join(runRoot, rel)))
|
|
148
|
+
rmSync(join(runRoot, rel));
|
|
149
|
+
}
|
|
150
|
+
red = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'red', { reconstructed: true });
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
for (const rel of trackedNonTest) {
|
|
154
|
+
if (inCommit(runRoot, 'HEAD', rel))
|
|
155
|
+
git(runRoot, 'checkout', 'HEAD', '--', rel);
|
|
156
|
+
}
|
|
157
|
+
if (stashed)
|
|
158
|
+
git(runRoot, 'stash', 'pop');
|
|
159
|
+
}
|
|
160
|
+
if (!red.ok)
|
|
161
|
+
return red;
|
|
162
|
+
const green = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'green', { reconstructed: true });
|
|
163
|
+
if (!green.ok)
|
|
164
|
+
return green;
|
|
165
|
+
return ok({ redOk: !red.value.allOk, greenOk: green.value.allOk, vacuous: red.value.allOk });
|
|
166
|
+
}
|
|
167
|
+
export function diffTags(runRoot, base) {
|
|
168
|
+
const tags = new Set();
|
|
169
|
+
for (const rel of changedFiles(runRoot, base).filter(isTestPath)) {
|
|
170
|
+
let added;
|
|
171
|
+
if (inCommit(runRoot, base, rel)) {
|
|
172
|
+
added = git(runRoot, 'diff', base, '--', rel)
|
|
173
|
+
.split('\n')
|
|
174
|
+
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
|
175
|
+
.join('\n');
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const abs = join(runRoot, rel);
|
|
179
|
+
added = existsSync(abs) ? readFileSync(abs, 'utf8') : '';
|
|
180
|
+
}
|
|
181
|
+
for (const tag of extractCanonicalTags(added))
|
|
182
|
+
tags.add(tag);
|
|
183
|
+
}
|
|
184
|
+
return [...tags].sort();
|
|
185
|
+
}
|
|
186
|
+
export function evidenceForDiff(runRoot, stateRoot, plan, base) {
|
|
187
|
+
const planId = String(plan.meta.id);
|
|
188
|
+
const entries = readStream(stateRoot, planId).filter((e) => e.t === 'test-evidence');
|
|
189
|
+
const matching = (e, tag) => (Array.isArray(e.tests) ? e.tests : []).filter((t) => matchesTag(t.name, tag));
|
|
190
|
+
const required = diffTags(runRoot, base).map((tag) => {
|
|
191
|
+
// latest-cycle semantics: the newest red matching the tag is the verdict on
|
|
192
|
+
// "was failure observed"; a green only counts if it post-dates that red.
|
|
193
|
+
// One early vacuous red must not poison the tag forever (append-only journal).
|
|
194
|
+
let lastRedIdx = -1;
|
|
195
|
+
let lastGreenIdx = -1;
|
|
196
|
+
for (let i = 0; i < entries.length; i++) {
|
|
197
|
+
const e = entries[i];
|
|
198
|
+
if (matching(e, tag).length === 0)
|
|
199
|
+
continue;
|
|
200
|
+
if (e.phase === 'red')
|
|
201
|
+
lastRedIdx = i;
|
|
202
|
+
else if (e.phase === 'green')
|
|
203
|
+
lastGreenIdx = i;
|
|
204
|
+
}
|
|
205
|
+
const lastRed = lastRedIdx >= 0 ? entries[lastRedIdx] : undefined;
|
|
206
|
+
const lastGreen = lastGreenIdx >= 0 ? entries[lastGreenIdx] : undefined;
|
|
207
|
+
return {
|
|
208
|
+
tag,
|
|
209
|
+
red: lastRed !== undefined && matching(lastRed, tag).some((t) => !t.ok),
|
|
210
|
+
green: lastGreen !== undefined && lastGreenIdx > lastRedIdx &&
|
|
211
|
+
matching(lastGreen, tag).every((t) => t.ok),
|
|
212
|
+
vacuous: lastRed?.vacuous === true,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
plan: planId,
|
|
217
|
+
base,
|
|
218
|
+
required,
|
|
219
|
+
satisfied: required.every((r) => r.red && r.green && !r.vacuous),
|
|
220
|
+
};
|
|
221
|
+
}
|
package/dist/fm.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
4
|
+
import { ok, refuse, v } from './refusal.js';
|
|
5
|
+
const FIELD_ORDER = [
|
|
6
|
+
'id', 'type', 'status', 'summary', 'ui', 'parent', 'derives-from', 'design-from',
|
|
7
|
+
'supersedes', 'depends', 'needs', 'criteria', 'steps', 'pr', 'drift', 'design',
|
|
8
|
+
];
|
|
9
|
+
export function splitDoc(raw) {
|
|
10
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
11
|
+
if (!m) {
|
|
12
|
+
return refuse([v('frontmatter', 'missing', 'no leading --- block', 'doc starts with ---\\n<yaml>\\n---')]);
|
|
13
|
+
}
|
|
14
|
+
let meta;
|
|
15
|
+
try {
|
|
16
|
+
meta = parseYaml(m[1]);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return refuse([v('frontmatter', 'yaml-parse', String(e.message).slice(0, 120), 'valid YAML')]);
|
|
20
|
+
}
|
|
21
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
22
|
+
return refuse([v('frontmatter', 'shape', Array.isArray(meta) ? 'sequence' : String(meta), 'YAML mapping')]);
|
|
23
|
+
}
|
|
24
|
+
return ok({ meta: meta, body: m[2] });
|
|
25
|
+
}
|
|
26
|
+
export function serializeDoc(doc) {
|
|
27
|
+
const ordered = {};
|
|
28
|
+
for (const k of FIELD_ORDER)
|
|
29
|
+
if (doc.meta[k] !== undefined)
|
|
30
|
+
ordered[k] = doc.meta[k];
|
|
31
|
+
for (const k of Object.keys(doc.meta))
|
|
32
|
+
if (!(k in ordered))
|
|
33
|
+
ordered[k] = doc.meta[k];
|
|
34
|
+
const yaml = stringifyYaml(ordered, { lineWidth: 0 });
|
|
35
|
+
const body = doc.body.replace(/^\n+/, '').replace(/\s+$/, '') + '\n';
|
|
36
|
+
return `---\n${yaml}---\n\n${body}`;
|
|
37
|
+
}
|
|
38
|
+
export function readDoc(path) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(path, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return refuse([v(path, 'unreadable', 'missing or unreadable file', 'existing utf8 file')]);
|
|
45
|
+
}
|
|
46
|
+
return splitDoc(raw);
|
|
47
|
+
}
|
|
48
|
+
export function writeDoc(path, doc) {
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
writeFileSync(path, serializeDoc(doc));
|
|
51
|
+
}
|