@erclx/aitk 3.47.1 → 3.48.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/claude-address-review/SKILL.md +3 -0
- package/claude/skills/claude-diagram/SKILL.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +3 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +3 -1
- package/claude/skills/git-split/SKILL.md +1 -1
- package/claude/skills/git-worktree/REQUIREMENT.md +2 -1
- package/claude/skills/git-worktree/SKILL.md +9 -2
- package/docs/agents/commands.md +1 -0
- package/package.json +1 -1
- package/scripts/lib/sandbox-path.sh +31 -2
- package/scripts/lib/ui.sh +9 -3
- package/src/commands/labels.ts +195 -0
- package/src/commands/sandbox.ts +1 -24
- package/src/labels/phase.ts +95 -0
- package/src/markdown/scan.ts +13 -2
- package/src/sandbox/tree.ts +47 -0
|
@@ -134,6 +134,9 @@ session when one did. Nothing tests the reply for either rule, since the scan
|
|
|
134
134
|
below reads it for banned characters and phase labels alone, so both halves hold
|
|
135
135
|
while a run applies them.
|
|
136
136
|
|
|
137
|
+
The `pull_request` check the git-pr surface carries reads a pull request's own
|
|
138
|
+
title and body, not a reply comment, so the scan below stays the only gate here.
|
|
139
|
+
|
|
137
140
|
When step 5 rebased the branch, say so in the summary sentence and name which
|
|
138
141
|
files were resolved by hand and which the regen rebuilt. The next review is a
|
|
139
142
|
full pass rather than a delta, and the reader is owed the reason.
|
|
@@ -82,7 +82,7 @@ A reader deciding whether the entry still holds starts from the code paths the e
|
|
|
82
82
|
|
|
83
83
|
Quote node labels containing spaces or special characters with double quotes (`A["Web shell"]`). Avoid parentheses inside labels, they break some renderers. Use `<br/>` for line breaks inside labels.
|
|
84
84
|
|
|
85
|
-
Run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the whole file, including inside mermaid `subgraph` labels and node text. The file lands in the repository rather than on a remote, so the scan's destination scoping leaves the character checks and not the phase-label one. A colon also works as a replacement inside a label. The pedagogical voice the diagram standard asks for is a yield on voice alone and buys no exemption from these bans.
|
|
85
|
+
Run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the whole file, including inside mermaid `subgraph` labels and node text. The file lands in the repository rather than on a remote, so the scan's destination scoping leaves the character checks and not the phase-label one, and the `pull_request` check the git-pr surface carries reads only a pull request's own title and body, never a file the diff carries. A colon also works as a replacement inside a label. The pedagogical voice the diagram standard asks for is a yield on voice alone and buys no exemption from these bans.
|
|
86
86
|
|
|
87
87
|
Nothing enforces the ban inside the diagram. The standards-audit hook toggles on a fence and skips every line within it, so a label carrying an em dash passes silently while the same character in the explanation paragraph below is caught. Check the labels by reading them. This is the one place in the file where the author is the only gate.
|
|
88
88
|
|
|
@@ -29,7 +29,7 @@ reader scanning the thread finds the current verdict where the last one sat.
|
|
|
29
29
|
|
|
30
30
|
## Step 1: resolve the PR and read context
|
|
31
31
|
|
|
32
|
-
Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title` for the current branch, or use a PR number the user names. The first seven characters of `headRefOid` are `<short-sha>`, which names the body file in Step 4.
|
|
32
|
+
Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names. The first seven characters of `headRefOid` are `<short-sha>`, which names the body file in Step 4.
|
|
33
33
|
|
|
34
34
|
Read these in parallel from the project root, skipping any that do not exist:
|
|
35
35
|
|
|
@@ -160,6 +160,8 @@ X critical, Y should-fix, Z minor. Reviewed against project docs and the board.
|
|
|
160
160
|
|
|
161
161
|
A stale ticked box goes in a `**PR body**` block, in place of a `**`path/to/file.ext`**` block and ahead of every one of those, since it precedes the code the diff carries rather than sitting inside it.
|
|
162
162
|
|
|
163
|
+
Run `aitk labels scan --title "<title>" --body "<body>" --head <headRefName>` against the PR under review, since Step 1 already holds all three and this pass is the last human-shaped gate before merge. A hit lands in the same `**PR body**` block, `should-fix`, naming each token the scan returns. This reads the pull request being reviewed, distinct from the comment this pass is about to post, which the scan later in this step still covers.
|
|
164
|
+
|
|
163
165
|
A later pass carrying findings keeps that shape and changes only the summary line:
|
|
164
166
|
|
|
165
167
|
```markdown
|
|
@@ -36,7 +36,7 @@ A missing tracking ref is no longer a guard. An open pull request proves the bra
|
|
|
36
36
|
- A body a person edited by hand between rounds gets no special handling: judge it against the tree the same way regardless of who wrote it last, since a hand-edit the fix commit has made stale is the exact drift this sync exists to close.
|
|
37
37
|
7. Route on the invocation and the comment count for the reply alone.
|
|
38
38
|
- When invoked with `reply-owned`, skip this step: the caller posts its own reply.
|
|
39
|
-
- Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first running the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against it, since the hook does not see an inline comment body.
|
|
39
|
+
- Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first running the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against it, since the hook does not see an inline comment body. The `pull_request` check the git-pr surface carries triggers on a push or an open rather than a plain edit, so neither this comment nor the title and body step 6 synced reaches it.
|
|
40
40
|
- If it is zero, nothing further runs. The sync in step 6 already did this branch's job.
|
|
41
41
|
|
|
42
42
|
## After completion
|
|
@@ -41,6 +41,8 @@ After outputting the preview, execute the final command immediately. Claude Code
|
|
|
41
41
|
|
|
42
42
|
Before running the final command, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the title and body. The title and body go straight to the remote with nothing checking them on the way, so this scan is the only gate. It covers the phase-label check as well as the characters, since both go to a reader who has no task board. It applies on top of the banned phrases in `${CLAUDE_SKILL_DIR}/../../standards/issue.md`.
|
|
43
43
|
|
|
44
|
+
The `pull_request` check the git-pr surface carries reads a pull request's own title and body alone, so an issue reaches no check behind this scan either.
|
|
45
|
+
|
|
44
46
|
### Final command
|
|
45
47
|
|
|
46
48
|
Map a bug to `--label bug` and a task to `--label enhancement`.
|
|
@@ -70,7 +70,9 @@ Leave a box unchecked only for the human-only cases the reference defines, and n
|
|
|
70
70
|
|
|
71
71
|
### Pre-publish scan
|
|
72
72
|
|
|
73
|
-
Before running the final command, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the PR title and body. The title and body go straight to the remote with nothing checking them on the way, so
|
|
73
|
+
Before running the final command, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against the PR title and body. The title and body go straight to the remote with nothing checking them on the way, so run the scan regardless of what backs it downstream. It covers the phase-label check as well as the characters, since both go to a reader who has no task board. It applies on top of the banned phrases in `${CLAUDE_SKILL_DIR}/../../standards/pr.md`.
|
|
74
|
+
|
|
75
|
+
A `pull_request` workflow job now backs the phase-label half for this repository, running `aitk labels scan` against the opened title and body. A project holding an older `aitk` carries no such job and reaches no check at all, so the scan above stays required rather than optional.
|
|
74
76
|
|
|
75
77
|
### Resolving the pull request
|
|
76
78
|
|
|
@@ -69,7 +69,7 @@ If Mode is Independent, append this line to the preview:
|
|
|
69
69
|
|
|
70
70
|
After outputting the preview, execute the final commands immediately. Claude Code's tool permission dialog is the confirmation gate. Do not wait for user input.
|
|
71
71
|
|
|
72
|
-
Before running them, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against every title and body. Each body stages under `.claude/.tmp/` and leaves through `gh`, so the hook sees neither and this scan is the only gate.
|
|
72
|
+
Before running them, run the scan in `${CLAUDE_SKILL_DIR}/../../standards/publish.md` against every title and body. Each body stages under `.claude/.tmp/` and leaves through `gh`, so the hook sees neither and this scan is the only gate. Each resulting branch reaches the `pull_request` check the git-pr surface carries once its own pull request opens, but not before, so this scan is what catches a leak ahead of that.
|
|
73
73
|
|
|
74
74
|
### Final commands
|
|
75
75
|
|
|
@@ -14,7 +14,7 @@ Without this skill, linked worktrees accumulate past the point where any of them
|
|
|
14
14
|
- Resolve merge state per branch from the pull request first, and fall back to local ancestry when no pull request exists
|
|
15
15
|
- Match the ancestry fallback against the branch name alone. `git branch --merged` decorates the current branch and every branch checked out in a linked worktree, which is the whole set this skill enumerates.
|
|
16
16
|
- Remove the worktree and its local branch together, since either one left alone is the state the skill exists to prevent
|
|
17
|
-
- Exclude
|
|
17
|
+
- Exclude five kinds of row from the remove set: the main root, the current session's worktree, any dirty tree, any worktree registered from outside the path prefix `claude-worktree` creates under, and any worktree a live session is occupying
|
|
18
18
|
- Give every skipped row a one-word reason, so the skip is a decision the user can overturn rather than a silence
|
|
19
19
|
- Pick exactly one mode. Listing and removing are different requests and inferring both from one invocation removes worktrees the user meant to read about.
|
|
20
20
|
|
|
@@ -22,6 +22,7 @@ Without this skill, linked worktrees accumulate past the point where any of them
|
|
|
22
22
|
|
|
23
23
|
- Enter or create a worktree. The description states the boundary so the model routes entry elsewhere rather than discovering it here.
|
|
24
24
|
- Remove a worktree with uncommitted work, whatever its merge state
|
|
25
|
+
- Remove a worktree a live session is occupying, whatever its merge state
|
|
25
26
|
- Remove the worktree the session is currently running in
|
|
26
27
|
- Emit anything after the result line
|
|
27
28
|
|
|
@@ -55,6 +55,10 @@ Determine current: the row whose `path` equals `git rev-parse --show-toplevel`.
|
|
|
55
55
|
|
|
56
56
|
Determine provenance: a non-main row is `foreign` when its `path` does not start with `<MAIN_ROOT>/.claude/worktrees/`. That prefix is the folder `claude-worktree` creates under, a convention of that skill rather than a fact this one owns. A tree an operator registered by hand anywhere else on disk reads as `foreign`. Step 1 has already marked the main row `main`, so it never reaches this test.
|
|
57
57
|
|
|
58
|
+
Determine occupancy: run `aitk sessions list --json` once for the whole enumeration, never once per row. Resolve each enumerated row's `path` and each live session's `worktree` field with `realpath` before comparing, since a session registered from a second clone of this repository reports a path under that clone rather than under `MAIN_ROOT`, and a raw string compare would hold nothing back. A row is `occupied` when a resolved `worktree` from any live session equals its resolved `path`. A resolved session `worktree` outside `MAIN_ROOT` names a different checkout, so it clears no row here and marks none as occupied.
|
|
59
|
+
|
|
60
|
+
The roster is unreadable when the command exits non-zero, when its JSON carries a `reason` field instead of a populated `sessions` array, or when `sessions` is non-empty but no entry carries a `worktree` key at all, which is the shape an older binary prints and cannot be told apart from a roster answering that nothing is occupied. `list` leaves the Notes column silent on that failure rather than changing behavior beyond it. `cleanup` refuses instead, stated in its own section below.
|
|
61
|
+
|
|
58
62
|
## `list` mode
|
|
59
63
|
|
|
60
64
|
Print the enumeration as a table, then stop. `list` has no final command.
|
|
@@ -64,7 +68,7 @@ Print the enumeration as a table, then stop. `list` has no final command.
|
|
|
64
68
|
| -- | ---------------------------------- | ----------------- | -------- | ------ | -------- |
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
Notes column shows the first that applies of `current`, `foreign`, `dirty`, or empty. Show paths relative to `MAIN_ROOT` (`.claude/worktrees/<name>`). A `foreign` row sits outside `MAIN_ROOT`, so print its path in full.
|
|
71
|
+
Notes column shows the first that applies of `current`, `occupied`, `foreign`, `dirty`, or empty. Show paths relative to `MAIN_ROOT` (`.claude/worktrees/<name>`). A `foreign` row sits outside `MAIN_ROOT`, so print its path in full.
|
|
68
72
|
|
|
69
73
|
After the table, append a one-line hint:
|
|
70
74
|
|
|
@@ -73,15 +77,18 @@ After the table, append a one-line hint:
|
|
|
73
77
|
|
|
74
78
|
## `cleanup` mode
|
|
75
79
|
|
|
80
|
+
If Enumeration marked the roster unreadable, stop: `❌ Session roster unreadable, so occupancy cannot be checked. Resolve the aitk sessions list failure, then re-run cleanup.` Hold every non-main row rather than computing the remove set from the other five tests alone, since removing a tree a live session holds is the failure this skill exists to prevent and a sweep that removes nothing costs one re-run.
|
|
81
|
+
|
|
76
82
|
From the enumeration, include a worktree in the remove set when all hold:
|
|
77
83
|
|
|
78
84
|
- Not the main row.
|
|
79
85
|
- Not the current session's worktree.
|
|
86
|
+
- Not occupied by a live session.
|
|
80
87
|
- Not a `foreign` tree.
|
|
81
88
|
- State is `merged` or `merged (local)`.
|
|
82
89
|
- Working tree is clean.
|
|
83
90
|
|
|
84
|
-
Every other non-main row goes to the skip set with a one-word reason, the first that applies in this order: `current`, `foreign`, `dirty`, `open`, `closed`, `unmerged`. A `foreign` tree that is also dirty reports `foreign`, since `dirty` is a state the operator can clear and `foreign` is not. Leading with the transient reason invites a commit or stash that changes nothing, after which the row reports `foreign` and holds back anyway.
|
|
91
|
+
Every other non-main row goes to the skip set with a one-word reason, the first that applies in this order: `current`, `occupied`, `foreign`, `dirty`, `open`, `closed`, `unmerged`. A `foreign` tree that is also dirty reports `foreign`, since `dirty` is a state the operator can clear and `foreign` is not. Leading with the transient reason invites a commit or stash that changes nothing, after which the row reports `foreign` and holds back anyway.
|
|
85
92
|
|
|
86
93
|
### Preview
|
|
87
94
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -58,6 +58,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
58
58
|
| `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
|
|
59
59
|
| `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
|
|
60
60
|
| `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
|
|
61
|
+
| `aitk labels scan` | Fail a pull request whose title or body carries a phase label, sorting a release pull request's tokens as semver rather than as a leak (`--event`, `--json`) |
|
|
61
62
|
| `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
|
|
62
63
|
| `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
|
|
63
64
|
| `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
|
package/package.json
CHANGED
|
@@ -10,15 +10,44 @@
|
|
|
10
10
|
# reports no writes at all. `scripts/eval/run.sh` keeps its fixture outside the
|
|
11
11
|
# repository for the same reason.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
13
|
+
# Mints a short random per-run identifier the first time it is asked for, then
|
|
14
|
+
# holds it in AITK_SANDBOX_RUN_ID for the rest of this process. A direct call
|
|
15
|
+
# (not `$(...)`) exports it into the caller's own shell, which is what lets
|
|
16
|
+
# `run.sh` mint once and have manage-sandbox.sh and the check it shells out to
|
|
17
|
+
# both inherit the same id as ordinary children. A call already carrying the
|
|
18
|
+
# variable, inherited from such a parent, reuses it rather than minting a new one.
|
|
19
|
+
#
|
|
20
|
+
# Twin of `mintSandboxRunId` in `src/commands/sandbox.ts`.
|
|
21
|
+
mint_sandbox_run_id() {
|
|
22
|
+
if [ -z "${AITK_SANDBOX_RUN_ID:-}" ]; then
|
|
23
|
+
AITK_SANDBOX_RUN_ID="$(date +%s)-$$-$RANDOM"
|
|
24
|
+
fi
|
|
25
|
+
export AITK_SANDBOX_RUN_ID
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# The base every per-run tree nests under, with no run id appended. Split out
|
|
29
|
+
# of `resolve_sandbox_dir` so a caller that needs to recognize any run's tree,
|
|
30
|
+
# such as `require_project_root` in `scripts/lib/ui.sh`, tests against this
|
|
31
|
+
# prefix instead of a single resolved path that changes on every call.
|
|
32
|
+
sandbox_dir_prefix() {
|
|
33
|
+
printf '%s/aitk/sandbox\n' "${XDG_STATE_HOME:-$HOME/.local/state}"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Twin of `sandboxTree` in `src/commands/sandbox.ts`. The exec boundary rules out
|
|
14
37
|
# a shared constant, so a change to the default lands on both sides.
|
|
38
|
+
#
|
|
39
|
+
# A bare fall-through with no per-run component is one path per machine, so
|
|
40
|
+
# two sessions resolving the default at once would provision over each other
|
|
41
|
+
# with neither told. `mint_sandbox_run_id` is what makes two such sessions
|
|
42
|
+
# land on two different trees rather than one.
|
|
15
43
|
resolve_sandbox_dir() {
|
|
16
44
|
if [ -n "${AITK_SANDBOX_DIR:-}" ]; then
|
|
17
45
|
printf '%s\n' "$AITK_SANDBOX_DIR"
|
|
18
46
|
return 0
|
|
19
47
|
fi
|
|
20
48
|
|
|
21
|
-
|
|
49
|
+
mint_sandbox_run_id
|
|
50
|
+
printf '%s-%s\n' "$(sandbox_dir_prefix)" "$AITK_SANDBOX_RUN_ID"
|
|
22
51
|
}
|
|
23
52
|
|
|
24
53
|
# Collapses repeated separators, folds `.` and `..` segments, and strips every
|
package/scripts/lib/ui.sh
CHANGED
|
@@ -115,9 +115,15 @@ guard_root() {
|
|
|
115
115
|
require_project_root() {
|
|
116
116
|
local GREEN RED YELLOW WHITE GREY NC
|
|
117
117
|
set_palette 2
|
|
118
|
-
local sandbox
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
local sandbox in_sandbox=0
|
|
119
|
+
if [ -n "${AITK_SANDBOX_DIR:-}" ]; then
|
|
120
|
+
sandbox="$AITK_SANDBOX_DIR"
|
|
121
|
+
[[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* ]] && in_sandbox=1
|
|
122
|
+
else
|
|
123
|
+
sandbox="$(sandbox_dir_prefix)"
|
|
124
|
+
[[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* || "$PWD" == "$sandbox"-* ]] && in_sandbox=1
|
|
125
|
+
fi
|
|
126
|
+
if [ "$in_sandbox" -eq 1 ]; then
|
|
121
127
|
echo -e "${GREY}┌${NC}" >&2
|
|
122
128
|
log_error "Execution restricted: Command cannot be run from inside the sandbox environment."
|
|
123
129
|
fi
|
package/src/commands/labels.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import { resolve } from 'node:path'
|
|
2
3
|
import type { Command } from 'commander'
|
|
3
4
|
import { type LabelAuditRefusal, auditLabels } from '@/labels/audit'
|
|
4
5
|
import { MAP_REL } from '@/labels/map'
|
|
6
|
+
import { scanPhaseLabels } from '@/labels/phase'
|
|
5
7
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
6
8
|
|
|
7
9
|
interface AuditOptions {
|
|
@@ -10,6 +12,14 @@ interface AuditOptions {
|
|
|
10
12
|
readonly json?: boolean
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
interface ScanOptions {
|
|
16
|
+
readonly event?: string
|
|
17
|
+
readonly title?: string
|
|
18
|
+
readonly body?: string
|
|
19
|
+
readonly head?: string
|
|
20
|
+
readonly json?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/** What a reader does about each way the audit produced no reading. */
|
|
14
24
|
const REFUSALS: Record<LabelAuditRefusal, string> = {
|
|
15
25
|
// An answer rather than a fault. A project declaring no map is labelled
|
|
@@ -76,6 +86,46 @@ export function register(program: Command): void {
|
|
|
76
86
|
.action(async (paths: string[], opts: AuditOptions) => {
|
|
77
87
|
process.exitCode = await runAudit(paths, opts)
|
|
78
88
|
})
|
|
89
|
+
|
|
90
|
+
labels
|
|
91
|
+
.command('scan')
|
|
92
|
+
.description(
|
|
93
|
+
'Fail a pull request whose title or body carries a phase label',
|
|
94
|
+
)
|
|
95
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
96
|
+
.option(
|
|
97
|
+
'--event <path>',
|
|
98
|
+
'GitHub pull_request event payload to read, such as $GITHUB_EVENT_PATH',
|
|
99
|
+
)
|
|
100
|
+
.option('--title <text>', 'Title to scan, overriding the event payload')
|
|
101
|
+
.option('--body <text>', 'Body to scan, overriding the event payload')
|
|
102
|
+
.option('--head <ref>', 'Head branch, overriding the event payload')
|
|
103
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
104
|
+
.addHelpText(
|
|
105
|
+
'after',
|
|
106
|
+
[
|
|
107
|
+
'',
|
|
108
|
+
'Reads aitk standards versioning for the two namespaces and sorts every',
|
|
109
|
+
'version-shaped token this pull request carries into the one the pull',
|
|
110
|
+
'request is allowed to hold. A release-please pull request, read off its',
|
|
111
|
+
'own fixed head branch and title, may carry semver references. Every',
|
|
112
|
+
'other pull request may carry neither, so any token found there is a',
|
|
113
|
+
'leaked phase label.',
|
|
114
|
+
'',
|
|
115
|
+
'Exit codes:',
|
|
116
|
+
' 0 no phase label found',
|
|
117
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
118
|
+
' 2 the title or body carries a phase label',
|
|
119
|
+
'',
|
|
120
|
+
'Examples:',
|
|
121
|
+
' aitk labels scan --event "$GITHUB_EVENT_PATH"',
|
|
122
|
+
' aitk labels scan --title "feat: x" --body "planned under v68.5" --head feat/x',
|
|
123
|
+
'',
|
|
124
|
+
].join('\n'),
|
|
125
|
+
)
|
|
126
|
+
.action(async (opts: ScanOptions) => {
|
|
127
|
+
process.exitCode = await runScan(opts)
|
|
128
|
+
})
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
async function runAudit(paths: string[], opts: AuditOptions): Promise<number> {
|
|
@@ -164,3 +214,148 @@ async function runAudit(paths: string[], opts: AuditOptions): Promise<number> {
|
|
|
164
214
|
|
|
165
215
|
return coverage.uncovered.length === 0 ? 0 : 2
|
|
166
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Why `runScan` had no title and body to hand `scanPhaseLabels`. */
|
|
219
|
+
type ScanInputRefusal = 'no-input' | 'unreadable-event' | 'not-a-pull-request'
|
|
220
|
+
|
|
221
|
+
type ResolvedScanInput =
|
|
222
|
+
| {
|
|
223
|
+
readonly kind: 'resolved'
|
|
224
|
+
readonly title: string
|
|
225
|
+
readonly body: string
|
|
226
|
+
readonly headRefName: string
|
|
227
|
+
}
|
|
228
|
+
| {
|
|
229
|
+
readonly kind: 'refused'
|
|
230
|
+
readonly reason: ScanInputRefusal
|
|
231
|
+
readonly message: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reads a title, a body, and a head branch from explicit flags first and the
|
|
236
|
+
* named event payload second, so a caller testing the wiring by hand never
|
|
237
|
+
* needs a real GitHub event file on disk.
|
|
238
|
+
*/
|
|
239
|
+
function resolveScanInput(opts: ScanOptions): ResolvedScanInput {
|
|
240
|
+
let title = opts.title
|
|
241
|
+
let body = opts.body
|
|
242
|
+
let headRefName = opts.head
|
|
243
|
+
|
|
244
|
+
if (opts.event !== undefined) {
|
|
245
|
+
let raw: string
|
|
246
|
+
try {
|
|
247
|
+
raw = readFileSync(opts.event, 'utf8')
|
|
248
|
+
} catch {
|
|
249
|
+
return {
|
|
250
|
+
kind: 'refused',
|
|
251
|
+
reason: 'unreadable-event',
|
|
252
|
+
message: `${opts.event} could not be read, so no payload was there to scan.`,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let payload: unknown
|
|
257
|
+
try {
|
|
258
|
+
payload = JSON.parse(raw)
|
|
259
|
+
} catch {
|
|
260
|
+
return {
|
|
261
|
+
kind: 'refused',
|
|
262
|
+
reason: 'unreadable-event',
|
|
263
|
+
message: `${opts.event} is not valid JSON, so no payload was there to scan.`,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const pullRequest =
|
|
268
|
+
typeof payload === 'object' && payload !== null
|
|
269
|
+
? (payload as Record<string, unknown>).pull_request
|
|
270
|
+
: undefined
|
|
271
|
+
|
|
272
|
+
if (typeof pullRequest !== 'object' || pullRequest === null) {
|
|
273
|
+
return {
|
|
274
|
+
kind: 'refused',
|
|
275
|
+
reason: 'not-a-pull-request',
|
|
276
|
+
message: `${opts.event} carries no pull_request, so no title or body exists to scan.`,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const record = pullRequest as Record<string, unknown>
|
|
281
|
+
const head = record.head
|
|
282
|
+
title ??= typeof record.title === 'string' ? record.title : undefined
|
|
283
|
+
body ??= typeof record.body === 'string' ? record.body : undefined
|
|
284
|
+
headRefName ??=
|
|
285
|
+
typeof head === 'object' &&
|
|
286
|
+
head !== null &&
|
|
287
|
+
typeof (head as Record<string, unknown>).ref === 'string'
|
|
288
|
+
? ((head as Record<string, unknown>).ref as string)
|
|
289
|
+
: undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (title === undefined) {
|
|
293
|
+
return {
|
|
294
|
+
kind: 'refused',
|
|
295
|
+
reason: 'no-input',
|
|
296
|
+
message:
|
|
297
|
+
'No --event, --title, or --body given, so there is nothing to scan.',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
kind: 'resolved',
|
|
303
|
+
title,
|
|
304
|
+
body: body ?? '',
|
|
305
|
+
headRefName: headRefName ?? '',
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runScan(opts: ScanOptions): Promise<number> {
|
|
310
|
+
const emitJson = opts.json ?? false
|
|
311
|
+
|
|
312
|
+
intro('aitk labels scan')
|
|
313
|
+
|
|
314
|
+
const resolved = resolveScanInput(opts)
|
|
315
|
+
|
|
316
|
+
if (resolved.kind === 'refused') {
|
|
317
|
+
logStep('Refused')
|
|
318
|
+
logWarn(resolved.message)
|
|
319
|
+
outro()
|
|
320
|
+
|
|
321
|
+
if (emitJson) {
|
|
322
|
+
process.stdout.write(
|
|
323
|
+
`${JSON.stringify({ reason: resolved.reason, message: resolved.message })}\n`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
return 1
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result = scanPhaseLabels(resolved)
|
|
330
|
+
|
|
331
|
+
logStep('Pull request')
|
|
332
|
+
logInfo(
|
|
333
|
+
result.cutsRelease
|
|
334
|
+
? 'reads as a release-please pull request'
|
|
335
|
+
: 'reads as an ordinary pull request',
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
logStep(result.phaseLabels.length === 0 ? 'Clean' : 'Phase label found')
|
|
339
|
+
if (result.phaseLabels.length === 0) {
|
|
340
|
+
logInfo('no phase label in the title or body')
|
|
341
|
+
} else {
|
|
342
|
+
logWarn(
|
|
343
|
+
`${plural(result.phaseLabels.length, 'phase label')} in the title or body. Describe the change itself and drop the internal label before publishing.`,
|
|
344
|
+
)
|
|
345
|
+
for (const label of result.phaseLabels) logWarn(label)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
outro()
|
|
349
|
+
|
|
350
|
+
if (emitJson) {
|
|
351
|
+
process.stdout.write(
|
|
352
|
+
`${JSON.stringify({
|
|
353
|
+
cutsRelease: result.cutsRelease,
|
|
354
|
+
phaseLabels: result.phaseLabels,
|
|
355
|
+
semverTags: result.semverTags,
|
|
356
|
+
})}\n`,
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return result.phaseLabels.length === 0 ? 0 : 2
|
|
361
|
+
}
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { homedir } from 'node:os'
|
|
3
2
|
import { join } from 'node:path'
|
|
4
3
|
import type { Command } from 'commander'
|
|
5
4
|
import { execScript } from '@/exec'
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
type RunEnvelope,
|
|
23
22
|
type Verdict,
|
|
24
23
|
} from '@/sandbox/expect'
|
|
24
|
+
import { sandboxTree } from '@/sandbox/tree'
|
|
25
25
|
import {
|
|
26
26
|
frameError,
|
|
27
27
|
intro,
|
|
@@ -64,29 +64,6 @@ function reportAbsentScenarioTree(): boolean {
|
|
|
64
64
|
return true
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* The provisioned tree, as opposed to `SANDBOX_DIR` above, which holds the
|
|
69
|
-
* scenario scripts. It sits outside the toolkit worktree so the toolkit's own
|
|
70
|
-
* `CLAUDE.md` stays off the ancestor chain of the session `run.sh` spawns with
|
|
71
|
-
* cwd here.
|
|
72
|
-
*
|
|
73
|
-
* Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
|
|
74
|
-
* boundary rules out a shared constant, so a change to the default lands on both
|
|
75
|
-
* sides.
|
|
76
|
-
*/
|
|
77
|
-
function sandboxTree(): string {
|
|
78
|
-
const override = process.env.AITK_SANDBOX_DIR
|
|
79
|
-
if (override !== undefined && override !== '') return override
|
|
80
|
-
|
|
81
|
-
const state = process.env.XDG_STATE_HOME
|
|
82
|
-
const base =
|
|
83
|
-
state !== undefined && state !== ''
|
|
84
|
-
? state
|
|
85
|
-
: join(homedir(), '.local', 'state')
|
|
86
|
-
|
|
87
|
-
return join(base, 'aitk', 'sandbox')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
67
|
/**
|
|
91
68
|
* Holds fixture content for scenarios rather than scenarios of its own.
|
|
92
69
|
* Twin of the `-not -name fixtures` filter in `scripts/manage-sandbox.sh`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two version namespaces `standards/versioning.md` keeps apart, and why a
|
|
5
|
+
* pull request cannot be sorted between them by pattern alone.
|
|
6
|
+
*
|
|
7
|
+
* A phase label (`v68.5`) and a semver reference (`v3.44`) both match
|
|
8
|
+
* `v<digits>(.<digits>){1,2}`, so a shape-only scan cannot tell them apart. The
|
|
9
|
+
* first measurement of this defect tried exactly that and returned 28 of 60
|
|
10
|
+
* correct. What separates the two is not the token but the pull request: a
|
|
11
|
+
* release-please pull request legitimately carries version numbers in its
|
|
12
|
+
* generated body, and every other pull request does not.
|
|
13
|
+
*/
|
|
14
|
+
export interface PhaseScanInput {
|
|
15
|
+
readonly title: string
|
|
16
|
+
readonly body: string
|
|
17
|
+
readonly headRefName: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PhaseScanResult {
|
|
21
|
+
/** Whether this pull request is release-please's own, per its fixed shape. */
|
|
22
|
+
readonly cutsRelease: boolean
|
|
23
|
+
readonly phaseLabels: readonly string[]
|
|
24
|
+
readonly semverTags: readonly string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VERSION_TOKEN = /\bv\d+(?:\.\d+){1,2}\b/g
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The head branch release-please opens every release pull request under.
|
|
31
|
+
*
|
|
32
|
+
* `release-please-config.json` names the package `aitk`, so the observed
|
|
33
|
+
* branch is `release-please--branches--main--components--aitk`. The prefix
|
|
34
|
+
* stops short of the component segment, since that segment moves with the
|
|
35
|
+
* package name and the branch segment does not.
|
|
36
|
+
*/
|
|
37
|
+
const RELEASE_HEAD_PREFIX = 'release-please--branches--main'
|
|
38
|
+
|
|
39
|
+
const RELEASE_TITLE_PREFIX = 'chore(main): release '
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Two fixed signals rather than one, because either alone is a string an
|
|
43
|
+
* author's own pull request could reproduce. A title starting with the
|
|
44
|
+
* release commit type is plain conventional-commit text, and a head branch is
|
|
45
|
+
* exactly what a PR renamed for review would want to imitate to slip a real
|
|
46
|
+
* phase label past this check. Together they name release-please's own act of
|
|
47
|
+
* opening the pull request, which nothing else on the remote can perform.
|
|
48
|
+
*/
|
|
49
|
+
function isReleasePullRequest(input: PhaseScanInput): boolean {
|
|
50
|
+
return (
|
|
51
|
+
input.headRefName.startsWith(RELEASE_HEAD_PREFIX) &&
|
|
52
|
+
input.title.startsWith(RELEASE_TITLE_PREFIX)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Drops a fenced block outright and blanks a code span inside what remains,
|
|
58
|
+
* so a token quoted rather than written is read the way a reader reads it:
|
|
59
|
+
* shown, not asserted.
|
|
60
|
+
*
|
|
61
|
+
* `aitk markdown audit` excludes a fenced block and a code span from its own
|
|
62
|
+
* ban scan over the same kind of text, and this reuses that reading rather
|
|
63
|
+
* than inventing a second one. `#1208` is the corpus case that forced it: a
|
|
64
|
+
* backticked span quoting a test fixture's own version-shaped name, which the
|
|
65
|
+
* shape-only scan below cannot tell from a leak on its own.
|
|
66
|
+
*
|
|
67
|
+
* A link destination stays unmasked, unlike the ban scan's own reading. A
|
|
68
|
+
* release-please body's real semver reference sits inside the generated
|
|
69
|
+
* compare link's URL, and masking it would empty `semverTags` on the one
|
|
70
|
+
* pull request this check exists to pass, trading the corpus's one code-span
|
|
71
|
+
* leak for a hole in every release.
|
|
72
|
+
*/
|
|
73
|
+
function readable(text: string): string {
|
|
74
|
+
return linesOutsideFences(text).map(maskCodeSpans).join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads a title and a body for version-shaped tokens and sorts every one
|
|
79
|
+
* found into the namespace this pull request is allowed to carry.
|
|
80
|
+
*
|
|
81
|
+
* The split runs on the pull request rather than on each token. A release
|
|
82
|
+
* pull request's tokens are read as the semver references its generated body
|
|
83
|
+
* legitimately carries, and every other pull request's tokens are read as
|
|
84
|
+
* leaked phase labels, which is what `standards/versioning.md` names the
|
|
85
|
+
* defect this exists to catch.
|
|
86
|
+
*/
|
|
87
|
+
export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
|
|
88
|
+
const text = readable(`${input.title}\n${input.body}`)
|
|
89
|
+
const tokens = [...new Set(text.match(VERSION_TOKEN) ?? [])]
|
|
90
|
+
const cutsRelease = isReleasePullRequest(input)
|
|
91
|
+
|
|
92
|
+
return cutsRelease
|
|
93
|
+
? { cutsRelease, phaseLabels: [], semverTags: tokens }
|
|
94
|
+
: { cutsRelease, phaseLabels: tokens, semverTags: [] }
|
|
95
|
+
}
|
package/src/markdown/scan.ts
CHANGED
|
@@ -150,6 +150,18 @@ function blank(match: string): string {
|
|
|
150
150
|
return ' '.repeat(match.length)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Blanks an inline code span alone, holding its width.
|
|
155
|
+
*
|
|
156
|
+
* Split from `maskDisplayed` for a reader who wants the narrower exclusion.
|
|
157
|
+
* A link destination often carries the one thing such a reader is after, such
|
|
158
|
+
* as a version-shaped token inside a generated compare link, where a code
|
|
159
|
+
* span is uniformly a quotation and never the claim itself.
|
|
160
|
+
*/
|
|
161
|
+
export function maskCodeSpans(text: string): string {
|
|
162
|
+
return text.replace(CODE_SPAN, blank)
|
|
163
|
+
}
|
|
164
|
+
|
|
153
165
|
/**
|
|
154
166
|
* Replaces displayed spans with spaces of equal width.
|
|
155
167
|
*
|
|
@@ -158,8 +170,7 @@ function blank(match: string): string {
|
|
|
158
170
|
* everything masked ahead of it on the line.
|
|
159
171
|
*/
|
|
160
172
|
export function maskDisplayed(text: string): string {
|
|
161
|
-
return text
|
|
162
|
-
.replace(CODE_SPAN, blank)
|
|
173
|
+
return maskCodeSpans(text)
|
|
163
174
|
.replace(LINK_DESTINATION, blank)
|
|
164
175
|
.replace(AUTOLINK, blank)
|
|
165
176
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mints a short per-run identifier the first time it is asked for and holds it
|
|
7
|
+
* in `AITK_SANDBOX_RUN_ID` for the rest of this process, so a script that
|
|
8
|
+
* spawns a child inheriting `process.env` — `run.sh` calling `manage-sandbox.sh`
|
|
9
|
+
* and then `aitk sandbox check` — resolves the same tree in every one of them.
|
|
10
|
+
* A process that already carries the variable, inherited from such a parent,
|
|
11
|
+
* reuses it rather than minting a new one.
|
|
12
|
+
*
|
|
13
|
+
* Twin of `mint_sandbox_run_id` in `scripts/lib/sandbox-path.sh`.
|
|
14
|
+
*/
|
|
15
|
+
export function mintSandboxRunId(): string {
|
|
16
|
+
const existing = process.env.AITK_SANDBOX_RUN_ID
|
|
17
|
+
if (existing !== undefined && existing !== '') return existing
|
|
18
|
+
|
|
19
|
+
const id = randomBytes(4).toString('hex')
|
|
20
|
+
process.env.AITK_SANDBOX_RUN_ID = id
|
|
21
|
+
return id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The provisioned tree's path, split out of `src/commands/sandbox.ts` so the
|
|
26
|
+
* per-run default is unit-testable on its own rather than only through the
|
|
27
|
+
* command's registration.
|
|
28
|
+
*
|
|
29
|
+
* Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
|
|
30
|
+
* boundary rules out a shared constant, so a change to the default lands on
|
|
31
|
+
* both sides. The fall-through used to be one path per machine, so two
|
|
32
|
+
* sessions each resolving the default at once provisioned over each other
|
|
33
|
+
* with neither told; `mintSandboxRunId` gives the path a per-run component
|
|
34
|
+
* instead, which is what makes two such sessions land on two different trees.
|
|
35
|
+
*/
|
|
36
|
+
export function sandboxTree(): string {
|
|
37
|
+
const override = process.env.AITK_SANDBOX_DIR
|
|
38
|
+
if (override !== undefined && override !== '') return override
|
|
39
|
+
|
|
40
|
+
const state = process.env.XDG_STATE_HOME
|
|
41
|
+
const base =
|
|
42
|
+
state !== undefined && state !== ''
|
|
43
|
+
? state
|
|
44
|
+
: join(homedir(), '.local', 'state')
|
|
45
|
+
|
|
46
|
+
return join(base, 'aitk', `sandbox-${mintSandboxRunId()}`)
|
|
47
|
+
}
|