@renzrollon/interlock 1.5.0 → 1.6.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/.claude-plugin/plugin.json +12 -1
- package/CHANGELOG.md +104 -0
- package/README.md +7 -8
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +21 -20
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- package/skills/review-code/dimensions/domain.md +21 -0
package/bin/interlock
CHANGED
|
@@ -26,9 +26,10 @@ import {
|
|
|
26
26
|
realpathSync,
|
|
27
27
|
renameSync,
|
|
28
28
|
rmSync,
|
|
29
|
+
statSync,
|
|
29
30
|
writeFileSync
|
|
30
31
|
} from 'node:fs'
|
|
31
|
-
import { dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
32
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
32
33
|
import { constants as osConstants, cpus, tmpdir } from 'node:os'
|
|
33
34
|
import { performance } from 'node:perf_hooks'
|
|
34
35
|
import {
|
|
@@ -46,11 +47,13 @@ import {
|
|
|
46
47
|
} from '../lib/waves.mjs'
|
|
47
48
|
import {
|
|
48
49
|
CLASSIFIED_PATH,
|
|
50
|
+
GRAPH_FILE_PATH,
|
|
49
51
|
LAST_STEP_PATH,
|
|
50
52
|
MANIFEST_PATH,
|
|
51
53
|
STATE_PATH,
|
|
52
54
|
VERIFY_TIMINGS_PATH,
|
|
53
55
|
checkSequence,
|
|
56
|
+
locateInput,
|
|
54
57
|
readManifest,
|
|
55
58
|
relayStep,
|
|
56
59
|
runClassified,
|
|
@@ -80,8 +83,23 @@ import {
|
|
|
80
83
|
DEFAULT_NTFY_URL
|
|
81
84
|
} from '../lib/notify.mjs'
|
|
82
85
|
import { classifySurface } from '../lib/surface.mjs'
|
|
83
|
-
import {
|
|
84
|
-
|
|
86
|
+
import {
|
|
87
|
+
applyToleranceBand,
|
|
88
|
+
carriesFindingsList,
|
|
89
|
+
evaluateGate,
|
|
90
|
+
formatGate,
|
|
91
|
+
NO_FINDINGS_LIST,
|
|
92
|
+
TOLERANCE_BAND
|
|
93
|
+
} from '../lib/findings.mjs'
|
|
94
|
+
import {
|
|
95
|
+
inspectChange,
|
|
96
|
+
isCoverableSource,
|
|
97
|
+
resolveChange,
|
|
98
|
+
listChanges,
|
|
99
|
+
formatInspection,
|
|
100
|
+
tickTasks,
|
|
101
|
+
planCoverage
|
|
102
|
+
} from '../lib/artifacts.mjs'
|
|
85
103
|
import { record, recordCleanFlow, getLevel, getState } from '../lib/autonomy.mjs'
|
|
86
104
|
import {
|
|
87
105
|
LIMITS,
|
|
@@ -92,10 +110,14 @@ import {
|
|
|
92
110
|
EFFORT,
|
|
93
111
|
LANE_CAPS,
|
|
94
112
|
SOLO,
|
|
113
|
+
MERGE_BANDS,
|
|
95
114
|
formatLimits
|
|
96
115
|
} from '../lib/limits.mjs'
|
|
97
116
|
import { planRemediation, formatRemediationPlan } from '../lib/remediate.mjs'
|
|
98
117
|
import {
|
|
118
|
+
REVIEW_POLICY_FILE,
|
|
119
|
+
composePolicy,
|
|
120
|
+
parseReviewPolicy,
|
|
99
121
|
resolveReview,
|
|
100
122
|
formatReview,
|
|
101
123
|
readReviewPolicy,
|
|
@@ -114,10 +136,36 @@ import {
|
|
|
114
136
|
VERIFY_CONTEXTS
|
|
115
137
|
} from '../lib/verify.mjs'
|
|
116
138
|
import { spillBytes } from '../lib/spill.mjs'
|
|
117
|
-
import { classifyRisk, formatRisk } from '../lib/risk.mjs'
|
|
139
|
+
import { canonicalizePath, classifyRisk, formatRisk } from '../lib/risk.mjs'
|
|
118
140
|
import { mergeDecision, foldMutationPaths } from '../lib/merge-lanes.mjs'
|
|
119
|
-
import { analyzeDrift, formatDrift } from '../lib/drift.mjs'
|
|
141
|
+
import { analyzeDrift, detectStaleSpecs, formatDrift, livingSpecEdges, readGraphAt } from '../lib/drift.mjs'
|
|
142
|
+
import {
|
|
143
|
+
BASELINE_SCHEMA,
|
|
144
|
+
buildCoverageInput,
|
|
145
|
+
changedLineCoverage,
|
|
146
|
+
coverageTotals,
|
|
147
|
+
parseAddedLines,
|
|
148
|
+
parseLcov,
|
|
149
|
+
unreportedChangedSource,
|
|
150
|
+
validateBaseline
|
|
151
|
+
} from '../lib/coverage.mjs'
|
|
152
|
+
import { capabilityOf, parseSpec } from '../lib/spec-text.mjs'
|
|
153
|
+
import { assembleDomainContext, selectDomainCapabilities } from '../lib/prompts/domain-context.mjs'
|
|
154
|
+
import { auditSpecs, formatSpecAudit } from '../lib/spec-audit.mjs'
|
|
120
155
|
import { buildChecklist, formatChecklist } from '../lib/conformance.mjs'
|
|
156
|
+
import {
|
|
157
|
+
assessMergeConfidence,
|
|
158
|
+
formatMergeConfidence,
|
|
159
|
+
formatMergeConfidenceMarkdown,
|
|
160
|
+
mergeLabelNames
|
|
161
|
+
} from '../lib/merge-confidence.mjs'
|
|
162
|
+
import {
|
|
163
|
+
pendingFromGitlabMr,
|
|
164
|
+
pipelineFromGithub,
|
|
165
|
+
pipelineFromGitlab,
|
|
166
|
+
validatePipelineInput
|
|
167
|
+
} from '../lib/pipeline.mjs'
|
|
168
|
+
import { gatherLivingList, gatherMergeEvidence, readRunEvidence } from '../lib/merge-evidence.mjs'
|
|
121
169
|
import { readLedger, summarize as summarizeLedger, formatLedger } from '../lib/ledger.mjs'
|
|
122
170
|
import {
|
|
123
171
|
computeFingerprint,
|
|
@@ -172,6 +220,36 @@ Usage:
|
|
|
172
220
|
interlock drift [--changed <files>] Report unarchived changes, broken spec
|
|
173
221
|
references, aging specs, and orphan code
|
|
174
222
|
interlock conformance [change] List the spec scenarios a change must satisfy
|
|
223
|
+
[--changed <files>] and, with --living, the requirements
|
|
224
|
+
[--living] [--base <ref>] already in force for the capabilities
|
|
225
|
+
it touches (see "conformance:" below)
|
|
226
|
+
interlock domain-context [change] The living requirements the domain
|
|
227
|
+
--changed <files> review dimension judges a diff
|
|
228
|
+
[--base <ref>] against: which capabilities, and
|
|
229
|
+
their bounded text (see
|
|
230
|
+
"domain-context:" below)
|
|
231
|
+
interlock spec-audit Count what the living specs state
|
|
232
|
+
and how they link to source. Gates
|
|
233
|
+
nothing and always exits 0
|
|
234
|
+
interlock merge-confidence [change] Decide whether a change can merge:
|
|
235
|
+
--changed <files...> MERGE | HUMAN | BLOCK, a score bounded
|
|
236
|
+
[--review <f>] by the worst band, and every signal
|
|
237
|
+
[--verify <f>] with its evidence. Offline: reads the
|
|
238
|
+
[--handoff <f>] repository and the files named, never
|
|
239
|
+
[--pipeline <f>] a hosting platform (see
|
|
240
|
+
[--coverage <f>] "merge-confidence:" below)
|
|
241
|
+
[--run <f>]
|
|
242
|
+
[--base <ref>] [--require-merge]
|
|
243
|
+
[--markdown]
|
|
244
|
+
interlock pipeline --github <file|-> Turn a platform's own check output
|
|
245
|
+
[--required <name>]... into the pipeline input "merge-
|
|
246
|
+
[--exclude <name>]... confidence --pipeline" reads. Offline:
|
|
247
|
+
[--out <file>] reads the document it is handed, never
|
|
248
|
+
interlock pipeline --gitlab-mr <file|-> gh, glab or the network (see
|
|
249
|
+
--out <file> "pipeline:" below)
|
|
250
|
+
interlock pipeline --gitlab <file|->
|
|
251
|
+
--mr <file>
|
|
252
|
+
[--exclude <name>]... [--out <file>]
|
|
175
253
|
interlock report [--since <iso>] Indicators over the three recorded
|
|
176
254
|
[--change <name>] corpora — outcome records, run
|
|
177
255
|
trajectories, review metrics. Every
|
|
@@ -278,12 +356,14 @@ Usage:
|
|
|
278
356
|
failed close
|
|
279
357
|
interlock remediate --findings <file> Plan one remediation round, or the verdict
|
|
280
358
|
interlock review --findings <f> --verdicts <f>
|
|
281
|
-
|
|
359
|
+
[--base <ref>] Resolve a review against skeptic verdicts.
|
|
282
360
|
Also reads repo-root REVIEW.md and drops
|
|
283
|
-
findings on its do-not-report paths
|
|
361
|
+
findings on its do-not-report paths;
|
|
362
|
+
--base reads those paths from the ref
|
|
284
363
|
interlock review-policy [--json] Print the parsed repo-root REVIEW.md:
|
|
285
|
-
|
|
286
|
-
and any parse problems.
|
|
364
|
+
[--base <ref>] owner + prose (advice for the reviewer),
|
|
365
|
+
both path lists and any parse problems.
|
|
366
|
+
Absent → empty
|
|
287
367
|
interlock risk [change] Classify blast radius for the continuity gate
|
|
288
368
|
interlock ledger [change] Summarize the decision ledger
|
|
289
369
|
interlock ready [change] --findings <f> Decide whether continuity may skip the human
|
|
@@ -307,6 +387,12 @@ Usage:
|
|
|
307
387
|
Spill oversized stdout/stderr to disk
|
|
308
388
|
interlock verify exec --kind <k> --command <cmd>
|
|
309
389
|
Run one planned check and time it
|
|
390
|
+
interlock verify baseline [--report <lcov>] [--baseline <file>]
|
|
391
|
+
Record a coverage report's project total
|
|
392
|
+
for this commit (see "verify baseline:")
|
|
393
|
+
interlock verify coverage --base <ref> [--report <lcov>] [--baseline <file>]
|
|
394
|
+
[--compute-base] Print the coverage input the merge
|
|
395
|
+
verdict reads: figures, never a band
|
|
310
396
|
interlock verify cluster --failures <f> Group failures by root-cause signature
|
|
311
397
|
interlock verify repair --state <file> Decide repair / halt / accept next
|
|
312
398
|
interlock plan fingerprint [change] Fingerprint a change's planning inputs
|
|
@@ -351,6 +437,23 @@ Exit codes:
|
|
|
351
437
|
ledger the ledger is missing or unparseable, or
|
|
352
438
|
needs_human or invalid rows remain
|
|
353
439
|
ready the change may not skip the human checkpoint
|
|
440
|
+
merge-confidence the verdict is BLOCK, or with --require-merge
|
|
441
|
+
anything but MERGE; an unreadable input, a
|
|
442
|
+
--pipeline file that is not well-formed, or
|
|
443
|
+
--markdown with --json also exits 1, with no
|
|
444
|
+
verdict on stdout
|
|
445
|
+
pipeline the document is not JSON or not the one the
|
|
446
|
+
platform flag expects, --out or --mr is missing
|
|
447
|
+
where required, the pipeline is not the merge
|
|
448
|
+
request's head pipeline, or a branch pipeline ran
|
|
449
|
+
on another commit; nothing is printed on stdout and
|
|
450
|
+
an earlier file at --out is removed
|
|
451
|
+
verify baseline the report cannot be read or has no project total;
|
|
452
|
+
the previous baseline is left in place
|
|
453
|
+
verify coverage --base is missing or names no commit, the report is
|
|
454
|
+
missing, unreadable or not lcov, the baseline file
|
|
455
|
+
fails its schema, or a --compute-base measurement
|
|
456
|
+
failed; nothing is printed on stdout
|
|
354
457
|
merge-lanes a lane worktree collided with another on a real
|
|
355
458
|
path, or a lane's worktree could not be located —
|
|
356
459
|
a clean fold is the only zero exit
|
|
@@ -381,7 +484,9 @@ Exit codes:
|
|
|
381
484
|
it, so an exit code would be a verdict it is forbidden to issue. Low agreement
|
|
382
485
|
is stated with its denominator and the command still succeeds; evals promote is
|
|
383
486
|
the only reader that turns that number into a decision.
|
|
384
|
-
|
|
487
|
+
domain-context exits 0 on a completed report — a skip is stated, not failed —
|
|
488
|
+
and 1 only on a --base that names no commit.
|
|
489
|
+
drift, conformance, spec-audit and report never exit non-zero either. For report that is
|
|
385
490
|
a policy, not a convenience: §4.15(a) forbids gating on the recorded corpora,
|
|
386
491
|
and a non-zero status is exactly the comparison it forbids. Nothing in the
|
|
387
492
|
loop reads the report.
|
|
@@ -462,8 +567,191 @@ gate:
|
|
|
462
567
|
limits:
|
|
463
568
|
Prints the caps the loop obeys and the workflow-runtime ceilings above them.
|
|
464
569
|
Cite these rather than restating a number in prose. With --json the per-tier
|
|
465
|
-
lane-cap table
|
|
466
|
-
solo), never entries of the iteration-count
|
|
570
|
+
lane-cap table, the solo envelope and the merge bands are their own top-level
|
|
571
|
+
groups (laneCaps, solo, mergeBands), never entries of the iteration-count
|
|
572
|
+
limits.
|
|
573
|
+
|
|
574
|
+
conformance:
|
|
575
|
+
[change] The change whose delta-spec scenarios are listed (or
|
|
576
|
+
--change <name>); with none named, the single active
|
|
577
|
+
change. Each scenario is a question, C1..Cn, with the
|
|
578
|
+
artifact and line it is written at.
|
|
579
|
+
--changed <files> Repo-relative changed paths. Listed as the source files
|
|
580
|
+
to check against; with --living they also select
|
|
581
|
+
capabilities through the graph.
|
|
582
|
+
--living Also list, after the scenarios, the living requirements
|
|
583
|
+
in force for the capabilities the change touches:
|
|
584
|
+
L1..Ln, each with its capability, the living spec's
|
|
585
|
+
path and the line of its heading, never its body. The
|
|
586
|
+
capabilities, the text and the size bound are the ones
|
|
587
|
+
"domain-context" gives the domain reviewer, so the two
|
|
588
|
+
cannot disagree. A requirement the change's own delta
|
|
589
|
+
specs modify, remove or rename is not listed; one past
|
|
590
|
+
the bound is named as left out. --json carries the
|
|
591
|
+
block as "living": { requirements, requirementsRequired,
|
|
592
|
+
omitted, source, skipped, partial }.
|
|
593
|
+
--base <ref> With --living: read the living text from this ref
|
|
594
|
+
instead of the working tree. The output names which it
|
|
595
|
+
read. A ref that names no commit is the reason the list
|
|
596
|
+
was not built; the scenarios are still listed.
|
|
597
|
+
Questions, never verdicts: nothing here decides whether a scenario or a
|
|
598
|
+
requirement is satisfied. Without --living the output is what it was before the
|
|
599
|
+
option existed. Always exits 0.
|
|
600
|
+
|
|
601
|
+
pipeline:
|
|
602
|
+
--github <file|-> The document "gh pr view <n> --json
|
|
603
|
+
headRefOid,statusCheckRollup" prints: the head commit
|
|
604
|
+
and its checks in one response. A document with no
|
|
605
|
+
statusCheckRollup is refused; an empty one is "no
|
|
606
|
+
checks". A check run that is not completed reads
|
|
607
|
+
pending; SUCCESS reads pass; NEUTRAL and SKIPPED read
|
|
608
|
+
skipped; FAILURE, TIMED_OUT, ACTION_REQUIRED and
|
|
609
|
+
STARTUP_FAILURE read fail; CANCELLED, STALE and anything
|
|
610
|
+
else read pending. A commit status reads pass for
|
|
611
|
+
SUCCESS, fail for FAILURE and ERROR, pending otherwise.
|
|
612
|
+
A re-run is read from the run that started last. With a
|
|
613
|
+
start time among them that cannot be read there is no
|
|
614
|
+
last run: the check reads the least favourable state
|
|
615
|
+
among them (fail, pending, skipped, pass, in that
|
|
616
|
+
order) and stderr says so.
|
|
617
|
+
--required <name> With --github: mark the check of that exact name as
|
|
618
|
+
required. Repeatable, and every name is kept. A name
|
|
619
|
+
that matches no reported check becomes a pending,
|
|
620
|
+
required entry. Absent, every check is not required and
|
|
621
|
+
stderr says the required set was not given.
|
|
622
|
+
--gitlab-mr <file|-> The document "glab mr view <iid> --output json" prints.
|
|
623
|
+
Needs --out. With no head pipeline it writes an input
|
|
624
|
+
with the merge request's head commit and no checks;
|
|
625
|
+
with one it writes a pending record { sha, headPipeline }
|
|
626
|
+
for the next step, which "merge-confidence --pipeline"
|
|
627
|
+
refuses. A document with no sha or no head_pipeline key
|
|
628
|
+
is refused.
|
|
629
|
+
--gitlab <file|-> The document "glab ci get --merge-request <iid> --output
|
|
630
|
+
json" prints. Needs --mr. A job reads pass for success,
|
|
631
|
+
fail for failed, skipped for skipped and for a manual
|
|
632
|
+
job allowed to fail, pending otherwise; it is required
|
|
633
|
+
when its allow_failure is false. The input's commit is
|
|
634
|
+
the record's, never typed: a pipeline that is not the
|
|
635
|
+
record's, or a branch pipeline for another commit, is
|
|
636
|
+
refused.
|
|
637
|
+
--mr <file> With --gitlab: the pending record --gitlab-mr wrote. May
|
|
638
|
+
be the same path as --out.
|
|
639
|
+
--exclude <name> With --github or --gitlab: leave the check of that exact
|
|
640
|
+
name out and record it under "excluded". Repeatable. No
|
|
641
|
+
character is special: a name that matches nothing is
|
|
642
|
+
said on stderr and not recorded. For the check whose own
|
|
643
|
+
result waits on the verdict.
|
|
644
|
+
--out <file> Write the input (or the pending record) here, creating
|
|
645
|
+
the directories above it. When no input is produced, an
|
|
646
|
+
earlier file at this path is removed, and stderr says so.
|
|
647
|
+
--json Print the input (or the pending record) instead of the
|
|
648
|
+
one-line summary.
|
|
649
|
+
Prints "pipeline <sha>: <n> check(s) (… pass, … fail, … pending, … skipped);
|
|
650
|
+
required: …; excluded: …", or "pending <sha>: head pipeline <id>". The input
|
|
651
|
+
carries names and states only. The GitLab reader is written from GitLab's and
|
|
652
|
+
glab's documentation; see docs/15-merge-confidence.md.
|
|
653
|
+
|
|
654
|
+
merge-confidence:
|
|
655
|
+
[change] The change under assessment (or --change <name>); with
|
|
656
|
+
none named, the single active change. Its artifacts,
|
|
657
|
+
decision ledger, delta-spec scenario count and
|
|
658
|
+
capabilities are read from the repository. With no
|
|
659
|
+
change, that is said on stderr and those signals read
|
|
660
|
+
amber rather than clean.
|
|
661
|
+
--changed <files...> Repo-relative changed paths ("-" for stdin). The risk
|
|
662
|
+
class, the conformance checklist, the living list and
|
|
663
|
+
drift read them.
|
|
664
|
+
--review <file> The adjudicated review: "interlock review --json", or
|
|
665
|
+
the review.json a ship run writes.
|
|
666
|
+
--verify <file> The final verification verdict: "interlock verify
|
|
667
|
+
judge --context final --json".
|
|
668
|
+
--handoff <file> The handoff's scenariosChecked and scenariosUnconfirmed,
|
|
669
|
+
and its requirementsChecked and requirementsUnconfirmed
|
|
670
|
+
for the living list. A verdictsRequired or a
|
|
671
|
+
requirementsRequired in the file is ignored: each
|
|
672
|
+
denominator is the command's own count.
|
|
673
|
+
--pipeline <file> { sha, checks: [{ name, state, required }], excluded }
|
|
674
|
+
for the head commit, state pass | fail | pending |
|
|
675
|
+
skipped. Produced by "interlock pipeline" from the
|
|
676
|
+
platform's own output. A file that is not well-formed
|
|
677
|
+
(no checks list, an entry in another state, a pending
|
|
678
|
+
GitLab record) exits 1 naming the fault, with nothing
|
|
679
|
+
on stdout: an absent list is never read as no checks.
|
|
680
|
+
--coverage <file> { configured, headTotalPct, baseTotalPct, baseSha,
|
|
681
|
+
baseIsAncestor, diffPct, changedLines, unreportedFiles,
|
|
682
|
+
unreportedAddedLines }. Produced by "interlock verify
|
|
683
|
+
coverage".
|
|
684
|
+
--run <file> The evidence file a ship run's close writes
|
|
685
|
+
(.claude/ship/merge-confidence.json). Supplies what only
|
|
686
|
+
the run observed: the adjudicated review, the final
|
|
687
|
+
verification verdict, the handoff counts, the coverage
|
|
688
|
+
figures and the run record. Used only when it carries
|
|
689
|
+
the published schema, names this change, says the run
|
|
690
|
+
committed and records HEAD as that commit; and, with
|
|
691
|
+
--base, only when the base it records is the merge base
|
|
692
|
+
of --base and HEAD, because a run's evidence covers the
|
|
693
|
+
diff that run reviewed and no other. Otherwise nothing
|
|
694
|
+
is taken from it and stderr says which of those failed.
|
|
695
|
+
Pass it with --base, as in
|
|
696
|
+
--base origin/main --run .claude/ship/merge-confidence.json
|
|
697
|
+
No base is taken from the file: without --base the
|
|
698
|
+
signals that need one stay unobserved, so the verdict
|
|
699
|
+
cannot be MERGE. An explicit --review, --verify,
|
|
700
|
+
--handoff or --coverage replaces its entry. The verdict
|
|
701
|
+
recorded in the file is never read: the command always
|
|
702
|
+
reassesses.
|
|
703
|
+
--base <ref> Diff against this ref (git diff --numstat
|
|
704
|
+
<ref>...HEAD) for the diff shape. Every path git
|
|
705
|
+
reports is assessed too, whether or not --changed
|
|
706
|
+
names it; the ones it did not are listed on stderr.
|
|
707
|
+
Absent, or a ref that is HEAD or ahead of it (an
|
|
708
|
+
empty diff by construction), the diff shape is
|
|
709
|
+
unobserved. The same ref is where REVIEW.md's
|
|
710
|
+
always-human-review list and the living requirements
|
|
711
|
+
are read from, and what spec.coverage tells an added
|
|
712
|
+
file from an edited one against. A ref that names no
|
|
713
|
+
commit exits 1 with nothing on stdout.
|
|
714
|
+
--require-merge Exit 1 unless the verdict is MERGE.
|
|
715
|
+
--markdown Print the description block for a merge request and
|
|
716
|
+
nothing else: the verdict line, the commit and the base,
|
|
717
|
+
what is driving by kind, the flags, the checks excluded
|
|
718
|
+
from the pipeline input, the two labels, and every
|
|
719
|
+
signal in a collapsed table. Each quoted reason, evidence
|
|
720
|
+
line and name is an inline code span, so nothing a
|
|
721
|
+
platform, a review or a ledger supplied renders as a
|
|
722
|
+
mention, a link or markup. The exit status is what it is
|
|
723
|
+
without the flag. With --json it exits 1.
|
|
724
|
+
--label-names Print how to recognise the labels and assess nothing:
|
|
725
|
+
the three verdict label names and the score label
|
|
726
|
+
prefix (with --json, "labels": { verdictNames,
|
|
727
|
+
scorePrefix }). It reads no input and no repository, so
|
|
728
|
+
it answers when an assessment could not run, which is
|
|
729
|
+
when a caller has an earlier assessment's labels to
|
|
730
|
+
remove and no verdict to read their names from. Exit 0.
|
|
731
|
+
--json carries "head" ({ sha }, or null when the head cannot be read), "base"
|
|
732
|
+
({ ref, sha }: the reference given and the merge base the diff was taken from,
|
|
733
|
+
sha null when it cannot be read; null with no --base) and "labels" (the verdict
|
|
734
|
+
and score labels for a merge request, each { name, color, description }, with
|
|
735
|
+
verdictNames and scorePrefix). The text form prints the two label names.
|
|
736
|
+
Every input not given reads unobserved with that reason, and unobserved ranks
|
|
737
|
+
as amber. The command never calls gh, glab or the network. Every gating signal
|
|
738
|
+
has a producer, so a clean, reviewed, verified change reads MERGE once the
|
|
739
|
+
run's evidence (--run) and a passing pipeline for HEAD (--pipeline) are given.
|
|
740
|
+
conformance.living counts the handoff's answers against the living list the
|
|
741
|
+
command builds itself (the list "conformance --living" prints). spec.coverage
|
|
742
|
+
needs --base: an added source file that no living spec and none of the
|
|
743
|
+
change's delta specs names is amber; it reads spec text, never the graph.
|
|
744
|
+
policy.humanPaths is observed when --base is given: the always-human-review
|
|
745
|
+
list is read from the REVIEW.md committed at that ref, never from the working
|
|
746
|
+
copy, and the signal is unobserved ("no --base") without it. A fact the
|
|
747
|
+
repository could not supply (a diff git cannot compute, a file unreadable at
|
|
748
|
+
the base) is named on stderr and its signal reads unobserved. Every threshold
|
|
749
|
+
is a merge-band row of "interlock limits", cited by name in the reason that
|
|
750
|
+
applies it.
|
|
751
|
+
HUMAN exits 0: a change that needs a person is not a failed command. Exit 1
|
|
752
|
+
is BLOCK, and, as for gate, also an input that could not be read or parsed,
|
|
753
|
+
which prints nothing on stdout. Exit 1 alone does not tell the two apart; a
|
|
754
|
+
verdict on stdout does.
|
|
467
755
|
|
|
468
756
|
doctor:
|
|
469
757
|
The preflight. Every condition here would otherwise be discovered mid-run, by
|
|
@@ -509,6 +797,18 @@ review:
|
|
|
509
797
|
A verdict that a finding IS real needs no citation.
|
|
510
798
|
--min-quality <n> Quality-band floor, as for gate
|
|
511
799
|
--no-tolerance Disable the quality band
|
|
800
|
+
--base <ref> Read REVIEW.md's two path lists (Do Not Report, Always
|
|
801
|
+
Human Review) from the commit at this ref instead of
|
|
802
|
+
the working tree, so a change cannot exclude its own
|
|
803
|
+
paths by editing the copy applied to it. The prose
|
|
804
|
+
still comes from the working tree. A working copy that
|
|
805
|
+
differs from the ref's, ignoring line endings, is said
|
|
806
|
+
so; a ref that names no commit exits 1 with nothing on
|
|
807
|
+
stdout. A finding's optional "requirement"
|
|
808
|
+
(openspec/specs/<capability>/spec.md#<heading>) is read
|
|
809
|
+
at the ref too; a citation naming no requirement there
|
|
810
|
+
is reported under anomalies.requirementCitations and
|
|
811
|
+
never drops the finding.
|
|
512
812
|
--changed also drives nothing else; REVIEW.md is located from --root (default
|
|
513
813
|
cwd). Findings on its do-not-report paths are dropped BEFORE the band, counted
|
|
514
814
|
as "droppedByPolicy" with the excluding path, and "policyProblems" reports a
|
|
@@ -519,10 +819,43 @@ review:
|
|
|
519
819
|
|
|
520
820
|
review-policy:
|
|
521
821
|
--root <dir> Repository root to read REVIEW.md from (default cwd).
|
|
522
|
-
--
|
|
822
|
+
--base <ref> Read both path lists from the commit at this ref and the
|
|
823
|
+
prose from the working tree; the output says which
|
|
824
|
+
ref the lists came from and whether the copies differ.
|
|
825
|
+
--json Emit { prose, excludePaths, humanPaths, owner, problems }
|
|
826
|
+
(with --base also listsFrom and differsFromBase).
|
|
523
827
|
The prose is advice a reviewer reads; it can never lower survival. The band and
|
|
524
828
|
nit cap are NOT policy fields — a threshold-shaped key is reported, not adopted.
|
|
525
|
-
Always exits 0; an absent REVIEW.md is the empty policy, not an error.
|
|
829
|
+
Always exits 0; an absent REVIEW.md is the empty policy, not an error. A --base
|
|
830
|
+
that names no commit is the one exit 1.
|
|
831
|
+
|
|
832
|
+
domain-context:
|
|
833
|
+
[change] The change whose delta specs declare the capabilities it
|
|
834
|
+
modifies (or --change <name>). None resolved is said on
|
|
835
|
+
stderr; only the graph then selects a capability.
|
|
836
|
+
--changed <files...> Repo-relative changed paths ("-" for stdin).
|
|
837
|
+
--base <ref> Read the living requirement text from the commit at this
|
|
838
|
+
ref. Absent, the working tree is read and the output
|
|
839
|
+
names it as the source.
|
|
840
|
+
Selects capabilities two ways: a changed path that a reliable graph edge links
|
|
841
|
+
to a living spec ("linked"), and a capability the change's delta specs modify
|
|
842
|
+
that has a living spec ("declared"). The graph is found root first, then the
|
|
843
|
+
state home. No graph with something declared is PARTIAL; with nothing declared
|
|
844
|
+
it is SKIPPED, stated with its reason. A graph that links none of the changed
|
|
845
|
+
paths records them as notSelected and is neither. The text is whole
|
|
846
|
+
requirements, the change's superseded ones removed, bounded by the published
|
|
847
|
+
domain-spec cap (see "interlock limits"); what is left out is named. Exit 0 on
|
|
848
|
+
a completed report; exit 1, with nothing on stdout, on a --base that names no
|
|
849
|
+
commit.
|
|
850
|
+
|
|
851
|
+
spec-audit:
|
|
852
|
+
Walks openspec/specs/**/spec.md and counts what the living specs state:
|
|
853
|
+
requirements, scenarios, bound and prohibition lines, failure and edge-case
|
|
854
|
+
scenarios, numeric literals in requirement prose. With a graph it adds how many
|
|
855
|
+
coverable source files no living spec names and which spec links name a file
|
|
856
|
+
that no longer exists; without one those measures are absent with that reason,
|
|
857
|
+
never zero. A spec it cannot read is named and left out of the totals. Always
|
|
858
|
+
exits 0. --json carries capabilities[], totals, unreadable[] and graph.
|
|
526
859
|
|
|
527
860
|
risk:
|
|
528
861
|
[change] OpenSpec change name (resolved like validate when omitted)
|
|
@@ -709,6 +1042,43 @@ verify spill:
|
|
|
709
1042
|
head/tail slice bounded by \`interlock limits\`' verify preview budget.
|
|
710
1043
|
Always exits 0.
|
|
711
1044
|
|
|
1045
|
+
verify baseline:
|
|
1046
|
+
--report <lcov> The coverage report taken on this commit. Absent, the
|
|
1047
|
+
test profile's coverage.report_path under unit.cwd.
|
|
1048
|
+
--baseline <file> Where to write it (default
|
|
1049
|
+
.claude/testing/coverage-baseline.json in the working
|
|
1050
|
+
root; never the state home).
|
|
1051
|
+
Writes { schema, sha, ref, totalPct, generatedAt }: the report's project total
|
|
1052
|
+
with the commit and branch it was taken on. A report that cannot be read or
|
|
1053
|
+
instruments no line has no total: exit 1, the previous baseline untouched.
|
|
1054
|
+
|
|
1055
|
+
verify coverage:
|
|
1056
|
+
--base <ref> REQUIRED. The diff from its merge base to HEAD is the
|
|
1057
|
+
changed-line figure.
|
|
1058
|
+
--report <lcov> Use this report instead of the profile's. Names a report
|
|
1059
|
+
for a repository whose profile declares no coverage.
|
|
1060
|
+
--baseline <file> Read this baseline instead of root then state home. A
|
|
1061
|
+
baseline file that fails its schema exits 1, naming it.
|
|
1062
|
+
--compute-base When no usable baseline exists (none, or its commit is
|
|
1063
|
+
not an ancestor of HEAD), check the merge base out in a
|
|
1064
|
+
temporary worktree, run the profile's coverage command
|
|
1065
|
+
there, record its total as the baseline and use it. The
|
|
1066
|
+
worktree is removed whether the command passed or not;
|
|
1067
|
+
a failed measurement exits 1 naming the command and its
|
|
1068
|
+
status. The worktree has no gitignored files and no
|
|
1069
|
+
installed dependencies; a shallow clone may lack the
|
|
1070
|
+
baseline's commit, which reads as not an ancestor.
|
|
1071
|
+
Prints { configured, headTotalPct, baseTotalPct, baseSha, baseIsAncestor,
|
|
1072
|
+
diffPct, changedLines, unreportedFiles, unreportedAddedLines } — figures only,
|
|
1073
|
+
no band — for "merge-confidence --coverage". unreportedFiles lists the changed
|
|
1074
|
+
source files the report holds no record for (a file the diff adds a line to
|
|
1075
|
+
that the project counts as coverable source: never a test, a configuration
|
|
1076
|
+
file or a document), and unreportedAddedLines the lines the diff adds in them;
|
|
1077
|
+
the changed-line figure still counts only lines the report instruments. No
|
|
1078
|
+
coverage command declared prints configured: false, with both of those null.
|
|
1079
|
+
A declared report that is missing, unreadable or not lcov exits 1 with nothing
|
|
1080
|
+
on stdout. Runs no coverage command unless --compute-base asks it to.
|
|
1081
|
+
|
|
712
1082
|
verify exec:
|
|
713
1083
|
--kind <k> The planned step this runs, e.g. typecheck/unit/lint (required)
|
|
714
1084
|
--command <cmd> The planned command, verbatim; run through the shell (required)
|
|
@@ -805,6 +1175,12 @@ Examples:
|
|
|
805
1175
|
interlock ledger add-auth || echo "a human still owes a decision"
|
|
806
1176
|
interlock verify plan --profile .claude/testing/profile.json --typecheck-command "npx tsc --noEmit" --json > vplan.json
|
|
807
1177
|
interlock verify judge --plan vplan.json --results results.json --context inter-wave || echo "halt"
|
|
1178
|
+
interlock verify baseline --report coverage/lcov.info
|
|
1179
|
+
interlock verify coverage --base origin/main --json > coverage-input.json
|
|
1180
|
+
interlock domain-context add-auth --changed src/auth/session.ts --base origin/main --json
|
|
1181
|
+
interlock conformance add-auth --changed src/auth/session.ts --living --base origin/main
|
|
1182
|
+
interlock merge-confidence add-auth --base origin/main --run .claude/ship/merge-confidence.json --pipeline pipeline.json --require-merge
|
|
1183
|
+
interlock spec-audit
|
|
808
1184
|
interlock plan reuse add-auth --json || true
|
|
809
1185
|
interlock plan fingerprint add-auth --write --json
|
|
810
1186
|
interlock waves --classified plan.json --json | interlock wave-state create --plan - --json > run.json
|
|
@@ -841,7 +1217,21 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
841
1217
|
'no-tdd',
|
|
842
1218
|
// `run close --notify`: both drivers pass it unconditionally, and the CLI —
|
|
843
1219
|
// not the driver — decides whether a topic is configured (design D1).
|
|
844
|
-
'notify'
|
|
1220
|
+
'notify',
|
|
1221
|
+
// `merge-confidence --require-merge`: exit 1 unless the verdict is MERGE.
|
|
1222
|
+
'require-merge',
|
|
1223
|
+
// `verify coverage --compute-base`: measure the merge base in a temporary worktree
|
|
1224
|
+
// when no usable baseline exists. A flag, not a value: it must not swallow the next argument.
|
|
1225
|
+
'compute-base',
|
|
1226
|
+
// `conformance --living`: add the living requirements to the checklist. A
|
|
1227
|
+
// flag, so a change name may follow it.
|
|
1228
|
+
'living',
|
|
1229
|
+
// `merge-confidence --markdown`: print the description block. A flag, so it
|
|
1230
|
+
// never swallows a change name that follows it.
|
|
1231
|
+
'markdown',
|
|
1232
|
+
// `merge-confidence --label-names`: print how to recognise the labels, and
|
|
1233
|
+
// assess nothing.
|
|
1234
|
+
'label-names'
|
|
845
1235
|
// `--review`, `--handoff`, `--conformance` and `--strict` are deliberately
|
|
846
1236
|
// NOT here. `interlock ready --review <findings.json>` takes a value under
|
|
847
1237
|
// the same spelling, and making the name boolean everywhere would silently
|
|
@@ -872,12 +1262,14 @@ function parseArgs(argv) {
|
|
|
872
1262
|
out.flags.changed = vals
|
|
873
1263
|
continue
|
|
874
1264
|
}
|
|
875
|
-
if (key === 'mode') {
|
|
1265
|
+
if (key === 'mode' || key === 'required' || key === 'exclude') {
|
|
876
1266
|
// Accumulated rather than last-wins: `--mode solo --mode waves` must reach
|
|
877
1267
|
// `decideMode` as an array so it is rejected as contradictory, not silently
|
|
878
1268
|
// resolved to the last flag. A single `--mode solo` stays a scalar string.
|
|
1269
|
+
// `pipeline --required` and `--exclude` accumulate for the opposite reason:
|
|
1270
|
+
// every name given is kept, and a last-wins parse would drop all but one.
|
|
879
1271
|
const val = next === undefined || next.startsWith('--') ? true : argv[++i]
|
|
880
|
-
out.flags
|
|
1272
|
+
out.flags[key] = out.flags[key] === undefined ? val : [].concat(out.flags[key], val)
|
|
881
1273
|
continue
|
|
882
1274
|
}
|
|
883
1275
|
if (next === undefined || next.startsWith('--')) {
|
|
@@ -961,6 +1353,20 @@ function readInput(value, what) {
|
|
|
961
1353
|
}
|
|
962
1354
|
}
|
|
963
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* A findings input, for `gate`, `review` and `remediate`: read as `readInput`
|
|
1358
|
+
* reads, then refused when it carries no findings list (design D10 of
|
|
1359
|
+
* label-and-harden-the-merge-verdict). The shape reader returns nothing for an
|
|
1360
|
+
* object it does not recognise, and each of those commands used to read that as
|
|
1361
|
+
* a clean review. Exit 1, the reason on stderr, nothing on stdout — as for a
|
|
1362
|
+
* file that cannot be read.
|
|
1363
|
+
*/
|
|
1364
|
+
function readFindingsInput(value, what = '--findings') {
|
|
1365
|
+
const input = readInput(value, what)
|
|
1366
|
+
if (!carriesFindingsList(input)) die(`${what} ${value === '-' ? '(stdin)' : value}: ${NO_FINDINGS_LIST}`)
|
|
1367
|
+
return input
|
|
1368
|
+
}
|
|
1369
|
+
|
|
964
1370
|
// Like `readInput`, but for a payload that is bytes to spill rather than JSON
|
|
965
1371
|
// to parse — `verify spill` hands this raw suite output, which is exactly the
|
|
966
1372
|
// text the rest of the CLI must never try to JSON.parse.
|
|
@@ -969,11 +1375,34 @@ function readRawInput(value, what) {
|
|
|
969
1375
|
return value === '-' ? readStdin() : readFileSync(value, 'utf8')
|
|
970
1376
|
}
|
|
971
1377
|
|
|
1378
|
+
/** What `die` raises inside `reasonInsteadOfExit`, in place of ending the process. */
|
|
1379
|
+
class Refusal extends Error {}
|
|
1380
|
+
let refusalDepth = 0
|
|
1381
|
+
|
|
972
1382
|
function die(message) {
|
|
1383
|
+
if (refusalDepth > 0) throw new Refusal(message)
|
|
973
1384
|
process.stderr.write(`interlock: ${message}\n`)
|
|
974
1385
|
process.exit(1)
|
|
975
1386
|
}
|
|
976
1387
|
|
|
1388
|
+
/**
|
|
1389
|
+
* Run `fn` so that a `die()` anywhere beneath it returns `{ reason }` rather
|
|
1390
|
+
* than ending the process. A subcommand's body can then be called from a ship
|
|
1391
|
+
* run's close, where a refusal is a fact to report and never an exit: the
|
|
1392
|
+
* subcommand dies with the reason, the close records it.
|
|
1393
|
+
*/
|
|
1394
|
+
function reasonInsteadOfExit(fn) {
|
|
1395
|
+
refusalDepth += 1
|
|
1396
|
+
try {
|
|
1397
|
+
return fn()
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
if (err instanceof Refusal) return { reason: err.message }
|
|
1400
|
+
throw err
|
|
1401
|
+
} finally {
|
|
1402
|
+
refusalDepth -= 1
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
977
1406
|
function warn(message) {
|
|
978
1407
|
process.stderr.write(`interlock: ${message}\n`)
|
|
979
1408
|
}
|
|
@@ -1844,6 +2273,767 @@ function readChangeArtifacts(root, change) {
|
|
|
1844
2273
|
return artifacts
|
|
1845
2274
|
}
|
|
1846
2275
|
|
|
2276
|
+
/**
|
|
2277
|
+
* One caller-supplied input to `merge-confidence`, read before any assessment
|
|
2278
|
+
* (design D9). Not given is `null`, which the signal reads as unobserved. An
|
|
2279
|
+
* input that cannot be read or parsed ends the command through `die()`, as
|
|
2280
|
+
* `gate` does: exit 1, the reason on stderr naming the file, nothing on stdout.
|
|
2281
|
+
*/
|
|
2282
|
+
function readMergeInput(flags, key) {
|
|
2283
|
+
const value = flags[key]
|
|
2284
|
+
if (value === undefined) return null
|
|
2285
|
+
if (value === true) die(`--${key} needs a file (or "-" for stdin)`)
|
|
2286
|
+
let raw
|
|
2287
|
+
if (value === '-') raw = readStdin()
|
|
2288
|
+
else {
|
|
2289
|
+
try {
|
|
2290
|
+
raw = readFileSync(value, 'utf8')
|
|
2291
|
+
} catch (err) {
|
|
2292
|
+
die(`--${key} ${value} could not be read: ${err.message}`)
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
let parsed
|
|
2296
|
+
try {
|
|
2297
|
+
parsed = JSON.parse(raw)
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
die(`--${key} ${value} is not valid JSON: ${err.message}`)
|
|
2300
|
+
}
|
|
2301
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) die(`--${key} ${value} must hold a JSON object`)
|
|
2302
|
+
return parsed
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* One file as `ref` has it, for a reader that must not trust the working tree
|
|
2307
|
+
* (judge-against-base-and-living-specs D3): `{ text }`, `{ absent: true }` when
|
|
2308
|
+
* the commit has no such file, or `{ tooLarge: true, size }` over `maxBytes`.
|
|
2309
|
+
* Throws, naming the ref, when `ref` is not a commit — a base nobody can read is
|
|
2310
|
+
* the caller's to degrade on, never a guess. Handed to the run as
|
|
2311
|
+
* `ctx.deps.readAtRef`.
|
|
2312
|
+
*/
|
|
2313
|
+
function readAtRef(root, ref, path, { maxBytes } = {}) {
|
|
2314
|
+
const git = args =>
|
|
2315
|
+
execFileSync('git', args, {
|
|
2316
|
+
cwd: root,
|
|
2317
|
+
encoding: 'utf8',
|
|
2318
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2319
|
+
maxBuffer: 64 * 1024 * 1024
|
|
2320
|
+
})
|
|
2321
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-')) {
|
|
2322
|
+
throw new Error(`cannot resolve ${JSON.stringify(ref)} to a commit`)
|
|
2323
|
+
}
|
|
2324
|
+
try {
|
|
2325
|
+
git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
2326
|
+
} catch {
|
|
2327
|
+
throw new Error(`cannot resolve "${ref}" to a commit`)
|
|
2328
|
+
}
|
|
2329
|
+
try {
|
|
2330
|
+
git(['cat-file', '-e', `${ref}:${path}`])
|
|
2331
|
+
} catch {
|
|
2332
|
+
return { absent: true }
|
|
2333
|
+
}
|
|
2334
|
+
if (Number.isFinite(maxBytes)) {
|
|
2335
|
+
const size = Number(git(['cat-file', '-s', `${ref}:${path}`]).trim())
|
|
2336
|
+
if (size > maxBytes) return { tooLarge: true, size }
|
|
2337
|
+
}
|
|
2338
|
+
return { text: git(['show', `${ref}:${path}`]) }
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** Run git in `root`; stdout on success, an Error carrying git's own words on failure. */
|
|
2342
|
+
function gitText(root, args) {
|
|
2343
|
+
try {
|
|
2344
|
+
return execFileSync('git', args, {
|
|
2345
|
+
cwd: root,
|
|
2346
|
+
encoding: 'utf8',
|
|
2347
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2348
|
+
maxBuffer: 256 * 1024 * 1024
|
|
2349
|
+
})
|
|
2350
|
+
} catch (err) {
|
|
2351
|
+
const detail = (err.stderr && String(err.stderr).trim()) || err.message
|
|
2352
|
+
throw new Error(detail)
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** The commit `ref` names, or null when it names none (never a guess). */
|
|
2357
|
+
function commitOf(root, ref) {
|
|
2358
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-')) return null
|
|
2359
|
+
try {
|
|
2360
|
+
return gitText(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() || null
|
|
2361
|
+
} catch {
|
|
2362
|
+
return null
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* `--base <ref>` on the commands that read policy, specs or a diff from a ref.
|
|
2368
|
+
* Null when absent; a flag with no value, a value that reads as an option, and a
|
|
2369
|
+
* ref that names no commit each end the command with nothing on stdout.
|
|
2370
|
+
*/
|
|
2371
|
+
function baseRefOf(root, flags) {
|
|
2372
|
+
if (flags.base === undefined) return null
|
|
2373
|
+
if (flags.base === true) die('--base needs a ref')
|
|
2374
|
+
const base = String(flags.base)
|
|
2375
|
+
if (base.startsWith('-')) die(`--base ${base}: a base must name a ref, not an option`)
|
|
2376
|
+
if (!commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit`)
|
|
2377
|
+
return base
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** The working tree's `REVIEW.md` as text plus its parsed policy, or null when absent. */
|
|
2381
|
+
function workingPolicyCopy(root) {
|
|
2382
|
+
const path = join(root, REVIEW_POLICY_FILE)
|
|
2383
|
+
if (!existsSync(path)) return null
|
|
2384
|
+
let text = ''
|
|
2385
|
+
try {
|
|
2386
|
+
if (statSync(path).size <= LIMITS.maxReviewPolicyBytes) text = readFileSync(path, 'utf8')
|
|
2387
|
+
} catch {
|
|
2388
|
+
// `readReviewPolicy` reports an unreadable file as a problem.
|
|
2389
|
+
}
|
|
2390
|
+
return { text, policy: readReviewPolicy(root) }
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* The committed `REVIEW.md` at `ref` as text plus its parsed policy, or null when
|
|
2395
|
+
* the commit has none. An oversized or unreadable copy is an empty policy that
|
|
2396
|
+
* carries the reason as a problem, as the working-tree reader does.
|
|
2397
|
+
*/
|
|
2398
|
+
function basePolicyCopy(root, ref) {
|
|
2399
|
+
let read
|
|
2400
|
+
try {
|
|
2401
|
+
read = readAtRef(root, ref, REVIEW_POLICY_FILE, { maxBytes: LIMITS.maxReviewPolicyBytes })
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
read = { error: (err && err.message) || String(err) }
|
|
2404
|
+
}
|
|
2405
|
+
if (typeof read.text === 'string') return { text: read.text, policy: parseReviewPolicy(read.text) }
|
|
2406
|
+
if (read.absent) return null
|
|
2407
|
+
const policy = parseReviewPolicy('')
|
|
2408
|
+
policy.problems.push(
|
|
2409
|
+
read.tooLarge
|
|
2410
|
+
? `${REVIEW_POLICY_FILE} is ${read.size} bytes, over the ${LIMITS.maxReviewPolicyBytes}-byte scan ` +
|
|
2411
|
+
`cap — not parsed; review proceeds under default policy`
|
|
2412
|
+
: `${REVIEW_POLICY_FILE} at ${ref} could not be read: ${read.error || 'unreadable'}`
|
|
2413
|
+
)
|
|
2414
|
+
return { text: '', policy }
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* The policy a command judges by (judge-against-base-and-living-specs D3): with a
|
|
2419
|
+
* base, prose and owner from the working tree and both enforced path lists from
|
|
2420
|
+
* the base; without one, the working tree supplies everything, as before.
|
|
2421
|
+
* `baseProblems` are the base copy's alone — the merge verdict names those.
|
|
2422
|
+
*/
|
|
2423
|
+
function reviewPolicyAt(root, base) {
|
|
2424
|
+
if (!base) return { policy: readReviewPolicy(root), ref: null, differsFromBase: null, baseProblems: [] }
|
|
2425
|
+
const working = workingPolicyCopy(root)
|
|
2426
|
+
const committed = basePolicyCopy(root, base)
|
|
2427
|
+
const policy = composePolicy({ working, base: committed, ref: base })
|
|
2428
|
+
return {
|
|
2429
|
+
policy,
|
|
2430
|
+
ref: base,
|
|
2431
|
+
differsFromBase: policy.differsFromBase,
|
|
2432
|
+
baseProblems: committed ? committed.policy.problems : []
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
const SPEC_CITATION_PATH = /^openspec\/specs\/.+\/spec\.md$/
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* For every living spec a finding cites, that spec's requirement keys at `base`
|
|
2440
|
+
* (the working tree with no base), or `{ unreadable }`. Only canonical
|
|
2441
|
+
* `openspec/specs/**\/spec.md` paths are read, so a citation cannot make the
|
|
2442
|
+
* command read an arbitrary file.
|
|
2443
|
+
*/
|
|
2444
|
+
function requirementHeadingsAt(root, base, findings) {
|
|
2445
|
+
const headings = new Map()
|
|
2446
|
+
let cited = []
|
|
2447
|
+
try {
|
|
2448
|
+
cited = applyToleranceBand(findings, null).reportable
|
|
2449
|
+
} catch {
|
|
2450
|
+
// A findings file the gate cannot flatten has no citations to read.
|
|
2451
|
+
}
|
|
2452
|
+
for (const finding of cited) {
|
|
2453
|
+
const text = finding && typeof finding.requirement === 'string' ? finding.requirement : ''
|
|
2454
|
+
const hash = text.indexOf('#')
|
|
2455
|
+
if (hash === -1) continue
|
|
2456
|
+
const path = canonicalizePath(text.slice(0, hash).trim())
|
|
2457
|
+
if (!path || !SPEC_CITATION_PATH.test(path) || headings.has(path)) continue
|
|
2458
|
+
const read = readSpecText(root, base, path)
|
|
2459
|
+
if (typeof read.text === 'string') headings.set(path, parseSpec(read.text).requirements.map(r => r.key))
|
|
2460
|
+
else if (read.absent) headings.set(path, { unreadable: `${path} does not exist ${base ? `at ${base}` : 'in the working tree'}` })
|
|
2461
|
+
else headings.set(path, { unreadable: read.error || 'unreadable' })
|
|
2462
|
+
}
|
|
2463
|
+
return headings
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** One file at `base` (null = the working tree): `{ text }`, `{ absent }` or `{ error }`; never throws. */
|
|
2467
|
+
function readSpecText(root, base, relPath) {
|
|
2468
|
+
if (base) {
|
|
2469
|
+
try {
|
|
2470
|
+
return readAtRef(root, base, relPath)
|
|
2471
|
+
} catch (err) {
|
|
2472
|
+
return { error: (err && err.message) || String(err) }
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
const path = join(root, relPath)
|
|
2476
|
+
try {
|
|
2477
|
+
if (!existsSync(path)) return { absent: true }
|
|
2478
|
+
return { text: readFileSync(path, 'utf8') }
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
return { error: (err && err.message) || String(err) }
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* The living list `conformance --living` adds to the checklist (design D5 of
|
|
2486
|
+
* complete-and-record-the-merge-verdict): the graph read root first then the
|
|
2487
|
+
* state home, the living text from `--base` when one is named and from the
|
|
2488
|
+
* working tree otherwise. Never ends the command, which exits 0 in every case:
|
|
2489
|
+
* a base nobody can read is the reason the list was not built.
|
|
2490
|
+
*/
|
|
2491
|
+
function livingListFor(root, flags, { change, changed }) {
|
|
2492
|
+
const notBuilt = (reason, source) => ({
|
|
2493
|
+
skipped: reason,
|
|
2494
|
+
partial: null,
|
|
2495
|
+
requirements: [],
|
|
2496
|
+
requirementsRequired: 0,
|
|
2497
|
+
omitted: [],
|
|
2498
|
+
source
|
|
2499
|
+
})
|
|
2500
|
+
let base = null
|
|
2501
|
+
if (flags.base !== undefined) {
|
|
2502
|
+
if (flags.base === true) return notBuilt('--base needs a ref, so there is no base to read the living requirements from', 'no base')
|
|
2503
|
+
base = String(flags.base)
|
|
2504
|
+
if (base.startsWith('-') || !commitOf(root, base)) {
|
|
2505
|
+
return notBuilt(`--base ${base} names no commit, so the living requirements could not be read from it`, `base ${base}`)
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
const graph = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
|
|
2509
|
+
const { living, notes } = gatherLivingList(root, {
|
|
2510
|
+
change,
|
|
2511
|
+
changedPaths: changed,
|
|
2512
|
+
base,
|
|
2513
|
+
deps: { readAtRef },
|
|
2514
|
+
...(graph.path ? { graphPath: graph.path } : {})
|
|
2515
|
+
})
|
|
2516
|
+
if (living) return living
|
|
2517
|
+
return notBuilt(
|
|
2518
|
+
notes.map(note => `${note.what}: ${note.reason}`).join('; ') || 'the living requirements could not be read',
|
|
2519
|
+
base ? `base ${base}` : 'the working tree'
|
|
2520
|
+
)
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/** The graph, root first then the state home, as `run start` locates it. */
|
|
2524
|
+
function readGraphFor(root, flags) {
|
|
2525
|
+
const located = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
|
|
2526
|
+
if (!located.path) {
|
|
2527
|
+
return { links: null, reason: 'no graph has been built — run /interlock:bootstrap once', source: 'none' }
|
|
2528
|
+
}
|
|
2529
|
+
return { ...readGraphAt(located.path), source: located.source }
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// --- coverage (judge-against-base-and-living-specs D2) ------------------------
|
|
2533
|
+
|
|
2534
|
+
const COVERAGE_BASELINE_PATH = join('.claude', 'testing', 'coverage-baseline.json')
|
|
2535
|
+
|
|
2536
|
+
/** The test profile as parsed JSON plus where it was found; null when there is none. */
|
|
2537
|
+
function readTestProfile(root, flags) {
|
|
2538
|
+
const found = locateInput(root, startStateHome(root, flags).home, TEST_PROFILE_PATH)
|
|
2539
|
+
if (!found.path) return null
|
|
2540
|
+
let parsed
|
|
2541
|
+
try {
|
|
2542
|
+
parsed = JSON.parse(readFileSync(found.path, 'utf8'))
|
|
2543
|
+
} catch (err) {
|
|
2544
|
+
die(`the test profile ${found.path} could not be read: ${err.message}`)
|
|
2545
|
+
}
|
|
2546
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
2547
|
+
die(`the test profile ${found.path} must hold a JSON object`)
|
|
2548
|
+
}
|
|
2549
|
+
return { path: found.path, profile: parsed }
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
/** The directory a profile's `report_path` is relative to, kept inside `base`. */
|
|
2553
|
+
function unitCwdOf(base, profile) {
|
|
2554
|
+
const cwd = profile && profile.unit && typeof profile.unit.cwd === 'string' && profile.unit.cwd.trim() ? profile.unit.cwd : '.'
|
|
2555
|
+
const target = resolve(base, cwd)
|
|
2556
|
+
if (target !== resolve(base) && !target.startsWith(resolve(base) + sep)) {
|
|
2557
|
+
die(`the test profile's unit.cwd ${JSON.stringify(cwd)} leaves the repository`)
|
|
2558
|
+
}
|
|
2559
|
+
return target
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
/** Real paths, so an lcov `SF:` written absolute and a repository root given through a symlink still match. */
|
|
2563
|
+
function realOr(path) {
|
|
2564
|
+
try {
|
|
2565
|
+
return realpathSync(path)
|
|
2566
|
+
} catch {
|
|
2567
|
+
return resolve(path)
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/** Parse an lcov report text into records plus its total, relative to `root`/`reportDir`. */
|
|
2572
|
+
function readLcovFigures(text, root, reportDir) {
|
|
2573
|
+
const parsed = parseLcov(text, { root: realOr(root), reportDir: realOr(reportDir) })
|
|
2574
|
+
return { ...parsed, totals: coverageTotals(parsed.records) }
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/** Write JSON beside its destination and rename it into place: a failure leaves the old file. */
|
|
2578
|
+
function writeJsonAtomic(path, value) {
|
|
2579
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
2580
|
+
const staging = `${path}.interlock-partial`
|
|
2581
|
+
try {
|
|
2582
|
+
writeFileSync(staging, JSON.stringify(value, null, 2) + '\n')
|
|
2583
|
+
renameSync(staging, path)
|
|
2584
|
+
} catch (err) {
|
|
2585
|
+
try {
|
|
2586
|
+
rmSync(staging, { force: true })
|
|
2587
|
+
} catch {
|
|
2588
|
+
// Cleaning up a file that may never have existed cannot be the reported failure.
|
|
2589
|
+
}
|
|
2590
|
+
throw err
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** The baseline as the caller names it, else root then state home; null when there is none. */
|
|
2595
|
+
function readCoverageBaseline(root, flags) {
|
|
2596
|
+
let path
|
|
2597
|
+
if (flags.baseline !== undefined) {
|
|
2598
|
+
if (flags.baseline === true) die('--baseline needs a file')
|
|
2599
|
+
path = String(flags.baseline)
|
|
2600
|
+
if (!existsSync(path)) die(`--baseline ${path} does not exist`)
|
|
2601
|
+
} else {
|
|
2602
|
+
path = locateInput(root, startStateHome(root, flags).home, COVERAGE_BASELINE_PATH).path
|
|
2603
|
+
if (!path) return null
|
|
2604
|
+
}
|
|
2605
|
+
let parsed
|
|
2606
|
+
try {
|
|
2607
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
2608
|
+
} catch (err) {
|
|
2609
|
+
die(`the coverage baseline ${path} could not be read: ${err.message}`)
|
|
2610
|
+
}
|
|
2611
|
+
const verdict = validateBaseline(parsed)
|
|
2612
|
+
if (!verdict.ok) die(`the coverage baseline ${path} is not valid: ${verdict.problems.join('; ')}`)
|
|
2613
|
+
return { path, ...parsed }
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* Whether `sha` is an ancestor of HEAD, read from the repository: exit 0 true, 1
|
|
2618
|
+
* false; any other status (an unknown commit in a shallow clone) is false, with
|
|
2619
|
+
* the likely cause on stderr.
|
|
2620
|
+
*/
|
|
2621
|
+
function isAncestorOfHead(root, sha) {
|
|
2622
|
+
const r = spawnSync('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], { cwd: root, encoding: 'utf8' })
|
|
2623
|
+
if (r.status === 0) return true
|
|
2624
|
+
if (r.status === 1) return false
|
|
2625
|
+
warn(`could not tell whether ${sha} is an ancestor of HEAD (git exited ${r.status ?? 'abnormally'}); a shallow clone that lacks the commit is the usual cause — fetch more history`)
|
|
2626
|
+
return false
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Measure the coverage of the merge base in a temporary worktree and record it as
|
|
2631
|
+
* the baseline (design D2). The worktree is removed in `finally`, whether the
|
|
2632
|
+
* command passed, failed or could not start; every failure is thrown to the
|
|
2633
|
+
* caller, which exits only after the cleanup, because `process.exit` would skip it.
|
|
2634
|
+
*/
|
|
2635
|
+
function computeBaselineAt(root, base, profile) {
|
|
2636
|
+
const coverage = profile && profile.coverage
|
|
2637
|
+
const command = coverage && typeof coverage.command === 'string' ? coverage.command : ''
|
|
2638
|
+
if (!command.trim()) throw new Error('--compute-base needs a coverage command in the test profile')
|
|
2639
|
+
const reportRel = typeof coverage.report_path === 'string' && coverage.report_path ? coverage.report_path : 'coverage/lcov.info'
|
|
2640
|
+
if (isAbsolute(reportRel)) throw new Error(`coverage.report_path ${reportRel} must be relative to compute a base figure`)
|
|
2641
|
+
|
|
2642
|
+
const mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim()
|
|
2643
|
+
const tmp = realOr(mkdtempSync(join(tmpdir(), 'interlock-cov-base-')))
|
|
2644
|
+
let added = false
|
|
2645
|
+
try {
|
|
2646
|
+
gitText(root, ['worktree', 'add', '--detach', tmp, mergeBase])
|
|
2647
|
+
added = true
|
|
2648
|
+
const cwd = resolve(tmp, (profile.unit && typeof profile.unit.cwd === 'string' && profile.unit.cwd.trim()) || '.')
|
|
2649
|
+
if (cwd !== tmp && !cwd.startsWith(tmp + sep)) throw new Error(`unit.cwd leaves the repository`)
|
|
2650
|
+
// The command's own output goes to stderr: stdout is the coverage input.
|
|
2651
|
+
const ran = spawnSync(command, { shell: true, cwd, stdio: ['ignore', 2, 2] })
|
|
2652
|
+
if (ran.error) throw new Error(`the coverage command "${command}" could not start on merge base ${mergeBase.slice(0, 7)}: ${ran.error.message}`)
|
|
2653
|
+
if (ran.status !== 0) {
|
|
2654
|
+
throw new Error(
|
|
2655
|
+
`the coverage command "${command}" exited with status ${ran.status ?? `signal ${ran.signal}`} on merge base ${mergeBase.slice(0, 7)}`
|
|
2656
|
+
)
|
|
2657
|
+
}
|
|
2658
|
+
const reportPath = resolve(cwd, reportRel)
|
|
2659
|
+
let text
|
|
2660
|
+
try {
|
|
2661
|
+
text = readFileSync(reportPath, 'utf8')
|
|
2662
|
+
} catch (err) {
|
|
2663
|
+
throw new Error(`the coverage command "${command}" passed on merge base ${mergeBase.slice(0, 7)} but its report ${reportRel} could not be read: ${err.message}`)
|
|
2664
|
+
}
|
|
2665
|
+
const figures = readLcovFigures(text, tmp, cwd)
|
|
2666
|
+
if (figures.totals.totalPct === null) {
|
|
2667
|
+
throw new Error(`the report of "${command}" on merge base ${mergeBase.slice(0, 7)} instruments no line, so it has no project total`)
|
|
2668
|
+
}
|
|
2669
|
+
return { sha: mergeBase, totalPct: figures.totals.totalPct }
|
|
2670
|
+
} finally {
|
|
2671
|
+
if (added) {
|
|
2672
|
+
try {
|
|
2673
|
+
gitText(root, ['worktree', 'remove', '--force', tmp])
|
|
2674
|
+
} catch {
|
|
2675
|
+
// Falls through to the directory removal and the prune below.
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
2680
|
+
} catch {
|
|
2681
|
+
// Best effort: the prune below drops the registration either way.
|
|
2682
|
+
}
|
|
2683
|
+
try {
|
|
2684
|
+
gitText(root, ['worktree', 'prune'])
|
|
2685
|
+
} catch {
|
|
2686
|
+
// A prune that fails leaves a stale registration, never a stale directory.
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* The coverage input the merge verdict reads, computed from the head report, the
|
|
2693
|
+
* baseline and the diff against `base`: figures only, no band (design D2 of
|
|
2694
|
+
* judge-against-base-and-living-specs). This is the body of `interlock verify
|
|
2695
|
+
* coverage`, and a ship run's close calls it too (`ctx.deps.coverageInput`,
|
|
2696
|
+
* design D3 of complete-and-record-the-merge-verdict), so it never ends the
|
|
2697
|
+
* process: it returns `{ input, text }`, or `{ reason }` with the words the
|
|
2698
|
+
* subcommand exits on.
|
|
2699
|
+
*
|
|
2700
|
+
* `flags` are the subcommand's own (`--report`, `--baseline`, `--compute-base`,
|
|
2701
|
+
* `--state-home`); a close passes none of the first three, so it reads the
|
|
2702
|
+
* profile's report and the recorded baseline and runs no coverage command.
|
|
2703
|
+
*/
|
|
2704
|
+
function coverageInputFor(root, base, flags = {}) {
|
|
2705
|
+
return reasonInsteadOfExit(() => {
|
|
2706
|
+
if (!commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit`)
|
|
2707
|
+
const found = readTestProfile(root, flags)
|
|
2708
|
+
const profile = found ? found.profile : null
|
|
2709
|
+
const coverage = profile && profile.coverage && typeof profile.coverage === 'object' ? profile.coverage : null
|
|
2710
|
+
const named = flags.report !== undefined
|
|
2711
|
+
if (flags.report === true) die('--report needs an lcov file')
|
|
2712
|
+
const declared = Boolean(coverage && coverage.enabled !== false && typeof coverage.command === 'string' && coverage.command.trim())
|
|
2713
|
+
if (!named && !declared) {
|
|
2714
|
+
return {
|
|
2715
|
+
input: buildCoverageInput({ configured: false }),
|
|
2716
|
+
text: 'coverage: not configured (no coverage command in the test profile)\n'
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const reportRel = named ? null : typeof coverage.report_path === 'string' && coverage.report_path ? coverage.report_path : 'coverage/lcov.info'
|
|
2721
|
+
const reportDir = unitCwdOf(root, profile)
|
|
2722
|
+
const reportPath = named ? String(flags.report) : resolve(reportDir, reportRel)
|
|
2723
|
+
const format = named ? 'lcov' : coverage.format || 'lcov'
|
|
2724
|
+
if (format !== 'lcov') die(`the coverage report ${reportRel} is ${format}: only lcov is supported`)
|
|
2725
|
+
let reportText
|
|
2726
|
+
try {
|
|
2727
|
+
reportText = readFileSync(reportPath, 'utf8')
|
|
2728
|
+
} catch (err) {
|
|
2729
|
+
die(
|
|
2730
|
+
`the coverage report ${named ? reportPath : reportRel} could not be read (${err.code || err.message}) — ` +
|
|
2731
|
+
`run the coverage command${declared ? ` (${coverage.command})` : ''} first`
|
|
2732
|
+
)
|
|
2733
|
+
}
|
|
2734
|
+
const head = readLcovFigures(reportText, root, reportDir)
|
|
2735
|
+
if (head.outsideRoot) warn(`${head.outsideRoot} record(s) in the report lie outside the repository and were left out of both figures`)
|
|
2736
|
+
|
|
2737
|
+
let diffText
|
|
2738
|
+
try {
|
|
2739
|
+
diffText = gitText(root, ['diff', '-U0', '--no-color', '--no-ext-diff', `${base}...HEAD`])
|
|
2740
|
+
} catch (err) {
|
|
2741
|
+
die(`--base ${base}: git could not diff it against HEAD: ${err.message}`)
|
|
2742
|
+
}
|
|
2743
|
+
const added = parseAddedLines(diffText)
|
|
2744
|
+
const changed = changedLineCoverage(head.records, added)
|
|
2745
|
+
// The changed source the report holds no record for: named, never judged
|
|
2746
|
+
// (design D13 of label-and-harden-the-merge-verdict).
|
|
2747
|
+
const unreported = unreportedChangedSource(head.records, added)
|
|
2748
|
+
|
|
2749
|
+
let baseline = readCoverageBaseline(root, flags)
|
|
2750
|
+
let baseIsAncestor = baseline ? isAncestorOfHead(root, baseline.sha) : null
|
|
2751
|
+
if (flags['compute-base'] === true && (!baseline || baseIsAncestor !== true)) {
|
|
2752
|
+
let computed
|
|
2753
|
+
try {
|
|
2754
|
+
computed = computeBaselineAt(root, base, profile)
|
|
2755
|
+
} catch (err) {
|
|
2756
|
+
die(`--compute-base: ${(err && err.message) || err}`)
|
|
2757
|
+
}
|
|
2758
|
+
const target = typeof flags.baseline === 'string' ? flags.baseline : join(root, COVERAGE_BASELINE_PATH)
|
|
2759
|
+
const recorded = {
|
|
2760
|
+
schema: BASELINE_SCHEMA,
|
|
2761
|
+
sha: computed.sha,
|
|
2762
|
+
ref: base,
|
|
2763
|
+
totalPct: computed.totalPct,
|
|
2764
|
+
generatedAt: new Date().toISOString()
|
|
2765
|
+
}
|
|
2766
|
+
try {
|
|
2767
|
+
writeJsonAtomic(target, recorded)
|
|
2768
|
+
} catch (err) {
|
|
2769
|
+
die(`--compute-base: the baseline could not be written to ${target}: ${err.message}`)
|
|
2770
|
+
}
|
|
2771
|
+
warn(`recorded a coverage baseline: ${recorded.totalPct}% at merge base ${computed.sha.slice(0, 7)} → ${target}`)
|
|
2772
|
+
baseline = recorded
|
|
2773
|
+
baseIsAncestor = isAncestorOfHead(root, computed.sha)
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const input = buildCoverageInput({ configured: true, head: head.totals, baseline, baseIsAncestor, changed, unreported })
|
|
2777
|
+
const fmt = v => (v === null ? 'unobserved' : v)
|
|
2778
|
+
return {
|
|
2779
|
+
input,
|
|
2780
|
+
text: [
|
|
2781
|
+
'COVERAGE INPUT',
|
|
2782
|
+
` head total: ${fmt(input.headTotalPct)}%`,
|
|
2783
|
+
` base total: ${fmt(input.baseTotalPct)}%${input.baseSha ? ` at ${input.baseSha.slice(0, 7)} (${input.baseIsAncestor ? 'an ancestor of HEAD' : 'NOT an ancestor of HEAD'})` : ' (no baseline)'}`,
|
|
2784
|
+
` changed lines: ${fmt(input.changedLines)}, covered ${fmt(input.diffPct)}%`,
|
|
2785
|
+
input.unreportedFiles.length
|
|
2786
|
+
? ` changed source the report does not list: ${input.unreportedFiles.length} file(s), ${input.unreportedAddedLines} added line(s): ${input.unreportedFiles.join(', ')}`
|
|
2787
|
+
: ' changed source the report does not list: none',
|
|
2788
|
+
''
|
|
2789
|
+
].join('\n')
|
|
2790
|
+
}
|
|
2791
|
+
})
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** HEAD's sha, or null when the root is not a git checkout — never a guess. */
|
|
2795
|
+
function headShaOf(root) {
|
|
2796
|
+
try {
|
|
2797
|
+
const out = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
2798
|
+
cwd: root,
|
|
2799
|
+
encoding: 'utf8',
|
|
2800
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
2801
|
+
})
|
|
2802
|
+
return out.trim() || null
|
|
2803
|
+
} catch {
|
|
2804
|
+
return null
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// --- git, as the merge-confidence gather reaches it ---------------------------
|
|
2809
|
+
//
|
|
2810
|
+
// `lib/merge-evidence.mjs` reads git only through four functions, each taking
|
|
2811
|
+
// the root first: `diffShape`, `nameStatus`, `listFiles` and `readAtRef` (above).
|
|
2812
|
+
// They are handed to the `merge-confidence` command's gather and to a ship run's
|
|
2813
|
+
// close alike (`ctx.deps`). None of them ends the process: a git that cannot
|
|
2814
|
+
// answer THROWS, in git's own words, and the gather turns that into a note and
|
|
2815
|
+
// an unobserved signal. Whether an unreadable base ends a command is the
|
|
2816
|
+
// command's decision, made before it gathers.
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* The diff shape against a base: `git diff --numstat -z <base>...HEAD`. A
|
|
2820
|
+
* binary row (`-\t-\tpath`) counts as one file and no lines; a rename counts
|
|
2821
|
+
* as git reports it, and both of its paths are changed paths. A base that is
|
|
2822
|
+
* HEAD or a descendant of it diffs empty by construction, so it carries
|
|
2823
|
+
* `nothingToDiff` instead of counts — an empty diff there proves nothing about
|
|
2824
|
+
* the change. Throws when git cannot diff the base against HEAD.
|
|
2825
|
+
*/
|
|
2826
|
+
function diffShape(root, base, headSha) {
|
|
2827
|
+
const explain = err => new Error(`git could not diff ${base} against HEAD: ${err.message}`)
|
|
2828
|
+
// HEAD as the repository has it; the caller's sha only when it cannot be read.
|
|
2829
|
+
const head = headShaOf(root) || headSha
|
|
2830
|
+
let mergeBase
|
|
2831
|
+
try {
|
|
2832
|
+
mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim()
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
throw explain(err)
|
|
2835
|
+
}
|
|
2836
|
+
if (head && mergeBase === head) {
|
|
2837
|
+
return {
|
|
2838
|
+
base,
|
|
2839
|
+
paths: [],
|
|
2840
|
+
nothingToDiff: `--base ${base} is HEAD or a descendant of it, so ${base}...HEAD is empty by construction and says nothing about the change`
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
let out
|
|
2844
|
+
try {
|
|
2845
|
+
out = gitText(root, ['diff', '--numstat', '-z', `${base}...HEAD`])
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
throw explain(err)
|
|
2848
|
+
}
|
|
2849
|
+
// -z: `added\tdeleted\tpath\0`, or for a rename `added\tdeleted\t\0old\0new\0`.
|
|
2850
|
+
let changedLines = 0
|
|
2851
|
+
let changedFiles = 0
|
|
2852
|
+
let binaryFiles = 0
|
|
2853
|
+
const paths = []
|
|
2854
|
+
const tokens = out.split('\0')
|
|
2855
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
2856
|
+
if (!tokens[i]) continue
|
|
2857
|
+
const [added, deleted, path] = tokens[i].split('\t')
|
|
2858
|
+
if (path) {
|
|
2859
|
+
paths.push(path)
|
|
2860
|
+
} else {
|
|
2861
|
+
paths.push(...tokens.slice(i + 1, i + 3).filter(Boolean))
|
|
2862
|
+
i += 2
|
|
2863
|
+
}
|
|
2864
|
+
changedFiles += 1
|
|
2865
|
+
if (added === '-' || deleted === '-') {
|
|
2866
|
+
binaryFiles += 1
|
|
2867
|
+
continue
|
|
2868
|
+
}
|
|
2869
|
+
changedLines += (Number(added) || 0) + (Number(deleted) || 0)
|
|
2870
|
+
}
|
|
2871
|
+
return { base, changedLines, changedFiles, binaryFiles, paths }
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* The paths `<base>...HEAD` adds, edits and deletes, for the spec-coverage
|
|
2876
|
+
* facts. Renames are not detected (`--no-renames`), so a rename is a delete of
|
|
2877
|
+
* the old path and an add of the new one — which is how the signal reads it. A
|
|
2878
|
+
* type change is an edit. Throws when git cannot diff the base against HEAD.
|
|
2879
|
+
*/
|
|
2880
|
+
function nameStatus(root, base) {
|
|
2881
|
+
let out
|
|
2882
|
+
try {
|
|
2883
|
+
out = gitText(root, ['diff', '--name-status', '--no-renames', '-z', `${base}...HEAD`])
|
|
2884
|
+
} catch (err) {
|
|
2885
|
+
throw new Error(`git could not list the files ${base}...HEAD changes: ${err.message}`)
|
|
2886
|
+
}
|
|
2887
|
+
const result = { added: [], modified: [], deleted: [] }
|
|
2888
|
+
// -z: `status\0path\0`, one pair per file.
|
|
2889
|
+
const tokens = out.split('\0')
|
|
2890
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
2891
|
+
const status = tokens[i]
|
|
2892
|
+
const path = tokens[i + 1]
|
|
2893
|
+
if (!status || !path) continue
|
|
2894
|
+
if (status.startsWith('A')) result.added.push(path)
|
|
2895
|
+
else if (status.startsWith('D')) result.deleted.push(path)
|
|
2896
|
+
else result.modified.push(path)
|
|
2897
|
+
}
|
|
2898
|
+
return result
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/** Every path tracked at `ref`. Throws when `ref` names no tree git can list. */
|
|
2902
|
+
function listFiles(root, ref) {
|
|
2903
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-')) {
|
|
2904
|
+
throw new Error(`cannot list the files at ${JSON.stringify(ref)}: it names no commit`)
|
|
2905
|
+
}
|
|
2906
|
+
let out
|
|
2907
|
+
try {
|
|
2908
|
+
out = gitText(root, ['ls-tree', '-r', '--name-only', '-z', ref])
|
|
2909
|
+
} catch (err) {
|
|
2910
|
+
throw new Error(`git could not list the files tracked at ${ref}: ${err.message}`)
|
|
2911
|
+
}
|
|
2912
|
+
return out.split('\0').filter(Boolean)
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/** A repeatable name flag as a list: absent is none, one use is a list of one. A use with no value ends the command. */
|
|
2916
|
+
function nameList(flags, key, fail) {
|
|
2917
|
+
const value = flags[key]
|
|
2918
|
+
if (value === undefined) return []
|
|
2919
|
+
const given = [].concat(value)
|
|
2920
|
+
if (given.some(v => typeof v !== 'string' || !v)) fail(`--${key} needs a check name`)
|
|
2921
|
+
return given
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
/**
|
|
2925
|
+
* `interlock pipeline`: a platform's own check document in, the pipeline input
|
|
2926
|
+
* `merge-confidence --pipeline` reads out (design D2 of
|
|
2927
|
+
* label-and-harden-the-merge-verdict). Offline by construction: it reads the
|
|
2928
|
+
* document it is handed, from a file or standard input, and never gh, glab or
|
|
2929
|
+
* the network. `lib/pipeline.mjs` decides everything; this owns the files.
|
|
2930
|
+
*
|
|
2931
|
+
* Every way this command produces no input goes through `refuse`, which removes
|
|
2932
|
+
* an earlier file at `--out` first and says so. A fixed output path that kept
|
|
2933
|
+
* its last good file would hand the next command an earlier moment's checks,
|
|
2934
|
+
* with a commit that may still match.
|
|
2935
|
+
*/
|
|
2936
|
+
function pipelineCommand(flags, json) {
|
|
2937
|
+
const outPath = typeof flags.out === 'string' && flags.out ? flags.out : null
|
|
2938
|
+
const refuse = message => {
|
|
2939
|
+
if (outPath && existsSync(outPath)) {
|
|
2940
|
+
try {
|
|
2941
|
+
rmSync(outPath, { force: true })
|
|
2942
|
+
warn(`removed ${outPath}: no pipeline input was produced, so the earlier file there is not left for the next command to read`)
|
|
2943
|
+
} catch (err) {
|
|
2944
|
+
warn(`${outPath} could not be removed (${err.message}): it still holds an earlier file, which describes an earlier moment`)
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
die(message)
|
|
2948
|
+
}
|
|
2949
|
+
if (flags.out === true) refuse('--out needs a file')
|
|
2950
|
+
|
|
2951
|
+
const platforms = ['github', 'gitlab-mr', 'gitlab'].filter(key => flags[key] !== undefined)
|
|
2952
|
+
if (platforms.length !== 1) {
|
|
2953
|
+
refuse(
|
|
2954
|
+
`pipeline needs exactly one of --github, --gitlab-mr or --gitlab, each with the platform's document ` +
|
|
2955
|
+
`(a file, or "-" for stdin)${platforms.length ? `; got ${platforms.map(p => `--${p}`).join(' and ')}` : ''}`
|
|
2956
|
+
)
|
|
2957
|
+
}
|
|
2958
|
+
const platform = platforms[0]
|
|
2959
|
+
const source = flags[platform]
|
|
2960
|
+
if (source === true) refuse(`--${platform} needs the platform's document: a file, or "-" for stdin`)
|
|
2961
|
+
|
|
2962
|
+
const required = nameList(flags, 'required', refuse)
|
|
2963
|
+
const exclude = nameList(flags, 'exclude', refuse)
|
|
2964
|
+
if (platform !== 'github' && required.length) {
|
|
2965
|
+
refuse(`--required is read with --github only: on GitLab a job is required when its allow_failure is false`)
|
|
2966
|
+
}
|
|
2967
|
+
if (platform === 'gitlab-mr' && exclude.length) {
|
|
2968
|
+
refuse('--exclude is read with --github and --gitlab: the merge request document carries no checks to leave out')
|
|
2969
|
+
}
|
|
2970
|
+
if (platform === 'gitlab-mr' && !outPath) {
|
|
2971
|
+
refuse('--gitlab-mr needs --out <file>: the head commit travels to "interlock pipeline --gitlab --mr <file>" in that file')
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
// The record of the first GitLab step, read before anything at --out is
|
|
2975
|
+
// touched: --mr and --out may name the same path.
|
|
2976
|
+
let record = null
|
|
2977
|
+
if (platform === 'gitlab') {
|
|
2978
|
+
if (typeof flags.mr !== 'string' || !flags.mr || flags.mr === '-') {
|
|
2979
|
+
refuse('--gitlab needs --mr <file>: the file "interlock pipeline --gitlab-mr --out <file>" wrote, which carries the head commit')
|
|
2980
|
+
}
|
|
2981
|
+
try {
|
|
2982
|
+
record = JSON.parse(readFileSync(flags.mr, 'utf8'))
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
refuse(`--mr ${flags.mr} could not be read as JSON: ${err.message}`)
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
let raw
|
|
2989
|
+
try {
|
|
2990
|
+
raw = source === '-' ? readStdin() : readFileSync(source, 'utf8')
|
|
2991
|
+
} catch (err) {
|
|
2992
|
+
refuse(`--${platform} ${source} could not be read: ${err.message}`)
|
|
2993
|
+
}
|
|
2994
|
+
let doc
|
|
2995
|
+
try {
|
|
2996
|
+
doc = JSON.parse(raw)
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
refuse(`--${platform} ${source === '-' ? '(stdin)' : source} is not valid JSON: ${err.message}`)
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
const read =
|
|
3002
|
+
platform === 'github'
|
|
3003
|
+
? pipelineFromGithub(doc, { required, exclude })
|
|
3004
|
+
: platform === 'gitlab-mr'
|
|
3005
|
+
? pendingFromGitlabMr(doc)
|
|
3006
|
+
: pipelineFromGitlab(doc, record, { exclude })
|
|
3007
|
+
if (!read.ok) refuse(`--${platform} ${source === '-' ? '(stdin)' : source}: ${read.reason}`)
|
|
3008
|
+
for (const note of read.notes || []) warn(note)
|
|
3009
|
+
|
|
3010
|
+
const produced = read.pending || read.input
|
|
3011
|
+
if (outPath) {
|
|
3012
|
+
try {
|
|
3013
|
+
writeJsonAtomic(outPath, produced)
|
|
3014
|
+
} catch (err) {
|
|
3015
|
+
refuse(`--out ${outPath} could not be written: ${err.message}`)
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
if (read.pending) {
|
|
3020
|
+
emit(json, read.pending, `pending ${read.pending.sha}: head pipeline ${read.pending.headPipeline}\n`)
|
|
3021
|
+
return
|
|
3022
|
+
}
|
|
3023
|
+
const { input } = read
|
|
3024
|
+
const count = state => input.checks.filter(c => c.state === state).length
|
|
3025
|
+
const requiredNames = input.checks.filter(c => c.required).map(c => c.name)
|
|
3026
|
+
// GitHub's required set is the caller's to give; GitLab states it per job.
|
|
3027
|
+
const requiredText = platform === 'github' && !required.length ? 'not given' : requiredNames.join(', ') || 'none'
|
|
3028
|
+
emit(
|
|
3029
|
+
json,
|
|
3030
|
+
input,
|
|
3031
|
+
`pipeline ${input.sha}: ${input.checks.length} check(s) (${count('pass')} pass, ${count('fail')} fail, ` +
|
|
3032
|
+
`${count('pending')} pending, ${count('skipped')} skipped); required: ${requiredText}; ` +
|
|
3033
|
+
`excluded: ${input.excluded.join(', ') || 'none'}\n`
|
|
3034
|
+
)
|
|
3035
|
+
}
|
|
3036
|
+
|
|
1847
3037
|
function splitPaths(value) {
|
|
1848
3038
|
if (value === undefined || value === true) return []
|
|
1849
3039
|
const raw = value === '-' ? readStdin().split('\n') : String(value).split(',')
|
|
@@ -1936,7 +3126,7 @@ async function main() {
|
|
|
1936
3126
|
}
|
|
1937
3127
|
|
|
1938
3128
|
case 'gate': {
|
|
1939
|
-
const input =
|
|
3129
|
+
const input = readFindingsInput(flags.findings)
|
|
1940
3130
|
const dismissed =
|
|
1941
3131
|
typeof flags.dismissed === 'string'
|
|
1942
3132
|
? flags.dismissed.split(',').map(s => s.trim()).filter(Boolean)
|
|
@@ -2066,14 +3256,185 @@ async function main() {
|
|
|
2066
3256
|
|
|
2067
3257
|
case 'conformance': {
|
|
2068
3258
|
// Emits questions, never verdicts, and never exits non-zero — see the
|
|
2069
|
-
// header of lib/conformance.mjs.
|
|
3259
|
+
// header of lib/conformance.mjs. That holds for `--living` and a `--base`
|
|
3260
|
+
// nobody can read too: the reason is printed in the list's place.
|
|
2070
3261
|
const name = typeof flags.change === 'string' ? flags.change : positional[1]
|
|
2071
3262
|
const changed = flags.changed === undefined ? [] : splitPaths(flags.changed)
|
|
2072
3263
|
const result = buildChecklist(root, name, { changed })
|
|
3264
|
+
// `--living` adds the requirements already in force, after the scenarios
|
|
3265
|
+
// (design D5 of complete-and-record-the-merge-verdict). Without it the
|
|
3266
|
+
// checklist is field for field what it was, whatever else is given.
|
|
3267
|
+
if (flags.living === true) result.living = livingListFor(root, flags, { change: result.change, changed })
|
|
2073
3268
|
emit(json, result, formatChecklist(result) + '\n')
|
|
2074
3269
|
break
|
|
2075
3270
|
}
|
|
2076
3271
|
|
|
3272
|
+
case 'pipeline': {
|
|
3273
|
+
pipelineCommand(flags, json)
|
|
3274
|
+
break
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
case 'merge-confidence': {
|
|
3278
|
+
// Offline by construction: the repository, the files the caller names and
|
|
3279
|
+
// the published limits. Never gh, glab or the network (design D4).
|
|
3280
|
+
//
|
|
3281
|
+
// Two output forms and one stdout: asking for both is a usage error.
|
|
3282
|
+
const markdown = flags.markdown === true
|
|
3283
|
+
if (markdown && json) die('--markdown and --json cannot be given together: one prints the description block, the other the assessment')
|
|
3284
|
+
// How to recognise the labels, and nothing else. It is asked for when an
|
|
3285
|
+
// assessment stopped: a merge request may still carry the labels of an
|
|
3286
|
+
// earlier one, and there is no verdict to read their names from. So it
|
|
3287
|
+
// reads no file and nothing of the repository, and answers where every
|
|
3288
|
+
// other form of this command would end on the input that stopped it.
|
|
3289
|
+
if (flags['label-names'] === true) {
|
|
3290
|
+
if (markdown) die('--label-names and --markdown cannot be given together: one prints the label names, the other the description block')
|
|
3291
|
+
const labelNames = mergeLabelNames()
|
|
3292
|
+
emit(
|
|
3293
|
+
json,
|
|
3294
|
+
{ labels: labelNames },
|
|
3295
|
+
`verdict labels: ${labelNames.verdictNames.join(', ')}\nscore label prefix: ${labelNames.scorePrefix}\n`
|
|
3296
|
+
)
|
|
3297
|
+
break
|
|
3298
|
+
}
|
|
3299
|
+
// Every caller file is read FIRST, so an unreadable one ends the command
|
|
3300
|
+
// before any assessment and nothing reaches stdout (design D9).
|
|
3301
|
+
const review = readMergeInput(flags, 'review')
|
|
3302
|
+
const verify = readMergeInput(flags, 'verify')
|
|
3303
|
+
const handoffFile = readMergeInput(flags, 'handoff')
|
|
3304
|
+
const pipeline = readMergeInput(flags, 'pipeline')
|
|
3305
|
+
// A pipeline file that is not well-formed is an input error, as one that is
|
|
3306
|
+
// not JSON is (design D7 of label-and-harden-the-merge-verdict). An absent
|
|
3307
|
+
// or misspelt check list read as "no checks" would hide a failed required
|
|
3308
|
+
// check behind a green signal.
|
|
3309
|
+
if (pipeline) {
|
|
3310
|
+
const shape = validatePipelineInput(pipeline)
|
|
3311
|
+
if (!shape.ok) {
|
|
3312
|
+
die(`--pipeline ${flags.pipeline} is not a pipeline input: ${shape.reason} (produce it with "interlock pipeline")`)
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
const coverage = readMergeInput(flags, 'coverage')
|
|
3316
|
+
const runFile = readMergeInput(flags, 'run')
|
|
3317
|
+
if (flags.base === true) die('--base needs a ref')
|
|
3318
|
+
const base = typeof flags.base === 'string' ? flags.base : null
|
|
3319
|
+
// A ref is handed to git as an argument; one that reads as an option is
|
|
3320
|
+
// refused rather than passed through. A ref that names no commit is input
|
|
3321
|
+
// the command could not read, so it ends here, before any assessment. A
|
|
3322
|
+
// base that resolves and still cannot be diffed (a shallow clone with no
|
|
3323
|
+
// merge base) is the gather's to note: those signals read unobserved.
|
|
3324
|
+
if (base && base.startsWith('-')) die(`--base ${base}: a base must name a ref, not an option`)
|
|
3325
|
+
if (base && !commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit, so git cannot diff it against HEAD`)
|
|
3326
|
+
let changedPaths = []
|
|
3327
|
+
if (flags.changed !== undefined) {
|
|
3328
|
+
const given = [].concat(flags.changed)
|
|
3329
|
+
changedPaths =
|
|
3330
|
+
given.length === 1 && given[0] === '-'
|
|
3331
|
+
? readStdin().split('\n').map(p => p.trim()).filter(Boolean)
|
|
3332
|
+
: splitPaths(given.join(','))
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
const headSha = headShaOf(root)
|
|
3336
|
+
const requested = typeof flags.change === 'string' ? flags.change : positional[1]
|
|
3337
|
+
const resolved = resolveChange(root, requested)
|
|
3338
|
+
const name = resolved.error ? null : resolved.name
|
|
3339
|
+
|
|
3340
|
+
// The diff under assessment runs from the merge base of --base and the
|
|
3341
|
+
// head. The output names both, and a run's evidence is judged against it.
|
|
3342
|
+
// A base that resolves and has no merge base to read (a shallow clone) is
|
|
3343
|
+
// named with no commit.
|
|
3344
|
+
let assessedBase = null
|
|
3345
|
+
if (base) {
|
|
3346
|
+
let mergeBase = null
|
|
3347
|
+
try {
|
|
3348
|
+
mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim() || null
|
|
3349
|
+
} catch {
|
|
3350
|
+
mergeBase = null
|
|
3351
|
+
}
|
|
3352
|
+
assessedBase = { ref: base, sha: mergeBase }
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// A run's evidence file (design D9 of complete-and-record-the-merge-verdict)
|
|
3356
|
+
// supplies what only that run observed, and only for the commit it made:
|
|
3357
|
+
// a file for another commit, another change or a run that made no commit
|
|
3358
|
+
// is refused whole, out loud, and the signals read as with no file. With a
|
|
3359
|
+
// base it is also refused unless that is the diff the run reviewed (design
|
|
3360
|
+
// D8 of label-and-harden-the-merge-verdict); no base is ever taken from
|
|
3361
|
+
// the file. The assessment recorded in it is never read: the command
|
|
3362
|
+
// always reassesses.
|
|
3363
|
+
let fromRun = {}
|
|
3364
|
+
if (runFile) {
|
|
3365
|
+
const read = readRunEvidence(runFile, {
|
|
3366
|
+
change: name,
|
|
3367
|
+
headSha,
|
|
3368
|
+
base: assessedBase ? (assessedBase.sha ? { sha: assessedBase.sha } : { unreadable: true }) : undefined
|
|
3369
|
+
})
|
|
3370
|
+
if (read.usable) fromRun = read.evidence
|
|
3371
|
+
else warn(`--run ${flags.run} is not used: ${read.reason}`)
|
|
3372
|
+
}
|
|
3373
|
+
// The conformance denominators are the command's own counts. A count the
|
|
3374
|
+
// handoff carries is dropped here (and ignored by the module), so a
|
|
3375
|
+
// caller cannot shrink one.
|
|
3376
|
+
const handoffGiven = handoffFile ?? fromRun.handoff ?? null
|
|
3377
|
+
let handoff = null
|
|
3378
|
+
if (handoffGiven && typeof handoffGiven === 'object' && !Array.isArray(handoffGiven)) {
|
|
3379
|
+
const { verdictsRequired: _ignored, ...counts } = handoffGiven
|
|
3380
|
+
handoff = counts
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// One gather, shared with a ship run's close (design D1): the repository's
|
|
3384
|
+
// own facts, read here, and git through the four functions handed in. An
|
|
3385
|
+
// explicit input replaces the evidence file's entry for it.
|
|
3386
|
+
const graph = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
|
|
3387
|
+
const { input, notes } = gatherMergeEvidence(root, {
|
|
3388
|
+
change: requested,
|
|
3389
|
+
changedPaths,
|
|
3390
|
+
base,
|
|
3391
|
+
headSha,
|
|
3392
|
+
supplied: {
|
|
3393
|
+
review: review ?? fromRun.review ?? null,
|
|
3394
|
+
verify: verify ?? fromRun.verify ?? null,
|
|
3395
|
+
handoff,
|
|
3396
|
+
pipeline,
|
|
3397
|
+
coverage: coverage ?? fromRun.coverage ?? null,
|
|
3398
|
+
// A run record exists only where a run wrote one; by hand there is none.
|
|
3399
|
+
run: fromRun.run ?? null
|
|
3400
|
+
},
|
|
3401
|
+
deps: { diffShape, nameStatus, listFiles, readAtRef },
|
|
3402
|
+
...(graph.path ? { graphPath: graph.path } : {})
|
|
3403
|
+
})
|
|
3404
|
+
|
|
3405
|
+
// With a base, git's own file list is a changed-path source: a path the
|
|
3406
|
+
// caller left out of --changed still reaches risk, conformance and drift.
|
|
3407
|
+
const named = new Set(changedPaths)
|
|
3408
|
+
const added = input.changedPaths.filter(p => !named.has(p))
|
|
3409
|
+
if (added.length) {
|
|
3410
|
+
warn(`${added.length} path(s) git reports in ${base}...HEAD were not in --changed and are assessed too: ${added.join(', ')}`)
|
|
3411
|
+
}
|
|
3412
|
+
// What the gather could not read, with the reason. The signal that needed
|
|
3413
|
+
// it reads unobserved; this is where a reader learns why.
|
|
3414
|
+
for (const note of notes) warn(`${note.what}: ${note.reason}`)
|
|
3415
|
+
|
|
3416
|
+
const result = assessMergeConfidence(input)
|
|
3417
|
+
// The commit and the base the verdict is about: a label is a claim about
|
|
3418
|
+
// one commit, so the output says which (design D14).
|
|
3419
|
+
const assessedHead = headSha ? { sha: headSha } : null
|
|
3420
|
+
if (markdown) {
|
|
3421
|
+
// The block is rendered by the module from what the command holds, so
|
|
3422
|
+
// the description shows what was computed and nothing a model retyped.
|
|
3423
|
+
const excluded = input.pipeline && Array.isArray(input.pipeline.excluded) ? input.pipeline.excluded : []
|
|
3424
|
+
process.stdout.write(formatMergeConfidenceMarkdown(result, { head: assessedHead, base: assessedBase, excluded }))
|
|
3425
|
+
} else {
|
|
3426
|
+
emit(
|
|
3427
|
+
json,
|
|
3428
|
+
{ change: input.change ? input.change.name : null, ...result, head: assessedHead, base: assessedBase },
|
|
3429
|
+
formatMergeConfidence(result)
|
|
3430
|
+
)
|
|
3431
|
+
}
|
|
3432
|
+
// HUMAN exits 0: a change that needs a reviewer is not a failed command.
|
|
3433
|
+
const failed = flags['require-merge'] === true ? result.verdict !== 'MERGE' : result.verdict === 'BLOCK'
|
|
3434
|
+
if (failed) process.exit(1)
|
|
3435
|
+
break
|
|
3436
|
+
}
|
|
3437
|
+
|
|
2077
3438
|
case 'limits': {
|
|
2078
3439
|
// `laneCaps` and `solo` are their OWN top-level groups, not entries of
|
|
2079
3440
|
// `limits`: LIMITS holds positive-integer loop counts (its invariant test
|
|
@@ -2097,7 +3458,10 @@ async function main() {
|
|
|
2097
3458
|
report: REPORT_CAPS,
|
|
2098
3459
|
effort: EFFORT,
|
|
2099
3460
|
laneCaps: LANE_CAPS,
|
|
2100
|
-
solo: SOLO
|
|
3461
|
+
solo: SOLO,
|
|
3462
|
+
// The merge verdict's thresholds: their own group, a ceiling table
|
|
3463
|
+
// and seven scalars, never entries of the iteration-count limits.
|
|
3464
|
+
mergeBands: MERGE_BANDS
|
|
2101
3465
|
},
|
|
2102
3466
|
formatLimits({ runtimeObserved })
|
|
2103
3467
|
)
|
|
@@ -2263,7 +3627,7 @@ async function main() {
|
|
|
2263
3627
|
}
|
|
2264
3628
|
|
|
2265
3629
|
case 'remediate': {
|
|
2266
|
-
const input =
|
|
3630
|
+
const input = readFindingsInput(flags.findings)
|
|
2267
3631
|
const round = num(flags, 'round')
|
|
2268
3632
|
const plan = planRemediation(input, { round, band: parseBand(flags) })
|
|
2269
3633
|
emit(json, plan, formatRemediationPlan(plan))
|
|
@@ -2272,7 +3636,7 @@ async function main() {
|
|
|
2272
3636
|
}
|
|
2273
3637
|
|
|
2274
3638
|
case 'review': {
|
|
2275
|
-
const findings =
|
|
3639
|
+
const findings = readFindingsInput(flags.findings)
|
|
2276
3640
|
const verdicts = readInput(flags.verdicts, '--verdicts')
|
|
2277
3641
|
// The reviewed diff. A dismissing verdict has to cite a path that is in
|
|
2278
3642
|
// it — shape alone is satisfied by inventing `lib/nowhere.ts:1`. Optional
|
|
@@ -2284,12 +3648,20 @@ async function main() {
|
|
|
2284
3648
|
// here as a survival filter — never handed to a model as a request. Its
|
|
2285
3649
|
// problems (a malformed file, a band-like key) are reported alongside the
|
|
2286
3650
|
// review, so a broken policy is diagnosable rather than silent.
|
|
2287
|
-
|
|
3651
|
+
//
|
|
3652
|
+
// With --base the two enforced lists are read from the commit at that ref,
|
|
3653
|
+
// so a change cannot exclude its own paths by editing the copy applied to
|
|
3654
|
+
// it; the prose still comes from the working tree (design D3).
|
|
3655
|
+
const base = baseRefOf(root, flags)
|
|
3656
|
+
const { policy, differsFromBase } = reviewPolicyAt(root, base)
|
|
2288
3657
|
|
|
2289
3658
|
const result = resolveReview(findings, verdicts, {
|
|
2290
3659
|
band: parseBand(flags),
|
|
2291
3660
|
changedPaths,
|
|
2292
|
-
excludePaths: policy.excludePaths
|
|
3661
|
+
excludePaths: policy.excludePaths,
|
|
3662
|
+
// A requirement a finding cites is checked against the spec as the base
|
|
3663
|
+
// has it, so a citation cannot lean on a requirement the change added.
|
|
3664
|
+
requirementHeadings: requirementHeadingsAt(root, base, findings)
|
|
2293
3665
|
})
|
|
2294
3666
|
|
|
2295
3667
|
// Metrics are bookkeeping: they report their own failure and never touch
|
|
@@ -2305,11 +3677,22 @@ async function main() {
|
|
|
2305
3677
|
? ` policy: ${policy.problems.length} problem(s) in REVIEW.md\n` +
|
|
2306
3678
|
policy.problems.map(p => ` - ${p}`).join('\n') + '\n'
|
|
2307
3679
|
: ''
|
|
3680
|
+
const policyBase = base
|
|
3681
|
+
? { ref: base, differsFromBase }
|
|
3682
|
+
: null
|
|
3683
|
+
const policyDiffers = differsFromBase
|
|
3684
|
+
? ` policy: ${REVIEW_POLICY_FILE} differs from ${base}; its Do Not Report paths were read from ${base}\n`
|
|
3685
|
+
: ''
|
|
2308
3686
|
const text =
|
|
2309
3687
|
formatReview(result) +
|
|
3688
|
+
policyDiffers +
|
|
2310
3689
|
policyProblems +
|
|
2311
3690
|
(metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
|
|
2312
|
-
emit(
|
|
3691
|
+
emit(
|
|
3692
|
+
json,
|
|
3693
|
+
{ ...result, policyProblems: policy.problems, ...(policyBase ? { policyBase } : {}), ...(metrics ? { metrics } : {}) },
|
|
3694
|
+
text
|
|
3695
|
+
)
|
|
2313
3696
|
break
|
|
2314
3697
|
}
|
|
2315
3698
|
|
|
@@ -2317,9 +3700,174 @@ async function main() {
|
|
|
2317
3700
|
// Read-only view of the parsed REVIEW.md, so the workflow can inject the
|
|
2318
3701
|
// prose + owner into the reviewer instructions without re-parsing the file
|
|
2319
3702
|
// (the runtime cannot import lib modules). Always exits 0: a missing or
|
|
2320
|
-
// malformed policy is reported, never fatal.
|
|
2321
|
-
|
|
2322
|
-
|
|
3703
|
+
// malformed policy is reported, never fatal. A --base that names no commit
|
|
3704
|
+
// is the one exception: a base nobody can read is not a policy.
|
|
3705
|
+
const base = baseRefOf(root, flags)
|
|
3706
|
+
const { policy, differsFromBase } = reviewPolicyAt(root, base)
|
|
3707
|
+
const tail = base
|
|
3708
|
+
? ` lists read from ${base}${differsFromBase ? `; ${REVIEW_POLICY_FILE} differs from ${base}` : ''}\n`
|
|
3709
|
+
: ''
|
|
3710
|
+
emit(json, policy, formatReviewPolicy(policy) + tail)
|
|
3711
|
+
break
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
case 'domain-context': {
|
|
3715
|
+
// The same selection and assembly a ship run's review step makes, for a
|
|
3716
|
+
// hand-run review (design D4). Selected from the graph and from the
|
|
3717
|
+
// capabilities the change's delta specs declare; read from the base when
|
|
3718
|
+
// one is named, the working tree otherwise, and the output says which.
|
|
3719
|
+
// Exit 0 on a completed report — a skip is spoken, not a failure — and
|
|
3720
|
+
// exit 1 only on a base that names no commit.
|
|
3721
|
+
const base = baseRefOf(root, flags)
|
|
3722
|
+
const changed =
|
|
3723
|
+
flags.changed === undefined
|
|
3724
|
+
? []
|
|
3725
|
+
: (() => {
|
|
3726
|
+
const given = [].concat(flags.changed)
|
|
3727
|
+
return given.length === 1 && given[0] === '-'
|
|
3728
|
+
? readStdin().split('\n').map(p => p.trim()).filter(Boolean)
|
|
3729
|
+
: splitPaths(given.join(','))
|
|
3730
|
+
})()
|
|
3731
|
+
|
|
3732
|
+
const requested = typeof flags.change === 'string' ? flags.change : positional[1]
|
|
3733
|
+
const resolved = resolveChange(root, requested)
|
|
3734
|
+
const name = resolved.error ? null : resolved.name
|
|
3735
|
+
if (!name) warn(`no change under assessment: ${resolved.error} — only the graph selects a capability`)
|
|
3736
|
+
|
|
3737
|
+
const graph = readGraphFor(root, flags)
|
|
3738
|
+
const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
|
|
3739
|
+
|
|
3740
|
+
const living = {}
|
|
3741
|
+
const declared = []
|
|
3742
|
+
let specFiles = []
|
|
3743
|
+
if (name) {
|
|
3744
|
+
try {
|
|
3745
|
+
specFiles = inspectChange(root, name).specFiles
|
|
3746
|
+
} catch {
|
|
3747
|
+
// A change that cannot be inspected declares nothing.
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
for (const rel of specFiles) {
|
|
3751
|
+
const capability = capabilityOf(rel)
|
|
3752
|
+
if (!capability || declared.includes(capability)) continue
|
|
3753
|
+
const read = readSpecText(root, base, `openspec/specs/${capability}/spec.md`)
|
|
3754
|
+
if (typeof read.text === 'string') {
|
|
3755
|
+
living[capability] = read.text
|
|
3756
|
+
declared.push(capability)
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
const selection = selectDomainCapabilities({ changedPaths: changed, edges, graphReason: graph.reason, declared })
|
|
3761
|
+
let assembled = null
|
|
3762
|
+
let skipped = selection.skipped
|
|
3763
|
+
if (selection.capabilities.length) {
|
|
3764
|
+
const deltas = {}
|
|
3765
|
+
const readErrors = []
|
|
3766
|
+
for (const { capability } of selection.capabilities) {
|
|
3767
|
+
if (!(capability in living)) {
|
|
3768
|
+
const read = readSpecText(root, base, `openspec/specs/${capability}/spec.md`)
|
|
3769
|
+
if (typeof read.text === 'string') living[capability] = read.text
|
|
3770
|
+
else if (read.error) readErrors.push(`openspec/specs/${capability}/spec.md: ${read.error}`)
|
|
3771
|
+
}
|
|
3772
|
+
if (name) {
|
|
3773
|
+
const delta = readSpecText(root, null, `openspec/changes/${name}/specs/${capability}/spec.md`)
|
|
3774
|
+
if (typeof delta.text === 'string') deltas[capability] = delta.text
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
assembled = assembleDomainContext({ capabilities: selection.capabilities, living, deltas, ref: base })
|
|
3778
|
+
if (assembled.skipped) {
|
|
3779
|
+
skipped = readErrors.length ? `${assembled.skipped} (${readErrors.join('; ')})` : assembled.skipped
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
// `selected` says capabilities were chosen; `skipped` says whether any
|
|
3783
|
+
// requirement text came of it. Both can hold: a capability the change
|
|
3784
|
+
// rewrites entirely is selected and has nothing in force to judge against.
|
|
3785
|
+
const selected = selection.capabilities.length > 0
|
|
3786
|
+
const hasText = Boolean(assembled) && !assembled.skipped
|
|
3787
|
+
const source = assembled ? assembled.source : base ? `base ${base}` : 'the working tree'
|
|
3788
|
+
const out = {
|
|
3789
|
+
change: name,
|
|
3790
|
+
selected,
|
|
3791
|
+
ref: base,
|
|
3792
|
+
source,
|
|
3793
|
+
capabilities: selection.capabilities,
|
|
3794
|
+
skipped: skipped || null,
|
|
3795
|
+
partial: selection.partial || null,
|
|
3796
|
+
notSelected: selection.notSelected || null,
|
|
3797
|
+
text: hasText ? assembled.text : '',
|
|
3798
|
+
superseded: assembled ? assembled.superseded : [],
|
|
3799
|
+
omitted: assembled ? assembled.omitted : [],
|
|
3800
|
+
specifiedByChange: assembled ? assembled.specifiedByChange : []
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
const lines = []
|
|
3804
|
+
if (out.skipped) lines.push(`DOMAIN REVIEW SKIPPED: ${out.skipped} (base: ${source})`)
|
|
3805
|
+
if (selected) {
|
|
3806
|
+
lines.push(`DOMAIN CONTEXT — ${out.capabilities.length} capabilit${out.capabilities.length === 1 ? 'y' : 'ies'}, read from ${source}`)
|
|
3807
|
+
for (const c of out.capabilities) {
|
|
3808
|
+
lines.push(` ${c.capability} (${c.via}${c.files.length ? `: ${c.files.join(', ')}` : ''})`)
|
|
3809
|
+
}
|
|
3810
|
+
if (out.partial && hasText) lines.push(`DOMAIN REVIEW PARTIAL: ${out.partial}`)
|
|
3811
|
+
if (out.omitted.length) lines.push(` ${out.omitted.length} requirement(s) left out to fit the size cap`)
|
|
3812
|
+
if (hasText) lines.push('', out.text)
|
|
3813
|
+
} else if (!out.skipped && out.notSelected) {
|
|
3814
|
+
lines.push(`DOMAIN NOT SELECTED: ${out.notSelected.reason} (base: ${source})`)
|
|
3815
|
+
for (const path of out.notSelected.unlinkedPaths) lines.push(` unlinked: ${path}`)
|
|
3816
|
+
} else if (!out.skipped) {
|
|
3817
|
+
lines.push(`DOMAIN NOT SELECTED: no changed path was given (base: ${source})`)
|
|
3818
|
+
}
|
|
3819
|
+
emit(json, out, lines.join('\n') + '\n')
|
|
3820
|
+
break
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
case 'spec-audit': {
|
|
3824
|
+
// Counts over the living specs and, with a graph, over how they link to
|
|
3825
|
+
// source. Judges nothing and blocks nothing: it never exits non-zero, so a
|
|
3826
|
+
// measure the repository cannot yet take is reported absent with its
|
|
3827
|
+
// reason (design D5).
|
|
3828
|
+
const specsDir = join(root, 'openspec', 'specs')
|
|
3829
|
+
const specPaths = []
|
|
3830
|
+
const walk = dir => {
|
|
3831
|
+
let entries
|
|
3832
|
+
try {
|
|
3833
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
3834
|
+
} catch {
|
|
3835
|
+
return
|
|
3836
|
+
}
|
|
3837
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3838
|
+
const full = join(dir, entry.name)
|
|
3839
|
+
if (entry.isDirectory()) walk(full)
|
|
3840
|
+
else if (entry.name === 'spec.md') specPaths.push(relative(root, full).split(sep).join('/'))
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
walk(specsDir)
|
|
3844
|
+
const specs = specPaths.map(path => {
|
|
3845
|
+
try {
|
|
3846
|
+
return { path, text: readFileSync(join(root, path), 'utf8') }
|
|
3847
|
+
} catch (err) {
|
|
3848
|
+
return { path, error: (err && err.message) || String(err) }
|
|
3849
|
+
}
|
|
3850
|
+
})
|
|
3851
|
+
|
|
3852
|
+
const graph = readGraphFor(root, flags)
|
|
3853
|
+
const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
|
|
3854
|
+
let broken = []
|
|
3855
|
+
if (edges) {
|
|
3856
|
+
const stale = detectStaleSpecs(root, { lastCommit: () => null })
|
|
3857
|
+
broken = stale.skipped
|
|
3858
|
+
? edges
|
|
3859
|
+
.map(e => ({ spec: e.source_file, file: e.target.slice('file:'.length) }))
|
|
3860
|
+
.filter(b => !existsSync(join(root, b.file)))
|
|
3861
|
+
: stale.broken
|
|
3862
|
+
}
|
|
3863
|
+
const graphFiles = Array.isArray(graph.nodes)
|
|
3864
|
+
? graph.nodes
|
|
3865
|
+
.filter(n => n && n.type === 'file' && typeof n.source_file === 'string')
|
|
3866
|
+
.map(n => n.source_file)
|
|
3867
|
+
.filter(isCoverableSource)
|
|
3868
|
+
: []
|
|
3869
|
+
const report = auditSpecs({ specs, edges, graphReason: graph.reason, broken, graphFiles })
|
|
3870
|
+
emit(json, report, formatSpecAudit(report) + '\n')
|
|
2323
3871
|
break
|
|
2324
3872
|
}
|
|
2325
3873
|
|
|
@@ -2692,6 +4240,75 @@ async function main() {
|
|
|
2692
4240
|
process.stderr.write(`interlock: verify timing not recorded: ${(err && err.message) || err}\n`)
|
|
2693
4241
|
}
|
|
2694
4242
|
process.exit(exitCode)
|
|
4243
|
+
} else if (sub === 'baseline') {
|
|
4244
|
+
// Record the project total of a coverage report taken on this commit, so a
|
|
4245
|
+
// later `verify coverage` has a base figure. Refuses, leaving any previous
|
|
4246
|
+
// baseline in place, when the report yields no total (design D2).
|
|
4247
|
+
const found = readTestProfile(root, flags)
|
|
4248
|
+
const profile = found ? found.profile : null
|
|
4249
|
+
let reportPath
|
|
4250
|
+
if (flags.report !== undefined) {
|
|
4251
|
+
if (flags.report === true) die('--report needs an lcov file')
|
|
4252
|
+
reportPath = String(flags.report)
|
|
4253
|
+
} else {
|
|
4254
|
+
const rel = profile && profile.coverage && typeof profile.coverage.report_path === 'string' ? profile.coverage.report_path : ''
|
|
4255
|
+
if (!rel) die('verify baseline needs --report <lcov>, or coverage.report_path in the test profile')
|
|
4256
|
+
reportPath = resolve(unitCwdOf(root, profile), rel)
|
|
4257
|
+
}
|
|
4258
|
+
if (flags.report === undefined) {
|
|
4259
|
+
const format = profile.coverage.format || 'lcov'
|
|
4260
|
+
if (format !== 'lcov') die(`the report ${reportPath} is ${format}: only lcov is supported`)
|
|
4261
|
+
}
|
|
4262
|
+
let text
|
|
4263
|
+
try {
|
|
4264
|
+
text = readFileSync(reportPath, 'utf8')
|
|
4265
|
+
} catch (err) {
|
|
4266
|
+
die(`the coverage report ${reportPath} could not be read: ${err.message}`)
|
|
4267
|
+
}
|
|
4268
|
+
const figures = readLcovFigures(text, root, unitCwdOf(root, profile))
|
|
4269
|
+
if (figures.totals.totalPct === null) {
|
|
4270
|
+
die(`the coverage report ${reportPath} instruments no line, so it has no project total — no baseline written`)
|
|
4271
|
+
}
|
|
4272
|
+
const sha = headShaOf(root)
|
|
4273
|
+
if (!sha) die('verify baseline needs a git checkout with a commit to record the report against')
|
|
4274
|
+
let ref = null
|
|
4275
|
+
try {
|
|
4276
|
+
ref = gitText(root, ['symbolic-ref', '--short', '-q', 'HEAD']).trim() || null
|
|
4277
|
+
} catch {
|
|
4278
|
+
// A detached HEAD has no branch.
|
|
4279
|
+
}
|
|
4280
|
+
const baseline = {
|
|
4281
|
+
schema: BASELINE_SCHEMA,
|
|
4282
|
+
sha,
|
|
4283
|
+
ref,
|
|
4284
|
+
totalPct: figures.totals.totalPct,
|
|
4285
|
+
generatedAt: new Date().toISOString()
|
|
4286
|
+
}
|
|
4287
|
+
if (flags.baseline === true) die('--baseline needs a file')
|
|
4288
|
+
const target = typeof flags.baseline === 'string' ? flags.baseline : join(root, COVERAGE_BASELINE_PATH)
|
|
4289
|
+
try {
|
|
4290
|
+
writeJsonAtomic(target, baseline)
|
|
4291
|
+
} catch (err) {
|
|
4292
|
+
die(`the baseline could not be written to ${target}: ${err.message}`)
|
|
4293
|
+
}
|
|
4294
|
+
if (figures.outsideRoot) warn(`${figures.outsideRoot} record(s) in the report lie outside the repository and were left out of the total`)
|
|
4295
|
+
emit(
|
|
4296
|
+
json,
|
|
4297
|
+
{ ...baseline, path: target },
|
|
4298
|
+
`coverage baseline recorded: ${baseline.totalPct}% at ${sha.slice(0, 7)}${ref ? ` (${ref})` : ''} → ${target}\n`
|
|
4299
|
+
)
|
|
4300
|
+
} else if (sub === 'coverage') {
|
|
4301
|
+
// The coverage input the merge verdict reads, computed from the head
|
|
4302
|
+
// report, the baseline and the diff: figures only, no band (design D2).
|
|
4303
|
+
if (flags.base === undefined) {
|
|
4304
|
+
die('verify coverage: --base <ref> is required — the diff against it is the changed-line figure')
|
|
4305
|
+
}
|
|
4306
|
+
const base = baseRefOf(root, flags)
|
|
4307
|
+
// The body is `coverageInputFor`, which a ship run's close calls too. Here
|
|
4308
|
+
// a refusal is unreadable input: exit 1, nothing on stdout.
|
|
4309
|
+
const computed = coverageInputFor(root, base, flags)
|
|
4310
|
+
if (computed.reason) die(computed.reason)
|
|
4311
|
+
emit(json, computed.input, computed.text)
|
|
2695
4312
|
} else if (sub === 'cluster') {
|
|
2696
4313
|
const failures = readInput(flags.failures, '--failures <file|->')
|
|
2697
4314
|
if (!Array.isArray(failures)) {
|
|
@@ -2709,7 +4326,7 @@ async function main() {
|
|
|
2709
4326
|
if (action.action === 'halt') process.exit(1)
|
|
2710
4327
|
} else {
|
|
2711
4328
|
die(
|
|
2712
|
-
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec`
|
|
4329
|
+
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec|baseline|coverage`
|
|
2713
4330
|
)
|
|
2714
4331
|
}
|
|
2715
4332
|
break
|
|
@@ -2841,6 +4458,15 @@ async function main() {
|
|
|
2841
4458
|
return null
|
|
2842
4459
|
}
|
|
2843
4460
|
},
|
|
4461
|
+
readAtRef,
|
|
4462
|
+
// Git and coverage, as the close's merge-confidence gather reaches them
|
|
4463
|
+
// (complete-and-record-the-merge-verdict, D1 and D3): the same
|
|
4464
|
+
// functions the `merge-confidence` command hands its own gather.
|
|
4465
|
+
diffShape,
|
|
4466
|
+
nameStatus,
|
|
4467
|
+
listFiles,
|
|
4468
|
+
coverageInput: (repoRoot, { base } = {}) =>
|
|
4469
|
+
coverageInputFor(repoRoot, base, { 'state-home': flags['state-home'] }),
|
|
2844
4470
|
snapshotTree,
|
|
2845
4471
|
worktreeHead,
|
|
2846
4472
|
logWaveMutation,
|