@erclx/canon 5.73.0 → 5.74.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/CHANGELOG.md +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- package/claude/skills/create-skill/references/skill-practice.md +5 -0
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/{ui-film/references/pr-clip.md → pr-evidence/references/reel.md} +21 -21
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/references/adopted.md +4 -2
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +4 -5
- package/claude/skills/ui-film/SKILL.md +13 -12
- package/claude/skills/ui-film/references/compose-craft.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +2 -7
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/skill-map.md +1 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +89 -8
- package/src/commands/pr.ts +110 -13
- package/src/gate/measures.ts +1 -1
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -24
- package/src/markdown/scope.ts +2 -0
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- package/src/targets/pulls.ts +42 -0
package/src/commands/pr.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
type CheckRunListing,
|
|
25
25
|
collapseChecks,
|
|
26
|
+
type RawStatus,
|
|
27
|
+
requiredChecks,
|
|
26
28
|
withMergeState,
|
|
27
29
|
} from '@/pr/checks'
|
|
28
30
|
import {
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
findEvidenceLocal,
|
|
34
36
|
findEvidencePreview,
|
|
35
37
|
findEvidenceReel,
|
|
38
|
+
findEvidenceSkips,
|
|
36
39
|
type EvidenceItem,
|
|
37
40
|
type EvidenceReading,
|
|
38
41
|
groupEvidence,
|
|
@@ -171,6 +174,8 @@ interface EvidenceOptions extends ReadOptions {
|
|
|
171
174
|
readonly checklist?: string
|
|
172
175
|
readonly local?: string
|
|
173
176
|
readonly reel?: string
|
|
177
|
+
readonly localSkip?: string
|
|
178
|
+
readonly reelSkip?: string
|
|
174
179
|
readonly check?: boolean
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -433,7 +438,7 @@ type EvidenceRefusal =
|
|
|
433
438
|
|
|
434
439
|
const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
|
|
435
440
|
'check-writes':
|
|
436
|
-
'--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
|
|
441
|
+
'--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
|
|
437
442
|
'gh-failed':
|
|
438
443
|
'gh could not answer for this repository. Name the pull request number.',
|
|
439
444
|
'unreadable-checklist':
|
|
@@ -775,6 +780,14 @@ export function register(program: Command): void {
|
|
|
775
780
|
'--reel <link>',
|
|
776
781
|
'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
|
|
777
782
|
)
|
|
783
|
+
.option(
|
|
784
|
+
'--local-skip <reason>',
|
|
785
|
+
'Name why no local address is posted, from the reason pr-evidence holds for canon pr local',
|
|
786
|
+
)
|
|
787
|
+
.option(
|
|
788
|
+
'--reel-skip <reason>',
|
|
789
|
+
'Name why no reel is embedded, from the reason pr-evidence holds for canon pr frames --reel',
|
|
790
|
+
)
|
|
778
791
|
.option(
|
|
779
792
|
'--check',
|
|
780
793
|
'Report which post-pull-request steps the thread is still owed, rendering nothing',
|
|
@@ -854,6 +867,17 @@ export function register(program: Command): void {
|
|
|
854
867
|
'current, and a passed one replaces it. Like --local it rides on a',
|
|
855
868
|
'checklist or a comparison and never opens a comment alone.',
|
|
856
869
|
'',
|
|
870
|
+
'--local-skip and --reel-skip each put one line under the addresses,',
|
|
871
|
+
'**No local preview:** or **No reel:** followed by the text passed, a',
|
|
872
|
+
'reason code and any one-line message joined to it, folded onto one',
|
|
873
|
+
'line. Without the flag, a skip line the marked comment already carries',
|
|
874
|
+
'is carried forward. A skip line is dropped whenever its address or',
|
|
875
|
+
'reel renders, passed or carried, so a later run that posts the link or',
|
|
876
|
+
'films the reel replaces it. A passed --local-skip drops a carried',
|
|
877
|
+
'local address, since that line names no head and would read as live,',
|
|
878
|
+
'while a carried reel names its head and stays beside --reel-skip. Like',
|
|
879
|
+
'--local, neither opens a comment alone.',
|
|
880
|
+
'',
|
|
857
881
|
"A render at a new head also resets the pull request body's UI pass",
|
|
858
882
|
'line, the one `canon pr tick` writes under ## Testing, to unchecked',
|
|
859
883
|
'naming that head. It touches no other body text, leaves a line already',
|
|
@@ -893,7 +917,8 @@ export function register(program: Command): void {
|
|
|
893
917
|
'request changed, and the marked comment carries no **Preview:**',
|
|
894
918
|
'address. A failed deploy reads the same as a skipped one.',
|
|
895
919
|
'The record carries `owed` and the same marked fields. --check refuses',
|
|
896
|
-
'with --preview, --local, --checklist,
|
|
920
|
+
'with --preview, --local, --checklist, --reel, --local-skip, or',
|
|
921
|
+
'--reel-skip as check-writes.',
|
|
897
922
|
'',
|
|
898
923
|
'Refusals, read off `reason`:',
|
|
899
924
|
' unreadable-checklist --checklist named a path that could not be read,',
|
|
@@ -917,6 +942,7 @@ export function register(program: Command): void {
|
|
|
917
942
|
' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
|
|
918
943
|
' canon pr evidence 1341 --local http://localhost:5173 --json',
|
|
919
944
|
' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
|
|
945
|
+
" canon pr evidence 1341 --reel-skip 'no-server: <message>' --json",
|
|
920
946
|
' canon pr evidence 1341 --check --json',
|
|
921
947
|
'',
|
|
922
948
|
].join('\n'),
|
|
@@ -960,7 +986,12 @@ export function register(program: Command): void {
|
|
|
960
986
|
' or needing eyes refuses `ui-undriven`, carried or not',
|
|
961
987
|
' evidence the evidence check reads settled',
|
|
962
988
|
' standing the head is fresh against the base, not stale or conflicted',
|
|
963
|
-
' ci every check run on the tip passed, waited on up to --wait',
|
|
989
|
+
' ci every required check run on the tip passed, waited on up to --wait',
|
|
990
|
+
'',
|
|
991
|
+
"The required checks are the ones the base branch's rules list. A",
|
|
992
|
+
'check outside them is waited on by nobody, and one that failed is',
|
|
993
|
+
'named in the record as `advisory` without blocking. Rules that do not',
|
|
994
|
+
'read, or that list no check, gate by every check run on the tip.',
|
|
964
995
|
'',
|
|
965
996
|
'Review state, UI state, evidence, and standing are read before the CI',
|
|
966
997
|
'wait, so a reviewer whose partner is still driving refuses at once. The',
|
|
@@ -1039,6 +1070,10 @@ export function register(program: Command): void {
|
|
|
1039
1070
|
'request already passed `canon pr lift`. It does not wait on checks or',
|
|
1040
1071
|
'delete the branch.',
|
|
1041
1072
|
'',
|
|
1073
|
+
"The checks gate by the ones the base branch's rules require, the way",
|
|
1074
|
+
'`canon pr lift` reads them. A failing check outside that set is named',
|
|
1075
|
+
'in the record as `advisory` and does not refuse the merge.',
|
|
1076
|
+
'',
|
|
1042
1077
|
'Reasons:',
|
|
1043
1078
|
' merged, dry-run (--dry-run)',
|
|
1044
1079
|
' pull-closed, pull-draft, missing-summary, missing-key-changes,',
|
|
@@ -2658,7 +2693,9 @@ async function runEvidence(
|
|
|
2658
2693
|
(opts.preview !== undefined ||
|
|
2659
2694
|
opts.local !== undefined ||
|
|
2660
2695
|
opts.checklist !== undefined ||
|
|
2661
|
-
opts.reel !== undefined
|
|
2696
|
+
opts.reel !== undefined ||
|
|
2697
|
+
opts.localSkip !== undefined ||
|
|
2698
|
+
opts.reelSkip !== undefined)
|
|
2662
2699
|
) {
|
|
2663
2700
|
return refuseWith(
|
|
2664
2701
|
'check-writes',
|
|
@@ -2868,8 +2905,17 @@ async function runEvidence(
|
|
|
2868
2905
|
}
|
|
2869
2906
|
|
|
2870
2907
|
const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
|
|
2871
|
-
|
|
2908
|
+
// A carried local line names no head and reads as live, so a passed skip
|
|
2909
|
+
// drops it the way a passed address replaces it. A carried reel names the
|
|
2910
|
+
// head it was filmed at, so it stays beside a passed skip.
|
|
2911
|
+
const local =
|
|
2912
|
+
opts.local ?? (opts.localSkip === undefined ? carriedLocal : undefined)
|
|
2872
2913
|
const reel = nextEvidenceReel(opts.reel, identity.head, comments)
|
|
2914
|
+
const carriedSkips = findEvidenceSkips(comments)
|
|
2915
|
+
const skips = {
|
|
2916
|
+
local: opts.localSkip ?? carriedSkips.local,
|
|
2917
|
+
reel: opts.reelSkip ?? carriedSkips.reel,
|
|
2918
|
+
}
|
|
2873
2919
|
const unsettled = suppliedChecklist ?? carriedChecklist
|
|
2874
2920
|
const checklist =
|
|
2875
2921
|
unsettled === undefined
|
|
@@ -2886,6 +2932,7 @@ async function runEvidence(
|
|
|
2886
2932
|
checklist,
|
|
2887
2933
|
local,
|
|
2888
2934
|
reel,
|
|
2935
|
+
skips,
|
|
2889
2936
|
)
|
|
2890
2937
|
|
|
2891
2938
|
const caseCount = states.reduce((n, s) => n + s.items.length, 0)
|
|
@@ -4209,24 +4256,64 @@ async function readChecksGate(
|
|
|
4209
4256
|
number: string,
|
|
4210
4257
|
tip: string,
|
|
4211
4258
|
): Promise<ChecksGate | undefined> {
|
|
4212
|
-
|
|
4259
|
+
// A status read that fails leaves a required status context unseen, which
|
|
4260
|
+
// reads pending rather than passing, so it needs no refusal of its own.
|
|
4261
|
+
const [listed, pulled, combined] = await Promise.all([
|
|
4213
4262
|
gh(root, [
|
|
4214
4263
|
'api',
|
|
4215
4264
|
`repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
|
|
4216
4265
|
]),
|
|
4217
4266
|
gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
|
|
4267
|
+
gh(root, [
|
|
4268
|
+
'api',
|
|
4269
|
+
`repos/{owner}/{repo}/commits/${tip}/status?per_page=100`,
|
|
4270
|
+
]),
|
|
4218
4271
|
])
|
|
4219
4272
|
if (listed === null) return undefined
|
|
4220
4273
|
try {
|
|
4221
|
-
const
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4274
|
+
const row = pulled === null ? undefined : (JSON.parse(pulled) as RestPull)
|
|
4275
|
+
const required = await readRequiredChecks(root, row?.base?.ref)
|
|
4276
|
+
const statuses =
|
|
4277
|
+
combined === null
|
|
4278
|
+
? []
|
|
4279
|
+
: ((JSON.parse(combined) as { statuses?: readonly RawStatus[] })
|
|
4280
|
+
.statuses ?? [])
|
|
4225
4281
|
const reading = withMergeState(
|
|
4226
|
-
|
|
4227
|
-
collapseChecks(
|
|
4282
|
+
mergeStateOf(row?.mergeable_state),
|
|
4283
|
+
collapseChecks(
|
|
4284
|
+
tip,
|
|
4285
|
+
JSON.parse(listed) as CheckRunListing,
|
|
4286
|
+
required,
|
|
4287
|
+
statuses,
|
|
4288
|
+
),
|
|
4228
4289
|
)
|
|
4229
|
-
return {
|
|
4290
|
+
return {
|
|
4291
|
+
state: reading.state,
|
|
4292
|
+
isConflicted: reading.conflicted,
|
|
4293
|
+
...(reading.advisory !== undefined && { advisory: reading.advisory }),
|
|
4294
|
+
}
|
|
4295
|
+
} catch {
|
|
4296
|
+
return undefined
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
/**
|
|
4301
|
+
* The checks the base branch's rules require, so a check they leave out runs
|
|
4302
|
+
* without holding the gate. Undefined on any read that fails, which gates by
|
|
4303
|
+
* every check as the verbs did before the rules were read.
|
|
4304
|
+
*/
|
|
4305
|
+
async function readRequiredChecks(
|
|
4306
|
+
root: string,
|
|
4307
|
+
base: string | undefined,
|
|
4308
|
+
): Promise<ReadonlySet<string> | undefined> {
|
|
4309
|
+
if (base === undefined || base === '') return undefined
|
|
4310
|
+
const rules = await gh(root, [
|
|
4311
|
+
'api',
|
|
4312
|
+
`repos/{owner}/{repo}/rules/branches/${base}`,
|
|
4313
|
+
])
|
|
4314
|
+
if (rules === null) return undefined
|
|
4315
|
+
try {
|
|
4316
|
+
return requiredChecks(JSON.parse(rules))
|
|
4230
4317
|
} catch {
|
|
4231
4318
|
return undefined
|
|
4232
4319
|
}
|
|
@@ -4360,6 +4447,7 @@ async function runLift(
|
|
|
4360
4447
|
for (const gate of record.gates) {
|
|
4361
4448
|
if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
|
|
4362
4449
|
}
|
|
4450
|
+
warnAdvisory(record.advisory)
|
|
4363
4451
|
outro()
|
|
4364
4452
|
|
|
4365
4453
|
if (emitJson) {
|
|
@@ -4371,6 +4459,14 @@ async function runLift(
|
|
|
4371
4459
|
return record.isOk ? 0 : 1
|
|
4372
4460
|
}
|
|
4373
4461
|
|
|
4462
|
+
function warnAdvisory(advisory: readonly string[] | undefined): void {
|
|
4463
|
+
for (const name of advisory ?? []) {
|
|
4464
|
+
logWarn(
|
|
4465
|
+
`advisory: ${name} failed, which the base's rules do not require, so it did not block`,
|
|
4466
|
+
)
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4374
4470
|
const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
|
|
4375
4471
|
'gh-missing': PULL_REFUSALS['gh-missing'],
|
|
4376
4472
|
'pull-closed': 'The pull request is closed, so there is nothing to merge.',
|
|
@@ -4516,6 +4612,7 @@ async function runMerge(
|
|
|
4516
4612
|
? `#${record.number} would merge with the message below`
|
|
4517
4613
|
: MERGE_REFUSALS[record.reason],
|
|
4518
4614
|
)
|
|
4615
|
+
warnAdvisory(record.advisory)
|
|
4519
4616
|
outro()
|
|
4520
4617
|
|
|
4521
4618
|
if (emitJson) {
|
package/src/gate/measures.ts
CHANGED
|
@@ -1111,7 +1111,7 @@ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
|
|
|
1111
1111
|
|
|
1112
1112
|
/**
|
|
1113
1113
|
* `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
|
|
1114
|
-
* copy of the
|
|
1114
|
+
* copy of the five path globs a landing-page change should trigger on, plus
|
|
1115
1115
|
* the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
|
|
1116
1116
|
* Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
|
|
1117
1117
|
* anchor cannot cross files here, so the two copies are read live and compared
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -386,7 +386,13 @@ async function runCheck(
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
const run = check.kind === 'cli' ? ctx.cli : ctx.run
|
|
389
|
-
const
|
|
389
|
+
const argv =
|
|
390
|
+
check.kind === 'command' &&
|
|
391
|
+
check.changedArgv !== undefined &&
|
|
392
|
+
ctx.changed !== undefined
|
|
393
|
+
? check.changedArgv(ctx.changed)
|
|
394
|
+
: check.argv
|
|
395
|
+
const result = await run(argv, env)
|
|
390
396
|
return {
|
|
391
397
|
emissions: [{ kind: 'output', text: result.all }],
|
|
392
398
|
failure: result.exitCode === 0 ? undefined : check.failure,
|
package/src/gate/stages.ts
CHANGED
|
@@ -45,6 +45,12 @@ export type Check =
|
|
|
45
45
|
| {
|
|
46
46
|
readonly kind: 'command'
|
|
47
47
|
readonly argv: readonly string[]
|
|
48
|
+
/**
|
|
49
|
+
* Builds the argv from the changed set when scoping is on. `argv` runs
|
|
50
|
+
* where it is off, so `--all` and every widening fallback keep the full
|
|
51
|
+
* command.
|
|
52
|
+
*/
|
|
53
|
+
readonly changedArgv?: (changed: readonly string[]) => readonly string[]
|
|
48
54
|
readonly failure: string
|
|
49
55
|
}
|
|
50
56
|
/** This checkout's own CLI, so a worktree gate never reads the main tree. */
|
|
@@ -92,33 +98,36 @@ export interface Stage {
|
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* their tests walk the tree whole, which is what reaches a rule or a skill a
|
|
100
|
-
* branch adds rather than edits.
|
|
101
|
+
* Files a scoped run hands to `vitest related`, which follows imports from
|
|
102
|
+
* them to the tests that reach them. A corpus a test reads from disk is not
|
|
103
|
+
* among them, so a Markdown-only push runs no tests locally and CI's full run
|
|
104
|
+
* is what catches a broken corpus reader.
|
|
101
105
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
'^tooling/base/reference\\.md$',
|
|
108
|
-
'^tooling/web/configs/scripts/worktree-port\\.sh$',
|
|
109
|
-
]
|
|
106
|
+
const RELATED_SOURCES = /\.tsx?$|^commitlint\.config\.js$/
|
|
107
|
+
|
|
108
|
+
/** Files every test depends on without importing, so no related run sees them. */
|
|
109
|
+
const FULL_SUITE_TRIGGERS =
|
|
110
|
+
/^vitest\.(config|setup|global-setup)\.ts$|^tsconfig\.json$|^package\.json$/
|
|
110
111
|
|
|
111
112
|
const TESTS_SCOPE = new RegExp(
|
|
112
|
-
|
|
113
|
-
'^src/',
|
|
114
|
-
'^vitest\\.config\\.ts$',
|
|
115
|
-
'^tsconfig\\.json$',
|
|
116
|
-
'^package\\.json$',
|
|
117
|
-
'^commitlint\\.config\\.(js|test\\.ts)$',
|
|
118
|
-
...TEST_CORPORA_PATTERNS,
|
|
119
|
-
].join('|'),
|
|
113
|
+
`${RELATED_SOURCES.source}|${FULL_SUITE_TRIGGERS.source}`,
|
|
120
114
|
)
|
|
121
115
|
|
|
116
|
+
const FULL_SUITE = ['bun', 'run', 'test']
|
|
117
|
+
|
|
118
|
+
function relatedTests(changed: readonly string[]): readonly string[] {
|
|
119
|
+
if (changed.some((file) => FULL_SUITE_TRIGGERS.test(file))) return FULL_SUITE
|
|
120
|
+
return [
|
|
121
|
+
'bun',
|
|
122
|
+
'--bun',
|
|
123
|
+
'vitest',
|
|
124
|
+
'related',
|
|
125
|
+
'--run',
|
|
126
|
+
'--passWithNoTests',
|
|
127
|
+
...changed.filter((file) => RELATED_SOURCES.test(file)),
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
/**
|
|
123
132
|
* Every stage the gate runs, in the order it runs them.
|
|
124
133
|
*
|
|
@@ -590,14 +599,17 @@ export const STAGES: readonly Stage[] = [
|
|
|
590
599
|
id: 'tests',
|
|
591
600
|
label: 'Tests',
|
|
592
601
|
scope: TESTS_SCOPE,
|
|
593
|
-
skipped: 'Skipped, no TypeScript or
|
|
602
|
+
skipped: 'Skipped, no TypeScript or test config changes',
|
|
594
603
|
// Concurrent suites from a wave of worktrees starve each other's spawn
|
|
595
604
|
// and browser tests past their timeouts, so the box runs one at a time.
|
|
605
|
+
// A related run can still reach a spawn or browser test, so it holds the
|
|
606
|
+
// lock too.
|
|
596
607
|
lock: 'tests',
|
|
597
608
|
checks: [
|
|
598
609
|
{
|
|
599
610
|
kind: 'command',
|
|
600
|
-
argv:
|
|
611
|
+
argv: FULL_SUITE,
|
|
612
|
+
changedArgv: relatedTests,
|
|
601
613
|
failure: 'Tests failed',
|
|
602
614
|
},
|
|
603
615
|
],
|
package/src/markdown/scope.ts
CHANGED
|
@@ -280,6 +280,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
280
280
|
'claude/skills/pr-evidence/REQUIREMENT.md',
|
|
281
281
|
'claude/skills/pr-evidence/SKILL.md',
|
|
282
282
|
'claude/skills/pr-evidence/references/evidence.md',
|
|
283
|
+
'claude/skills/proposal-critique/REQUIREMENT.md',
|
|
284
|
+
'claude/skills/proposal-critique/SKILL.md',
|
|
283
285
|
'claude/skills/review-address/REQUIREMENT.md',
|
|
284
286
|
'claude/skills/review-address/SKILL.md',
|
|
285
287
|
'claude/skills/review-address/references/post-review-findings.md',
|
package/src/pr/checks.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ChecksState,
|
|
3
|
+
type RawCheck,
|
|
4
|
+
rollupRequired,
|
|
5
|
+
} from '@/targets/pulls'
|
|
2
6
|
|
|
3
7
|
/** One row of `repos/{owner}/{repo}/commits/<sha>/check-runs`. */
|
|
4
8
|
export interface RawCheckRun {
|
|
@@ -9,6 +13,12 @@ export interface RawCheckRun {
|
|
|
9
13
|
readonly id?: number
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
/** One row of `repos/{owner}/{repo}/commits/<sha>/status`, newest per context already. */
|
|
17
|
+
export interface RawStatus {
|
|
18
|
+
readonly context?: string
|
|
19
|
+
readonly state?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/** What that endpoint returns, count and rows apart. */
|
|
13
23
|
export interface CheckRunListing {
|
|
14
24
|
readonly total_count?: number
|
|
@@ -27,6 +37,41 @@ export interface ChecksReading {
|
|
|
27
37
|
readonly reported: number
|
|
28
38
|
/** Runs `matched` carried for a check name this reading already kept a newer run for. */
|
|
29
39
|
readonly collapsed: number
|
|
40
|
+
/**
|
|
41
|
+
* Failing checks outside the required set, present only when a required set
|
|
42
|
+
* was read, so a reading over every check keeps its old shape.
|
|
43
|
+
*/
|
|
44
|
+
readonly advisory?: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reads the check names a base branch's rules require, from
|
|
49
|
+
* `repos/{owner}/{repo}/rules/branches/<base>`.
|
|
50
|
+
*
|
|
51
|
+
* Undefined when the answer is not a rule list, which a repository without
|
|
52
|
+
* rulesets or a refused read returns, so the caller falls back to every check.
|
|
53
|
+
* Classic branch protection is not read, and falls back the same way.
|
|
54
|
+
*/
|
|
55
|
+
export function requiredChecks(
|
|
56
|
+
rules: unknown,
|
|
57
|
+
): ReadonlySet<string> | undefined {
|
|
58
|
+
if (!Array.isArray(rules)) return undefined
|
|
59
|
+
|
|
60
|
+
const names = new Set<string>()
|
|
61
|
+
for (const rule of rules as readonly RequiredRule[]) {
|
|
62
|
+
if (rule?.type !== 'required_status_checks') continue
|
|
63
|
+
for (const check of rule.parameters?.required_status_checks ?? []) {
|
|
64
|
+
if (typeof check.context === 'string') names.add(check.context)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RequiredRule {
|
|
71
|
+
readonly type?: string
|
|
72
|
+
readonly parameters?: {
|
|
73
|
+
readonly required_status_checks?: readonly { readonly context?: unknown }[]
|
|
74
|
+
}
|
|
30
75
|
}
|
|
31
76
|
|
|
32
77
|
export interface ConflictedReading extends ChecksReading {
|
|
@@ -68,6 +113,7 @@ export function withMergeState(
|
|
|
68
113
|
*/
|
|
69
114
|
function adapt(run: RawCheckRun): RawCheck {
|
|
70
115
|
return {
|
|
116
|
+
...(run.name !== undefined && { name: run.name }),
|
|
71
117
|
...(run.status !== undefined && { status: run.status.toUpperCase() }),
|
|
72
118
|
...(run.conclusion !== undefined &&
|
|
73
119
|
run.conclusion !== null && {
|
|
@@ -125,10 +171,22 @@ function newestPerCheck(runs: readonly RawCheckRun[]): {
|
|
|
125
171
|
* rolls up from `newestPerCheck`'s kept rows rather than from `matched`
|
|
126
172
|
* directly, so a stale failing run left behind by a rerun no longer outvotes
|
|
127
173
|
* the passing run beside it.
|
|
174
|
+
*
|
|
175
|
+
* A required set narrows only that last rollup. The foreign and partial
|
|
176
|
+
* listing guards still read every run, since either says the set in hand is
|
|
177
|
+
* incomplete whichever checks gate.
|
|
178
|
+
*
|
|
179
|
+
* A ruleset can require a commit status, which the check-run listing never
|
|
180
|
+
* carries, so the statuses join the rollup by `context` once a non-empty
|
|
181
|
+
* required set was read. An unread or empty set keeps them out, since the
|
|
182
|
+
* every-check fallback is the reading these verbs gave before rules were read
|
|
183
|
+
* and it saw no status.
|
|
128
184
|
*/
|
|
129
185
|
export function collapseChecks(
|
|
130
186
|
tip: string,
|
|
131
187
|
listing: CheckRunListing,
|
|
188
|
+
required?: ReadonlySet<string>,
|
|
189
|
+
statuses: readonly RawStatus[] = [],
|
|
132
190
|
): ChecksReading {
|
|
133
191
|
const runs = listing.check_runs ?? []
|
|
134
192
|
const matched = runs.filter((run) => run.head_sha === tip)
|
|
@@ -139,6 +197,7 @@ export function collapseChecks(
|
|
|
139
197
|
foreign: runs.length - matched.length,
|
|
140
198
|
reported: listing.total_count ?? runs.length,
|
|
141
199
|
collapsed,
|
|
200
|
+
...(required !== undefined && { advisory: [] }),
|
|
142
201
|
}
|
|
143
202
|
|
|
144
203
|
if (reading.foreign > 0) return { ...reading, state: 'pending' }
|
|
@@ -149,7 +208,20 @@ export function collapseChecks(
|
|
|
149
208
|
// sitting outside of, which is the false green keyed on a sha alone.
|
|
150
209
|
if (reading.reported > runs.length) return { ...reading, state: 'pending' }
|
|
151
210
|
|
|
152
|
-
|
|
211
|
+
const statusRows: RawCheck[] =
|
|
212
|
+
required === undefined || required.size === 0
|
|
213
|
+
? []
|
|
214
|
+
: statuses.map((status) => ({
|
|
215
|
+
...(status.context !== undefined && { name: status.context }),
|
|
216
|
+
state: (status.state ?? '').toUpperCase(),
|
|
217
|
+
}))
|
|
218
|
+
const rows = [...kept.map(adapt), ...statusRows]
|
|
219
|
+
if (rows.length === 0) return { ...reading, state: 'pending' }
|
|
153
220
|
|
|
154
|
-
|
|
221
|
+
const { state, advisory } = rollupRequired(rows, required)
|
|
222
|
+
return {
|
|
223
|
+
...reading,
|
|
224
|
+
state: state ?? 'pending',
|
|
225
|
+
...(required !== undefined && { advisory }),
|
|
226
|
+
}
|
|
155
227
|
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -25,6 +25,12 @@ const REEL_PREFIX = '**Reel** filmed at'
|
|
|
25
25
|
const REEL_LINE =
|
|
26
26
|
/^\*\*Reel\*\* filmed at `([0-9a-f]{7,40})`:<br><img src="([^"]+)"/
|
|
27
27
|
|
|
28
|
+
/** How the line naming why no local address was posted opens. */
|
|
29
|
+
const LOCAL_SKIP_PREFIX = '**No local preview:**'
|
|
30
|
+
|
|
31
|
+
/** How the line naming why no reel was filmed opens. */
|
|
32
|
+
const REEL_SKIP_PREFIX = '**No reel:**'
|
|
33
|
+
|
|
28
34
|
/** The width a reel is filmed at, which it asks for in the comment. */
|
|
29
35
|
const REEL_WIDTH = 960
|
|
30
36
|
|
|
@@ -198,6 +204,15 @@ function escapeCell(text: string): string {
|
|
|
198
204
|
.replaceAll('|', '|')
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
function unescapeCell(text: string): string {
|
|
208
|
+
return text
|
|
209
|
+
.replaceAll('|', '|')
|
|
210
|
+
.replaceAll('"', '"')
|
|
211
|
+
.replaceAll('>', '>')
|
|
212
|
+
.replaceAll('<', '<')
|
|
213
|
+
.replaceAll('&', '&')
|
|
214
|
+
}
|
|
215
|
+
|
|
201
216
|
function evidenceImage(url: string, alt: string, width?: number): string {
|
|
202
217
|
const shown = Math.min(width ?? IMAGE_WIDTH, IMAGE_WIDTH)
|
|
203
218
|
return `<img src="${url}" width="${shown}" alt="${alt}">`
|
|
@@ -449,6 +464,18 @@ function reelLine(reel: EvidenceReel): string {
|
|
|
449
464
|
return `${REEL_PREFIX} \`${head}\`:<br><img src="${escapeCell(reel.link)}" width="${REEL_WIDTH}" alt="reel filmed at ${head}">`
|
|
450
465
|
}
|
|
451
466
|
|
|
467
|
+
/** Why a render carries no local address or no reel, each a reason code and any one-line message the caller joined to it. */
|
|
468
|
+
export interface EvidenceSkips {
|
|
469
|
+
readonly local?: string
|
|
470
|
+
readonly reel?: string
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A skip line, or nothing for an empty reason. A reason is folded onto one line, since a line break would end the address region early. */
|
|
474
|
+
function skipLines(prefix: string, reason: string | undefined): string[] {
|
|
475
|
+
const text = reason?.replace(/\s+/g, ' ').trim() ?? ''
|
|
476
|
+
return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
|
|
477
|
+
}
|
|
478
|
+
|
|
452
479
|
export function evidenceMarker(head: string): string {
|
|
453
480
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
454
481
|
}
|
|
@@ -480,6 +507,10 @@ export function evidenceMarker(head: string): string {
|
|
|
480
507
|
*
|
|
481
508
|
* A reel sits on its own line under the addresses, labeled with the head it
|
|
482
509
|
* was filmed at, so a reel carried past a later push never reads as current.
|
|
510
|
+
*
|
|
511
|
+
* A skip names why the local address or the reel is missing, on its own line
|
|
512
|
+
* where that address or reel would sit. Each is dropped whenever its address
|
|
513
|
+
* or reel renders, carried or passed, so a skip never outlives its cause.
|
|
483
514
|
*/
|
|
484
515
|
export function renderEvidenceBody(
|
|
485
516
|
states: readonly EvidenceState[],
|
|
@@ -490,6 +521,7 @@ export function renderEvidenceBody(
|
|
|
490
521
|
checklist?: string,
|
|
491
522
|
local?: string,
|
|
492
523
|
reel?: EvidenceReel,
|
|
524
|
+
skips: EvidenceSkips = {},
|
|
493
525
|
): string {
|
|
494
526
|
const segments = [
|
|
495
527
|
...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
|
|
@@ -499,7 +531,10 @@ export function renderEvidenceBody(
|
|
|
499
531
|
EVIDENCE_HEADING,
|
|
500
532
|
'',
|
|
501
533
|
...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
|
|
502
|
-
...(
|
|
534
|
+
...(local === undefined ? skipLines(LOCAL_SKIP_PREFIX, skips.local) : []),
|
|
535
|
+
...(reel === undefined
|
|
536
|
+
? skipLines(REEL_SKIP_PREFIX, skips.reel)
|
|
537
|
+
: [reelLine(reel), '']),
|
|
503
538
|
]
|
|
504
539
|
const closing =
|
|
505
540
|
checklist === undefined
|
|
@@ -756,6 +791,32 @@ export function findEvidenceReel(
|
|
|
756
791
|
return undefined
|
|
757
792
|
}
|
|
758
793
|
|
|
794
|
+
/**
|
|
795
|
+
* The skips the marked comment already carries, read from the address region
|
|
796
|
+
* only, so a line quoted in a checklist is never taken for one. A re-render
|
|
797
|
+
* passing no skip carries these forward the way it carries an address.
|
|
798
|
+
*/
|
|
799
|
+
export function findEvidenceSkips(
|
|
800
|
+
comments: readonly EvidenceComment[],
|
|
801
|
+
): EvidenceSkips {
|
|
802
|
+
const marked = findMarked(comments)
|
|
803
|
+
if (marked === undefined) return {}
|
|
804
|
+
let local: string | undefined
|
|
805
|
+
let reel: string | undefined
|
|
806
|
+
for (const raw of marked.body.split('\n')) {
|
|
807
|
+
const line = raw.trim()
|
|
808
|
+
if (REGION_ENDS.some((end) => line.startsWith(end))) break
|
|
809
|
+
const ownSkip = readSegment(line, LOCAL_SKIP_PREFIX)
|
|
810
|
+
const reelSkip = readSegment(line, REEL_SKIP_PREFIX)
|
|
811
|
+
if (ownSkip !== undefined) local ??= unescapeCell(ownSkip)
|
|
812
|
+
if (reelSkip !== undefined) reel ??= unescapeCell(reelSkip)
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
...(local !== undefined && { local }),
|
|
816
|
+
...(reel !== undefined && { reel }),
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
759
820
|
/**
|
|
760
821
|
* The reel a render embeds: a passed link, filmed at this head, replaces
|
|
761
822
|
* whatever the comment carries, and with none passed the posted reel carries
|
package/src/pr/lift.ts
CHANGED
|
@@ -83,8 +83,19 @@ export interface EvidenceGate {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export interface ChecksGate {
|
|
86
|
+
/** The state over the checks the base requires, or every check when none were read. */
|
|
86
87
|
readonly state: ChecksState
|
|
87
88
|
readonly isConflicted: boolean
|
|
89
|
+
/** Failing checks outside the required set, which are reported and never gate. */
|
|
90
|
+
readonly advisory?: readonly string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The advisory field a record carries, absent when no advisory check failed. */
|
|
94
|
+
export function advisoryOf(reading: ChecksGate): {
|
|
95
|
+
advisory?: readonly string[]
|
|
96
|
+
} {
|
|
97
|
+
const advisory = reading.advisory ?? []
|
|
98
|
+
return advisory.length > 0 ? { advisory } : {}
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/**
|
|
@@ -129,6 +140,8 @@ export interface LiftRecord {
|
|
|
129
140
|
readonly carriedFrom?: string
|
|
130
141
|
readonly paintedPaths?: readonly string[]
|
|
131
142
|
readonly drivenPaths?: readonly string[]
|
|
143
|
+
/** Failing checks the base does not require, named so a reader sees them before merging. */
|
|
144
|
+
readonly advisory?: readonly string[]
|
|
132
145
|
readonly isLifted: boolean
|
|
133
146
|
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
134
147
|
readonly isOk: boolean
|
|
@@ -247,7 +260,11 @@ function standingGate(standing: Standing): GateResult {
|
|
|
247
260
|
}
|
|
248
261
|
|
|
249
262
|
type CiOutcome =
|
|
250
|
-
| {
|
|
263
|
+
| {
|
|
264
|
+
readonly kind: 'passing'
|
|
265
|
+
readonly waited: number
|
|
266
|
+
readonly reading: ChecksGate
|
|
267
|
+
}
|
|
251
268
|
| {
|
|
252
269
|
readonly kind: 'refused'
|
|
253
270
|
readonly reason: LiftRefusal
|
|
@@ -274,7 +291,9 @@ async function waitForChecks(
|
|
|
274
291
|
if (reading === undefined) {
|
|
275
292
|
return { kind: 'refused', reason: 'gh-failed', waited }
|
|
276
293
|
}
|
|
277
|
-
if (reading.state === 'passing')
|
|
294
|
+
if (reading.state === 'passing') {
|
|
295
|
+
return { kind: 'passing', waited, reading }
|
|
296
|
+
}
|
|
278
297
|
if (reading.state === 'failing') {
|
|
279
298
|
return { kind: 'refused', reason: 'ci-failing', waited }
|
|
280
299
|
}
|
|
@@ -422,6 +441,7 @@ export async function lift(
|
|
|
422
441
|
gates: all,
|
|
423
442
|
waitedSeconds: 0,
|
|
424
443
|
...carried(all),
|
|
444
|
+
...advisoryOf(reading),
|
|
425
445
|
}
|
|
426
446
|
}
|
|
427
447
|
|
|
@@ -485,6 +505,7 @@ export async function lift(
|
|
|
485
505
|
gates: cleared,
|
|
486
506
|
waitedSeconds: ci.waited,
|
|
487
507
|
...carried(cleared),
|
|
508
|
+
...advisoryOf(ci.reading),
|
|
488
509
|
}
|
|
489
510
|
}
|
|
490
511
|
|