@erclx/canon 5.13.0 → 5.14.1
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/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +3 -2
- package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
- package/claude/skills/deprecation-migration/SKILL.md +115 -0
- package/claude/skills/deprecation-migration/references/adopted.md +50 -0
- package/claude/skills/git-followup/REQUIREMENT.md +1 -0
- package/claude/skills/git-followup/SKILL.md +3 -0
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-ship/REQUIREMENT.md +3 -0
- package/claude/skills/git-ship/SKILL.md +12 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +5 -2
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/review-ui/references/driving.md +5 -3
- package/claude/skills/review-ui/references/post.md +3 -2
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +5 -0
- package/docs/agents/pr-frames.md +112 -0
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +3 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/workflow/skill-map.md +15 -14
- package/package.json +4 -5
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +1 -0
- package/src/commands/pr.ts +452 -0
- package/src/commands/sandbox.ts +2 -2
- package/src/context/architecture.ts +5 -2
- package/src/context/citations.ts +1 -1
- package/src/gate/boundaries.ts +509 -0
- package/src/gate/measures.ts +8 -19
- package/src/gate/stages.ts +12 -28
- package/src/pr/frames.ts +323 -0
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/expect.ts +0 -1
- package/src/shipped/references.ts +8 -10
- package/src/targets/pulls.ts +2 -2
- package/src/tooling/manifest.ts +2 -3
- package/src/tooling/seeds.ts +37 -0
- package/tooling/claude/manifest.toml +3 -3
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
- package/scripts/core/check-capability-seeding.sh +0 -134
- package/scripts/core/check-ignore-parity.sh +0 -141
- package/scripts/core/check-plugin-boundary.sh +0 -45
- package/scripts/core/check-seed-independence.sh +0 -62
- package/scripts/core/check-skill-paths.sh +0 -24
- package/scripts/core/list-seed-roots.sh +0 -18
- package/scripts/lib/tooling.sh +0 -37
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
FIXTURE_SUFFIX=".fixture"
|
|
7
7
|
|
|
8
8
|
# The tree mirrors the scenario path, so `category` and `scenario` together match
|
|
9
|
-
# `
|
|
9
|
+
# `sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
|
|
10
10
|
# four scenario basenames repeat across categories (`claude`, `docs`, `review`,
|
|
11
11
|
# `sync`), and `docs` is also a category of its own.
|
|
12
12
|
fixture_stage_dir() {
|
|
@@ -14,7 +14,7 @@ fixture_stage_dir() {
|
|
|
14
14
|
local scenario="$2"
|
|
15
15
|
local arm="$3"
|
|
16
16
|
local stage="$4"
|
|
17
|
-
echo "$PROJECT_ROOT/
|
|
17
|
+
echo "$PROJECT_ROOT/sandbox/fixtures/$category/$scenario/$arm/$stage"
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
list_fixture_files() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
|
-
# The sandbox tree lives outside the toolkit worktree. `
|
|
3
|
+
# The sandbox tree lives outside the toolkit worktree. `sandbox/run.sh`
|
|
4
4
|
# sets cwd to it for `claude -p`, and every `CLAUDE.md` between that cwd and the
|
|
5
5
|
# filesystem root loads into the session. Under the repository the toolkit's own
|
|
6
6
|
# instructions join that chain beside the seeded copy the scenario installed,
|
|
@@ -40,7 +40,7 @@ show_help() {
|
|
|
40
40
|
exit 0
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/
|
|
43
|
+
ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/sandbox/fixtures/anchor/create"
|
|
44
44
|
|
|
45
45
|
# Where an anchor scenario's tree comes from and where its remote points are two
|
|
46
46
|
# concerns, and only the remote needs a network. Taking the tree from a fixture
|
|
@@ -438,7 +438,7 @@ main() {
|
|
|
438
438
|
fi
|
|
439
439
|
|
|
440
440
|
SANDBOX="$(resolve_sandbox_dir)"
|
|
441
|
-
SANDBOX_DIR="$PROJECT_ROOT/
|
|
441
|
+
SANDBOX_DIR="$PROJECT_ROOT/sandbox"
|
|
442
442
|
|
|
443
443
|
local unsafe
|
|
444
444
|
if ! unsafe="$(assert_sandbox_dir_safe "$SANDBOX" "$PROJECT_ROOT")"; then
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -84,6 +84,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
84
84
|
"I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
|
|
85
85
|
expect: 'build-in-slices',
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
prompt:
|
|
89
|
+
'The old parseDate helper was replaced months ago. Can I just delete it, or could a few files still be calling it?',
|
|
90
|
+
expect: 'deprecation-migration',
|
|
91
|
+
},
|
|
87
92
|
{
|
|
88
93
|
prompt: 'Does our github about text still match what the readme says?',
|
|
89
94
|
expect: 'repo-metadata',
|
|
@@ -39,6 +39,7 @@ export const PRACTICE_SKILLS: readonly string[] = [
|
|
|
39
39
|
join(SHIPPED_CORPUS, 'api-design'),
|
|
40
40
|
join(SHIPPED_CORPUS, 'code-craft'),
|
|
41
41
|
join(SHIPPED_CORPUS, 'build-in-slices'),
|
|
42
|
+
join(SHIPPED_CORPUS, 'deprecation-migration'),
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
/** The closing H2s a practice skill carries, matched exactly outside fences. */
|
package/src/commands/pr.ts
CHANGED
|
@@ -36,6 +36,19 @@ import {
|
|
|
36
36
|
readOwed,
|
|
37
37
|
renderEvidenceBody,
|
|
38
38
|
} from '@/pr/evidence'
|
|
39
|
+
import {
|
|
40
|
+
dropFrames,
|
|
41
|
+
FRAMES_BRANCH,
|
|
42
|
+
type FramesApi,
|
|
43
|
+
type FramesRefusal,
|
|
44
|
+
frameLink,
|
|
45
|
+
isFrameHead,
|
|
46
|
+
isFramePass,
|
|
47
|
+
pruneFrames,
|
|
48
|
+
pushFrame,
|
|
49
|
+
readFrame,
|
|
50
|
+
type TreeEntry,
|
|
51
|
+
} from '@/pr/frames'
|
|
39
52
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
40
53
|
import {
|
|
41
54
|
findLocalServer,
|
|
@@ -116,6 +129,55 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
|
|
|
116
129
|
'gh could not read or edit the marked comment on this pull request.',
|
|
117
130
|
}
|
|
118
131
|
|
|
132
|
+
interface FramesOptions extends ReadOptions {
|
|
133
|
+
readonly add?: string
|
|
134
|
+
readonly box?: string
|
|
135
|
+
readonly head?: string
|
|
136
|
+
readonly pass?: string
|
|
137
|
+
readonly drop?: boolean
|
|
138
|
+
readonly prune?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type FramesCommandRefusal =
|
|
142
|
+
| FramesRefusal
|
|
143
|
+
| 'bad-mode'
|
|
144
|
+
| 'no-number'
|
|
145
|
+
| 'bad-box'
|
|
146
|
+
| 'bad-head'
|
|
147
|
+
| 'bad-pass'
|
|
148
|
+
| 'bad-days'
|
|
149
|
+
| 'unreadable-frame'
|
|
150
|
+
| 'read-only'
|
|
151
|
+
| 'gh-missing'
|
|
152
|
+
| 'gh-failed'
|
|
153
|
+
| 'no-object-head'
|
|
154
|
+
|
|
155
|
+
const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
|
|
156
|
+
'bad-mode':
|
|
157
|
+
'Pass exactly one of --add, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
|
|
158
|
+
'no-number':
|
|
159
|
+
'--add and --drop write for one pull request, so name its number.',
|
|
160
|
+
'bad-box':
|
|
161
|
+
'--add takes --box <n>, a positive whole number naming the checklist box.',
|
|
162
|
+
'bad-head':
|
|
163
|
+
'--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
|
|
164
|
+
'bad-pass':
|
|
165
|
+
'--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
|
|
166
|
+
'bad-days': '--prune takes a positive whole number of days.',
|
|
167
|
+
'unreadable-frame':
|
|
168
|
+
'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
|
|
169
|
+
'read-only': `GitHub refused the write to ${FRAMES_BRANCH}. The token cannot write this repository, which is what a fork pull request's workflow token gets, so describe the frame in words instead.`,
|
|
170
|
+
'gh-missing':
|
|
171
|
+
'gh is not on the path, so the branch could not be read or written.',
|
|
172
|
+
'gh-failed':
|
|
173
|
+
'gh could not answer for this repository or pull request, so no link could be built.',
|
|
174
|
+
'no-object-head':
|
|
175
|
+
'The pull request object reported no head commit and no --head was passed, so the frame has no head to file under.',
|
|
176
|
+
'unreadable-tip': `The ${FRAMES_BRANCH} branch could not be read, so nothing was written. A missing branch is not this, since an add creates it.`,
|
|
177
|
+
'push-failed': `A write to ${FRAMES_BRANCH} failed partway. The ref did not move, so the branch is as it was.`,
|
|
178
|
+
'ref-conflict': `Another writer kept moving ${FRAMES_BRANCH} through every attempt, so the add gave up. Re-run it.`,
|
|
179
|
+
}
|
|
180
|
+
|
|
119
181
|
interface PreviewOptions extends ReadOptions {
|
|
120
182
|
readonly timeout?: string
|
|
121
183
|
readonly check?: boolean
|
|
@@ -659,6 +721,82 @@ export function register(program: Command): void {
|
|
|
659
721
|
? await runLocalRemove(number, opts)
|
|
660
722
|
: await runLocal(opts)
|
|
661
723
|
})
|
|
724
|
+
|
|
725
|
+
pr.command('frames')
|
|
726
|
+
.description(
|
|
727
|
+
`Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
|
|
728
|
+
)
|
|
729
|
+
.argument(
|
|
730
|
+
'[number]',
|
|
731
|
+
'Pull request the frame belongs to, for --add and --drop',
|
|
732
|
+
)
|
|
733
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
734
|
+
.option('--root <path>', 'Repository to answer for, defaulting to the cwd')
|
|
735
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
736
|
+
.option('--add <file>', 'Push this PNG as a frame')
|
|
737
|
+
.option('--box <n>', 'The checklist box the frame shows, with --add')
|
|
738
|
+
.option(
|
|
739
|
+
'--head <sha>',
|
|
740
|
+
"The head the frame was driven at, with --add, defaulting to the pull request's",
|
|
741
|
+
)
|
|
742
|
+
.option(
|
|
743
|
+
'--pass <stamp>',
|
|
744
|
+
'The instant the pass started, in compact UTC such as 20261002T154450Z, with --add',
|
|
745
|
+
)
|
|
746
|
+
.option('--drop', "Rewrite the branch without this pull request's frames")
|
|
747
|
+
.option(
|
|
748
|
+
'--prune <days>',
|
|
749
|
+
'Drop the frames of every pull request closed more than this many days ago',
|
|
750
|
+
)
|
|
751
|
+
.addHelpText(
|
|
752
|
+
'after',
|
|
753
|
+
[
|
|
754
|
+
'',
|
|
755
|
+
`Writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
|
|
756
|
+
'so nothing is checked out and no other branch is touched. A frame lands',
|
|
757
|
+
'at pr-<number>/<short-head>/<pass>/box-<n>.png, and `link` names that',
|
|
758
|
+
`path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
|
|
759
|
+
'requests and resolves until its own pull request is dropped. The pass',
|
|
760
|
+
'segment keeps a second pass at one head and box from replacing an image',
|
|
761
|
+
'an earlier comment shows. The first --add creates the branch.',
|
|
762
|
+
'',
|
|
763
|
+
'--drop and --prune write one root commit and force-move the ref,',
|
|
764
|
+
'since a delete commit would keep every image reachable through history.',
|
|
765
|
+
'A rewrite that empties the branch deletes it. --prune keeps a pull',
|
|
766
|
+
'request still open, reopened, closed exactly the given days ago, or one',
|
|
767
|
+
'whose state could not be read, listing that last kind under `unread`.',
|
|
768
|
+
'',
|
|
769
|
+
'Read `reason` on the JSON record:',
|
|
770
|
+
' ok an add pushed, or a drop or prune finished',
|
|
771
|
+
' bad-mode not exactly one of --add, --drop, --prune',
|
|
772
|
+
' no-number --add or --drop named no pull request',
|
|
773
|
+
' bad-box --add without a positive --box',
|
|
774
|
+
' bad-head --head is not 7 to 40 lowercase hex characters',
|
|
775
|
+
' bad-pass --add without a compact UTC --pass stamp',
|
|
776
|
+
' bad-days --prune without a positive number of days',
|
|
777
|
+
' unreadable-frame the --add file is missing, empty, or not a PNG',
|
|
778
|
+
' read-only GitHub refused the write, as on a fork token',
|
|
779
|
+
' unreadable-tip the branch could not be read',
|
|
780
|
+
' push-failed a write failed and the ref did not move',
|
|
781
|
+
' ref-conflict another writer kept moving the ref',
|
|
782
|
+
' no-object-head no head to file the frame under',
|
|
783
|
+
' gh-missing gh is not on the path',
|
|
784
|
+
' gh-failed gh could not answer for the repository',
|
|
785
|
+
'',
|
|
786
|
+
'Exit codes:',
|
|
787
|
+
' 0 pushed, or a drop or prune finished, including one removing nothing',
|
|
788
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
789
|
+
'',
|
|
790
|
+
'Examples:',
|
|
791
|
+
' canon pr frames 1341 --add frames/3.png --box 3 --pass 20261002T154450Z --json',
|
|
792
|
+
' canon pr frames 1341 --drop --json',
|
|
793
|
+
' canon pr frames --prune 30 --json',
|
|
794
|
+
'',
|
|
795
|
+
].join('\n'),
|
|
796
|
+
)
|
|
797
|
+
.action(async (number: string | undefined, opts: FramesOptions) => {
|
|
798
|
+
process.exitCode = await runFrames(number, opts)
|
|
799
|
+
})
|
|
662
800
|
}
|
|
663
801
|
|
|
664
802
|
interface PullRequestRead {
|
|
@@ -1743,6 +1881,7 @@ async function runEvidence(
|
|
|
1743
1881
|
base,
|
|
1744
1882
|
head: identity.head,
|
|
1745
1883
|
body,
|
|
1884
|
+
states,
|
|
1746
1885
|
...(commentId !== undefined && { commentId }),
|
|
1747
1886
|
...carried,
|
|
1748
1887
|
})}\n`,
|
|
@@ -2198,6 +2337,319 @@ async function runLocalRemove(
|
|
|
2198
2337
|
return finish('removed', `comment ${commentId}`)
|
|
2199
2338
|
}
|
|
2200
2339
|
|
|
2340
|
+
const POSITIVE_WHOLE = /^[1-9]\d*$/
|
|
2341
|
+
|
|
2342
|
+
/** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
|
|
2343
|
+
type ApiCall =
|
|
2344
|
+
| { readonly ok: true; readonly data: unknown }
|
|
2345
|
+
| { readonly ok: false; readonly status: number | undefined }
|
|
2346
|
+
|
|
2347
|
+
type ApiCaller = (
|
|
2348
|
+
method: string,
|
|
2349
|
+
path: string,
|
|
2350
|
+
body?: unknown,
|
|
2351
|
+
) => Promise<ApiCall>
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* Runs `gh api` against the repository's own path, recording every write
|
|
2355
|
+
* GitHub answered 403 or 404, which is how a token without write access
|
|
2356
|
+
* reads, so a failed push can be reported as read-only rather than broken.
|
|
2357
|
+
*/
|
|
2358
|
+
function ghApiRunner(root: string): {
|
|
2359
|
+
readonly call: ApiCaller
|
|
2360
|
+
readonly refusedWrites: number[]
|
|
2361
|
+
} {
|
|
2362
|
+
const refusedWrites: number[] = []
|
|
2363
|
+
const call: ApiCaller = async (method, path, body) => {
|
|
2364
|
+
const args = ['api', '-X', method, `repos/{owner}/{repo}/${path}`]
|
|
2365
|
+
if (body !== undefined) args.push('--input', '-')
|
|
2366
|
+
const result = await execa('gh', args, {
|
|
2367
|
+
cwd: root,
|
|
2368
|
+
timeout: GH_TIMEOUT_MS,
|
|
2369
|
+
env: gitEnv(),
|
|
2370
|
+
extendEnv: false,
|
|
2371
|
+
reject: false,
|
|
2372
|
+
...(body !== undefined && { input: JSON.stringify(body) }),
|
|
2373
|
+
})
|
|
2374
|
+
if (result.exitCode !== 0) {
|
|
2375
|
+
const status = /HTTP (\d{3})/.exec(String(result.stderr))?.[1]
|
|
2376
|
+
const code = status === undefined ? undefined : Number(status)
|
|
2377
|
+
if (method !== 'GET' && (code === 403 || code === 404)) {
|
|
2378
|
+
refusedWrites.push(code)
|
|
2379
|
+
}
|
|
2380
|
+
return { ok: false, status: code }
|
|
2381
|
+
}
|
|
2382
|
+
const text = String(result.stdout).trim()
|
|
2383
|
+
try {
|
|
2384
|
+
return { ok: true, data: text === '' ? null : JSON.parse(text) }
|
|
2385
|
+
} catch {
|
|
2386
|
+
return { ok: false, status: undefined }
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
return { call, refusedWrites }
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function shaOf(data: unknown): string | undefined {
|
|
2393
|
+
if (typeof data !== 'object' || data === null || !('sha' in data)) {
|
|
2394
|
+
return undefined
|
|
2395
|
+
}
|
|
2396
|
+
return typeof data.sha === 'string' ? data.sha : undefined
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function isTreeEntry(value: unknown): value is TreeEntry {
|
|
2400
|
+
if (typeof value !== 'object' || value === null) return false
|
|
2401
|
+
const entry = value as Record<string, unknown>
|
|
2402
|
+
return (
|
|
2403
|
+
typeof entry.path === 'string' &&
|
|
2404
|
+
typeof entry.mode === 'string' &&
|
|
2405
|
+
(entry.type === 'blob' || entry.type === 'tree') &&
|
|
2406
|
+
typeof entry.sha === 'string'
|
|
2407
|
+
)
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* The git data API behind `canon pr frames`. A 422 on a ref write is GitHub
|
|
2412
|
+
* refusing a move that is not a fast-forward, or a create or update on a ref
|
|
2413
|
+
* that already exists or no longer does, which is another writer's doing and
|
|
2414
|
+
* worth a re-read rather than a failure.
|
|
2415
|
+
*/
|
|
2416
|
+
function ghFramesApi(call: ApiCaller): FramesApi {
|
|
2417
|
+
const ref = `git/refs/heads/${FRAMES_BRANCH}`
|
|
2418
|
+
const refWrite = (result: ApiCall) =>
|
|
2419
|
+
result.ok ? 'ok' : result.status === 422 ? 'conflict' : 'failed'
|
|
2420
|
+
return {
|
|
2421
|
+
async readTip() {
|
|
2422
|
+
const head = await call('GET', `git/ref/heads/${FRAMES_BRANCH}`)
|
|
2423
|
+
if (!head.ok) {
|
|
2424
|
+
return head.status === 404
|
|
2425
|
+
? { kind: 'missing' }
|
|
2426
|
+
: { kind: 'unreadable' }
|
|
2427
|
+
}
|
|
2428
|
+
const commit = (head.data as { object?: { sha?: unknown } } | null)
|
|
2429
|
+
?.object?.sha
|
|
2430
|
+
if (typeof commit !== 'string') return { kind: 'unreadable' }
|
|
2431
|
+
const read = await call('GET', `git/commits/${commit}`)
|
|
2432
|
+
const tree = read.ok
|
|
2433
|
+
? (read.data as { tree?: { sha?: unknown } } | null)?.tree?.sha
|
|
2434
|
+
: undefined
|
|
2435
|
+
return typeof tree === 'string'
|
|
2436
|
+
? { kind: 'found', commit, tree }
|
|
2437
|
+
: { kind: 'unreadable' }
|
|
2438
|
+
},
|
|
2439
|
+
async listTree(tree) {
|
|
2440
|
+
const read = await call('GET', `git/trees/${tree}`)
|
|
2441
|
+
if (!read.ok) return undefined
|
|
2442
|
+
const entries = (read.data as { tree?: unknown } | null)?.tree
|
|
2443
|
+
return Array.isArray(entries) && entries.every(isTreeEntry)
|
|
2444
|
+
? entries
|
|
2445
|
+
: undefined
|
|
2446
|
+
},
|
|
2447
|
+
async createBlob(base64) {
|
|
2448
|
+
const made = await call('POST', 'git/blobs', {
|
|
2449
|
+
content: base64,
|
|
2450
|
+
encoding: 'base64',
|
|
2451
|
+
})
|
|
2452
|
+
return made.ok ? shaOf(made.data) : undefined
|
|
2453
|
+
},
|
|
2454
|
+
async createTree(entries, base) {
|
|
2455
|
+
const made = await call('POST', 'git/trees', {
|
|
2456
|
+
tree: entries.map(({ path, mode, type, sha }) => ({
|
|
2457
|
+
path,
|
|
2458
|
+
mode,
|
|
2459
|
+
type,
|
|
2460
|
+
sha,
|
|
2461
|
+
})),
|
|
2462
|
+
...(base !== undefined && { base_tree: base }),
|
|
2463
|
+
})
|
|
2464
|
+
return made.ok ? shaOf(made.data) : undefined
|
|
2465
|
+
},
|
|
2466
|
+
async createCommit(message, tree, parents) {
|
|
2467
|
+
const made = await call('POST', 'git/commits', {
|
|
2468
|
+
message,
|
|
2469
|
+
tree,
|
|
2470
|
+
parents,
|
|
2471
|
+
})
|
|
2472
|
+
return made.ok ? shaOf(made.data) : undefined
|
|
2473
|
+
},
|
|
2474
|
+
async createRef(commit) {
|
|
2475
|
+
return refWrite(
|
|
2476
|
+
await call('POST', 'git/refs', {
|
|
2477
|
+
ref: `refs/heads/${FRAMES_BRANCH}`,
|
|
2478
|
+
sha: commit,
|
|
2479
|
+
}),
|
|
2480
|
+
)
|
|
2481
|
+
},
|
|
2482
|
+
async moveRef(commit, force) {
|
|
2483
|
+
return refWrite(await call('PATCH', ref, { sha: commit, force }))
|
|
2484
|
+
},
|
|
2485
|
+
async deleteRef() {
|
|
2486
|
+
const gone = await call('DELETE', ref)
|
|
2487
|
+
return gone.ok || gone.status === 422
|
|
2488
|
+
},
|
|
2489
|
+
async readPull(number) {
|
|
2490
|
+
const read = await call('GET', `pulls/${number}`)
|
|
2491
|
+
if (!read.ok) return undefined
|
|
2492
|
+
const row = read.data as { state?: unknown; closed_at?: unknown } | null
|
|
2493
|
+
if (row?.state !== 'open' && row?.state !== 'closed') return undefined
|
|
2494
|
+
return {
|
|
2495
|
+
state: row.state,
|
|
2496
|
+
...(typeof row.closed_at === 'string' && { closedAt: row.closed_at }),
|
|
2497
|
+
}
|
|
2498
|
+
},
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/** Validates the mode and its arguments before anything reads the network. */
|
|
2503
|
+
function framesArgumentRefusal(
|
|
2504
|
+
number: string | undefined,
|
|
2505
|
+
opts: FramesOptions,
|
|
2506
|
+
): FramesCommandRefusal | undefined {
|
|
2507
|
+
const modes = [
|
|
2508
|
+
opts.add !== undefined,
|
|
2509
|
+
opts.drop === true,
|
|
2510
|
+
opts.prune !== undefined,
|
|
2511
|
+
]
|
|
2512
|
+
if (modes.filter(Boolean).length !== 1) return 'bad-mode'
|
|
2513
|
+
if (opts.prune !== undefined) {
|
|
2514
|
+
if (number !== undefined) return 'bad-mode'
|
|
2515
|
+
return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
|
|
2516
|
+
}
|
|
2517
|
+
if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
|
|
2518
|
+
if (
|
|
2519
|
+
opts.add !== undefined &&
|
|
2520
|
+
(opts.box === undefined || !POSITIVE_WHOLE.test(opts.box))
|
|
2521
|
+
) {
|
|
2522
|
+
return 'bad-box'
|
|
2523
|
+
}
|
|
2524
|
+
if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
|
|
2525
|
+
if (
|
|
2526
|
+
opts.add !== undefined &&
|
|
2527
|
+
(opts.pass === undefined || !isFramePass(opts.pass))
|
|
2528
|
+
) {
|
|
2529
|
+
return 'bad-pass'
|
|
2530
|
+
}
|
|
2531
|
+
return undefined
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
async function readRepoName(root: string): Promise<string | undefined> {
|
|
2535
|
+
const row = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
|
|
2536
|
+
if (row === null) return undefined
|
|
2537
|
+
try {
|
|
2538
|
+
const name = (JSON.parse(row) as { nameWithOwner?: unknown }).nameWithOwner
|
|
2539
|
+
return typeof name === 'string' ? name : undefined
|
|
2540
|
+
} catch {
|
|
2541
|
+
return undefined
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
async function runFrames(
|
|
2546
|
+
number: string | undefined,
|
|
2547
|
+
opts: FramesOptions,
|
|
2548
|
+
): Promise<number> {
|
|
2549
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
2550
|
+
const emitJson = opts.json ?? false
|
|
2551
|
+
const refuseFrames = (reason: FramesCommandRefusal) =>
|
|
2552
|
+
refuseWith(reason, FRAMES_REFUSALS[reason], emitJson, root)
|
|
2553
|
+
|
|
2554
|
+
intro('canon pr frames')
|
|
2555
|
+
|
|
2556
|
+
const invalid = framesArgumentRefusal(number, opts)
|
|
2557
|
+
if (invalid !== undefined) return refuseFrames(invalid)
|
|
2558
|
+
|
|
2559
|
+
const bytes =
|
|
2560
|
+
opts.add === undefined ? undefined : await readFrame(resolve(opts.add))
|
|
2561
|
+
if (opts.add !== undefined && bytes === undefined) {
|
|
2562
|
+
return refuseFrames('unreadable-frame')
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
if (Bun.which('gh') === null) return refuseFrames('gh-missing')
|
|
2566
|
+
const runner = ghApiRunner(root)
|
|
2567
|
+
const api = ghFramesApi(runner.call)
|
|
2568
|
+
const refuseWrite = (reason: FramesRefusal) =>
|
|
2569
|
+
refuseFrames(
|
|
2570
|
+
reason === 'push-failed' && runner.refusedWrites.length > 0
|
|
2571
|
+
? 'read-only'
|
|
2572
|
+
: reason,
|
|
2573
|
+
)
|
|
2574
|
+
|
|
2575
|
+
const finish = (
|
|
2576
|
+
record: Record<string, unknown>,
|
|
2577
|
+
lines: readonly string[],
|
|
2578
|
+
): number => {
|
|
2579
|
+
logStep('Frames')
|
|
2580
|
+
for (const line of lines) logInfo(line)
|
|
2581
|
+
outro()
|
|
2582
|
+
if (emitJson) {
|
|
2583
|
+
process.stdout.write(
|
|
2584
|
+
`${JSON.stringify({ root, reason: 'ok', ...record })}\n`,
|
|
2585
|
+
)
|
|
2586
|
+
}
|
|
2587
|
+
return 0
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
if (opts.prune !== undefined) {
|
|
2591
|
+
const days = Number(opts.prune)
|
|
2592
|
+
const result = await pruneFrames(api, days, new Date())
|
|
2593
|
+
if (result.kind === 'refused') return refuseWrite(result.reason)
|
|
2594
|
+
const { kind: _kind, ...rest } = result
|
|
2595
|
+
return finish({ days, ...rest }, [
|
|
2596
|
+
`removed ${plural(result.removed.length, 'pull request')}`,
|
|
2597
|
+
...(result.unread.length > 0
|
|
2598
|
+
? [`kept ${result.unread.map((n) => `#${n}`).join(', ')} unread`]
|
|
2599
|
+
: []),
|
|
2600
|
+
])
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const pull = Number(number)
|
|
2604
|
+
if (opts.drop === true) {
|
|
2605
|
+
const result = await dropFrames(api, [pull])
|
|
2606
|
+
if (result.kind === 'refused') return refuseWrite(result.reason)
|
|
2607
|
+
const { kind: _kind, ...rest } = result
|
|
2608
|
+
return finish({ number: pull, ...rest }, [
|
|
2609
|
+
result.removed.length === 0
|
|
2610
|
+
? `#${pull} holds no frames on ${FRAMES_BRANCH}`
|
|
2611
|
+
: `dropped the frames of #${pull}`,
|
|
2612
|
+
])
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
const repo = await readRepoName(root)
|
|
2616
|
+
if (repo === undefined) return refuseFrames('gh-failed')
|
|
2617
|
+
|
|
2618
|
+
let head = opts.head
|
|
2619
|
+
if (head === undefined) {
|
|
2620
|
+
const identity = await readIdentity(root, String(pull))
|
|
2621
|
+
if (identity.kind === 'refused') return refuseFrames('gh-failed')
|
|
2622
|
+
head = identity.identity.head
|
|
2623
|
+
}
|
|
2624
|
+
if (head === undefined || head === '') return refuseFrames('no-object-head')
|
|
2625
|
+
if (!isFrameHead(head)) return refuseFrames('bad-head')
|
|
2626
|
+
|
|
2627
|
+
const box = Number(opts.box)
|
|
2628
|
+
const pass = opts.pass ?? ''
|
|
2629
|
+
const result = await pushFrame(api, {
|
|
2630
|
+
number: pull,
|
|
2631
|
+
head,
|
|
2632
|
+
pass,
|
|
2633
|
+
box,
|
|
2634
|
+
bytes: bytes ?? new Uint8Array(),
|
|
2635
|
+
})
|
|
2636
|
+
if (result.kind === 'refused') return refuseWrite(result.reason)
|
|
2637
|
+
const link = frameLink(repo, result.path)
|
|
2638
|
+
return finish(
|
|
2639
|
+
{
|
|
2640
|
+
number: pull,
|
|
2641
|
+
box,
|
|
2642
|
+
head,
|
|
2643
|
+
pass,
|
|
2644
|
+
path: result.path,
|
|
2645
|
+
commit: result.commit,
|
|
2646
|
+
link,
|
|
2647
|
+
created: result.created,
|
|
2648
|
+
},
|
|
2649
|
+
[link],
|
|
2650
|
+
)
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2201
2653
|
/**
|
|
2202
2654
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
2203
2655
|
* so an operator reading the terminal sees the reason rather than a command
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -34,13 +34,13 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const SANDBOX_DIR = join(PROJECT_ROOT, '
|
|
37
|
+
const SANDBOX_DIR = join(PROJECT_ROOT, 'sandbox')
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Reports that the scenario tree does not ship, and answers whether it reported,
|
|
41
41
|
* so a caller that sees `true` returns without touching the tree.
|
|
42
42
|
*
|
|
43
|
-
* `
|
|
43
|
+
* `sandbox` sits outside the published package, so an installed
|
|
44
44
|
* `canon` resolves `SANDBOX_DIR` to a directory that is not there. Both entry
|
|
45
45
|
* points that walk the tree ask here rather than carrying a check each, because
|
|
46
46
|
* absence is a property of the install rather than of a verb, and a second copy
|
|
@@ -122,8 +122,11 @@ const QUANTIFIES_PATH = new RegExp(
|
|
|
122
122
|
'i',
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
125
|
+
/**
|
|
126
|
+
* A code span naming a check this repository could run: a shell script, or a
|
|
127
|
+
* gate module whose measures read the tree in-process.
|
|
128
|
+
*/
|
|
129
|
+
const SCRIPT_SPAN = /^(?:scripts\/[\w./-]+\.sh|src\/gate\/[\w./-]+\.ts)$/
|
|
127
130
|
/** A code span invoking the CLI, which may or may not name a registered audit. */
|
|
128
131
|
const CANON_SPAN = /^canon\s+(.+)$/
|
|
129
132
|
|
package/src/context/citations.ts
CHANGED
|
@@ -60,7 +60,7 @@ function ignoredCitations(line: string): IgnoredCitations | undefined {
|
|
|
60
60
|
* eval harness names paths in its target project. Neither is a reference into
|
|
61
61
|
* this repository, so an unresolved path there is correct rather than stale.
|
|
62
62
|
*/
|
|
63
|
-
const FIXTURE_TREES: readonly string[] = ['
|
|
63
|
+
const FIXTURE_TREES: readonly string[] = ['sandbox/', 'scripts/eval/']
|
|
64
64
|
|
|
65
65
|
const FIXTURE_SEGMENTS: readonly string[] = ['fixtures', '__fixtures__']
|
|
66
66
|
|