@erclx/aitk 3.55.0 → 3.57.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.55.0",
4
+ "version": "3.57.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -114,7 +114,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
114
114
  4. Hand off. Read `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md` and follow it: check the branch is unclaimed, check the row's file set against every track in flight, then dispatch a background worker with `claude --bg`. Fall back to the human-launch line it replaces when the check refuses, the sets overlap, or a stated reason serializes the row behind something already out.
115
115
  5. Review the PR. When a worker opens a PR, run `claude-pr-review` to post findings to it. This is the deep, independent pass. The worker's autoship self-review was only the green gate.
116
116
  - Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and start the poll it carries on the first dispatch rather than checking the board by hand. That runbook holds the routing, and a summary of it here is a second source that drifts from it.
117
- 6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Re-review when the answer lands, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
117
+ 6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Re-review when the worker's own message says the address pass finished, per the channel `claude-worker` states, rather than polling for an answer nothing else marks as landed. Then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
118
118
  - Read the threshold off `claude-pr-review`, which states it once and governs the heading with it, so an open heading and an owed dispatch answer the same question and either one is enough to send
119
119
  - Resolve the target at the moment of sending with `aitk sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 5 routes on the count and the confidence it answers with
120
120
  - Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
@@ -81,6 +81,48 @@ The last bullet is the one under pressure, since the dispatch that first proved
81
81
 
82
82
  Report the dispatch as loudly as the human-launch line it replaces: name the branch, the model, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
83
83
 
84
+ ## Dispatch to address a review
85
+
86
+ `claude-address-review` is a single pass, not a chain, so a launch naming it
87
+ alone reaches no `claude-worker` and takes no role, which owes no message
88
+ either. `#1251`'s replacement session was launched that way, onto the branch a
89
+ review had already posted findings against, and it answered by posting a
90
+ thread reply and telling its controller nothing. Reach the role directly on
91
+ this launch instead of wrapping a second chain around one skill that has none
92
+ of its own.
93
+
94
+ The branch already exists here, opened by whatever built it, so this shape
95
+ skips the plan-derived name the build shape resolves above. Take `<branch>`
96
+ off the pull request's own head ref. Enter the worktree the original build
97
+ left on disk, `.claude/worktrees/<slug>/`, with `EnterWorktree`'s `path` form
98
+ when it is still there, or `git worktree add .claude/worktrees/<slug>/
99
+ <branch>` when it was cleaned up, so `<slug>` is that directory name either
100
+ way.
101
+
102
+ `EnterWorktree` refuses that path in the ordinary case, because the original
103
+ build session stays registered against its own worktree after going idle and
104
+ holds a harness-level lock the roster does not report. Work in the folder
105
+ directly with `Bash`, `Read`, and `Edit` instead of retrying the tool, which
106
+ is the route two workers already took today on two different branches.
107
+
108
+ ```bash
109
+ claude --bg --model <model> -n "worker-<slug>" "Enter the worktree for <branch> at .claude/worktrees/<slug>/, creating it from that branch if the folder is gone. Run /aitk:claude-worker, then /aitk:claude-address-review. Your controller is the session whose sessionId is <dispatcher-id>. Resolve its current name from that id at the moment you send, and never resolve an addressee by name prefix. Message it when the address pass finishes, carrying what was addressed and the PR's CI state, and message it again if you stop on a question."
110
+ ```
111
+
112
+ `<dispatcher-id>` and `<model>` resolve the same way the build shape resolves
113
+ them above.
114
+
115
+ Take this shape wherever a review needs answering and no live session already
116
+ holds the branch. Where one does, message it to run `claude-address-review`
117
+ instead, per the loop's own step 6, since a session already there needs no
118
+ second one dispatched onto the same branch.
119
+
120
+ That check is blind to a session working through the direct-path fallback
121
+ above, since a session that never runs `EnterWorktree` never moves its
122
+ registered branch off `main`, so `aitk sessions list --branch` reports nothing
123
+ holding it. A dispatch landing on a branch worked that way collides with
124
+ nothing the check can see.
125
+
84
126
  ## Fall back to the human
85
127
 
86
128
  Hand the row to the human-launch line in step 4 instead of dispatching when any of these hold, and name which one: the collision check refused, the row's file set overlaps a track already out, or a stated reason holds the row behind one.
@@ -34,10 +34,11 @@ running and re-invoking it would restart the build.
34
34
 
35
35
  ## The channel
36
36
 
37
- The controlling session cannot watch this one build, so two messages are owed and
38
- nothing else.
37
+ The controlling session cannot watch this one build, so three messages are owed
38
+ and nothing else.
39
39
 
40
40
  - Announce the pull request as the ship chain's pull request step returns, carrying the number, the branch, and the task it closes. That transition is the one moment only this session knows, and the controller's review poll no longer starts on a dispatch because of it.
41
+ - Announce when an address-review pass finishes, as `claude-address-review` Step 8 returns, carrying what was addressed and the PR's new CI state. That transition is the other moment only this session knows, and it is what tells the controller to re-review rather than leaving it to poll for an answer nothing marks as landed.
41
42
  - Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so a relayed answer arrives under the open question and changes nothing.
42
43
  - Send nothing on progress. A worker reporting progress rebuilds, on this side of the channel, the poll the announcement retired on the other.
43
44
 
@@ -41,6 +41,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
41
41
  | `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
42
42
  | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
43
43
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
44
+ | `aitk migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
44
45
  | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
45
46
  | `aitk worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
46
47
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
@@ -94,13 +94,19 @@ reply that changes an outcome, such as a worker naming the plan question that
94
94
  already declined a finding, still belongs back on the PR, since the session
95
95
  holding it ends and the thread is what a later reader opens.
96
96
 
97
- Two messages travel the other way and the worker owes both. It announces its own
98
- pull request as the ship chain opens one, naming the number, the branch, and the
99
- task it closes. That is the one transition only that session can observe, since
100
- a worker that finishes goes idle rather than exiting, and one sat unnoticed for
101
- eighteen minutes before the announcement existed.
102
-
103
- The second reports a block before that block becomes an interactive prompt. A
97
+ Three messages travel the other way and the worker owes all three. It announces
98
+ its own pull request as the ship chain opens one, naming the number, the
99
+ branch, and the task it closes. That is the one transition only that session
100
+ can observe, since a worker that finishes goes idle rather than exiting, and
101
+ one sat unnoticed for eighteen minutes before the announcement existed.
102
+
103
+ It announces again when an address-review pass finishes, naming what it
104
+ addressed and the pull request's new CI state. That transition is the other
105
+ one only the worker can observe, and it replaces the same idle-poll gap on the
106
+ review's return leg, after a worker once addressed a posted review by
107
+ answering on the thread alone and telling its controller nothing.
108
+
109
+ The third reports a block before that block becomes an interactive prompt. A
104
110
  queued message drains at the next tool round and a session already waiting on
105
111
  input never reaches one, so an answer relayed to an open prompt renders beneath
106
112
  the question and changes nothing. Nothing is sent on progress, which would
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.55.0",
4
+ "version": "3.57.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ import { register as teach } from '@/commands/teach'
26
26
  import { register as comments } from '@/commands/comments'
27
27
  import { register as context } from '@/commands/context'
28
28
  import { register as markdown } from '@/commands/markdown'
29
+ import { register as migrate } from '@/commands/migrate'
29
30
  import { register as records } from '@/commands/records'
30
31
  import { register as sessions } from '@/commands/sessions'
31
32
  import { register as worktrees } from '@/commands/worktrees'
@@ -182,6 +183,7 @@ comments(program)
182
183
  context(program)
183
184
  markdown(program)
184
185
  records(program)
186
+ migrate(program)
185
187
  sessions(program)
186
188
  targets(program)
187
189
  worktrees(program)
@@ -0,0 +1,175 @@
1
+ import type { Command } from 'commander'
2
+ import { listRepositoryFiles } from '@/git-files'
3
+ import { applyRename, readSources } from '@/migrate/apply'
4
+ import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
5
+ import { logError, logInfo, logStep, logWarn, pipeOutput, plural } from '@/ui'
6
+
7
+ interface RenameOptions {
8
+ readonly json?: boolean
9
+ readonly write?: boolean
10
+ readonly root?: string
11
+ readonly scope?: string
12
+ }
13
+
14
+ const SCOPES = ['self', 'target'] as const
15
+
16
+ type Scope = (typeof SCOPES)[number]
17
+
18
+ function isScope(value: string): value is Scope {
19
+ return (SCOPES as readonly string[]).includes(value)
20
+ }
21
+
22
+ /**
23
+ * Reports rather than writes without `--write`, matching `aitk records
24
+ * migrate`. A rename touching this many files has no undo short of the branch
25
+ * it ran on, so the safe outcome sits on the default path.
26
+ */
27
+ async function runRename(opts: RenameOptions): Promise<number> {
28
+ const root = opts.root ?? process.cwd()
29
+ const scope = opts.scope ?? 'self'
30
+
31
+ if (!isScope(scope)) {
32
+ logError(`Unknown scope ${scope}. Use one of ${SCOPES.join(', ')}.`)
33
+ return 1
34
+ }
35
+
36
+ const files = await listRepositoryFiles(root)
37
+ if (files === undefined) {
38
+ logError(`Could not list files under ${root}. Is it a git repository?`)
39
+ return 1
40
+ }
41
+
42
+ const scoped = scope === 'target' ? files.filter(isToolkitOwned) : files
43
+ const sources = await readSources(root, scoped)
44
+ const plan = planRename(sources)
45
+
46
+ // A target scope reports the citations it did not rewrite, since prose the
47
+ // project wrote is theirs to change and a sweep editing it underneath them
48
+ // is the failure this scope exists to avoid.
49
+ const citations =
50
+ scope === 'target'
51
+ ? planRename(
52
+ await readSources(
53
+ root,
54
+ files.filter((f) => !isToolkitOwned(f)),
55
+ ),
56
+ ).entries.length
57
+ : 0
58
+
59
+ if (opts.json) {
60
+ pipeOutput(
61
+ JSON.stringify(toRecord(plan, scope, citations, opts.write), null, 2),
62
+ )
63
+ }
64
+
65
+ report(plan, scope, citations)
66
+
67
+ if (plan.entries.length === 0) return 0
68
+ if (!opts.write) {
69
+ logWarn('Nothing was written. Pass --write to apply this plan.')
70
+ return 2
71
+ }
72
+
73
+ const applied = await applyRename(root, plan)
74
+ logStep(
75
+ `Rewrote ${plural(applied.written, 'file')} and moved ${plural(applied.moved, 'path')}.`,
76
+ )
77
+
78
+ if (applied.failed.length > 0) {
79
+ logError(`Could not move ${plural(applied.failed.length, 'path')}.`)
80
+ for (const path of applied.failed) logError(` ${path}`)
81
+ return 1
82
+ }
83
+
84
+ return 0
85
+ }
86
+
87
+ function report(plan: RenamePlan, scope: Scope, citations: number): void {
88
+ logInfo(`Scope ${scope}.`)
89
+ logInfo(
90
+ `${plural(plan.entries.length, 'file')} to change, ${plural(plan.renamed, 'occurrence')} to rewrite.`,
91
+ )
92
+ logInfo(`${plural(plan.moves, 'path')} to move.`)
93
+ logInfo(
94
+ `${plural(plan.protectedCount, 'occurrence')} protected and left alone.`,
95
+ )
96
+
97
+ if (plan.excluded.length > 0) {
98
+ logInfo(`Excluded: ${plan.excluded.join(', ')}.`)
99
+ }
100
+
101
+ if (citations > 0) {
102
+ logWarn(
103
+ `${plural(citations, 'file')} outside toolkit-owned folders still cite the old name. They are yours to change.`,
104
+ )
105
+ }
106
+ }
107
+
108
+ function toRecord(
109
+ plan: RenamePlan,
110
+ scope: Scope,
111
+ citations: number,
112
+ wrote: boolean | undefined,
113
+ ): unknown {
114
+ return {
115
+ scope,
116
+ wrote: wrote === true,
117
+ files: plan.entries.length,
118
+ renamed: plan.renamed,
119
+ moves: plan.moves,
120
+ protected: plan.protectedCount,
121
+ excluded: plan.excluded,
122
+ citations,
123
+ paths: plan.entries.map((entry) => ({
124
+ path: entry.path,
125
+ ...(entry.movesTo === undefined ? {} : { movesTo: entry.movesTo }),
126
+ renamed: entry.renamed,
127
+ })),
128
+ }
129
+ }
130
+
131
+ export function register(program: Command): void {
132
+ const migrate = program
133
+ .command('migrate')
134
+ .description('Move a project off the retired aitk name')
135
+ .helpOption('-h, --help', 'Show this help message')
136
+
137
+ migrate
138
+ .command('rename')
139
+ .description('Rewrite every unprotected aitk token to canon')
140
+ .helpOption('-h, --help', 'Show this help message')
141
+ .option('--json', 'Add a machine-readable record on stdout')
142
+ .option('--write', 'Apply the plan rather than reporting it')
143
+ .option(
144
+ '--root <path>',
145
+ 'Project root, defaulting to the working directory',
146
+ )
147
+ .option('--scope <scope>', `What to rewrite: ${SCOPES.join(', ')}`, 'self')
148
+ .addHelpText(
149
+ 'after',
150
+ [
151
+ '',
152
+ 'Scopes:',
153
+ ' self every tracked file, for the toolkit repository itself',
154
+ ' target toolkit-owned folders only, reporting the rest as citations',
155
+ '',
156
+ 'Exit codes:',
157
+ ' 0 nothing to rewrite, or --write applied the whole plan',
158
+ ' 1 refused, or a move failed',
159
+ ' 2 a plan exists and --write was not passed',
160
+ '',
161
+ 'The changelog is never rewritten. Its entries record what shipped',
162
+ 'under the old name, and GitHub redirects the links they carry.',
163
+ 'aitk-sandbox is a separate repository and is left alone.',
164
+ '',
165
+ 'Examples:',
166
+ ' aitk migrate rename',
167
+ ' aitk migrate rename --write',
168
+ ' aitk migrate rename --scope target --json',
169
+ '',
170
+ ].join('\n'),
171
+ )
172
+ .action(async (opts: RenameOptions) => {
173
+ process.exitCode = await runRename(opts)
174
+ })
175
+ }
@@ -0,0 +1,115 @@
1
+ import { $ } from 'bun'
2
+ import { readFile, mkdir, rmdir, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, relative } from 'node:path'
4
+ import { gitEnv } from '@/git-env'
5
+ import type { RenamePlan, RenameSource } from '@/migrate/plan'
6
+
7
+ /**
8
+ * A file whose bytes carry a NUL is read as binary and its content is left
9
+ * alone, which lets the path still move. Reporting the empty string rather
10
+ * than a flag keeps the planner pure: an unchanged content rewrite is already
11
+ * how it expresses "path only", so binary needs no branch of its own there.
12
+ */
13
+ export async function readSources(
14
+ root: string,
15
+ paths: readonly string[],
16
+ ): Promise<RenameSource[]> {
17
+ const sources: RenameSource[] = []
18
+
19
+ for (const path of paths) {
20
+ const bytes = await readFile(join(root, path)).catch(() => undefined)
21
+ if (bytes === undefined) continue
22
+
23
+ const binary = bytes.includes(0)
24
+ sources.push({ path, text: binary ? '' : bytes.toString('utf8') })
25
+ }
26
+
27
+ return sources
28
+ }
29
+
30
+ export interface ApplyResult {
31
+ readonly written: number
32
+ readonly moved: number
33
+ readonly failed: readonly string[]
34
+ }
35
+
36
+ /**
37
+ * Writes the plan. Content lands first and the move follows, so a failed move
38
+ * leaves the rewrite on a path that still exists rather than stranding content
39
+ * at a destination nothing points at yet.
40
+ *
41
+ * `git mv` rather than a filesystem rename, so the history follows the file and
42
+ * a reviewer reading the pull request sees a rename instead of a delete beside
43
+ * an add.
44
+ */
45
+ export async function applyRename(
46
+ root: string,
47
+ plan: RenamePlan,
48
+ ): Promise<ApplyResult> {
49
+ let written = 0
50
+ let moved = 0
51
+ const failed: string[] = []
52
+
53
+ for (const entry of plan.entries) {
54
+ if (entry.text !== undefined) {
55
+ await writeFile(join(root, entry.path), entry.text)
56
+ written += 1
57
+ }
58
+
59
+ if (entry.movesTo === undefined) continue
60
+
61
+ await mkdir(dirname(join(root, entry.movesTo)), { recursive: true })
62
+ const result = await $`git -C ${root} mv ${entry.path} ${entry.movesTo}`
63
+ .env(gitEnv())
64
+ .quiet()
65
+ .nothrow()
66
+
67
+ if (result.exitCode === 0) moved += 1
68
+ else failed.push(entry.path)
69
+ }
70
+
71
+ await pruneEmptyParents(
72
+ root,
73
+ plan.entries
74
+ .filter((entry) => entry.movesTo !== undefined)
75
+ .map((entry) => dirname(entry.path)),
76
+ )
77
+
78
+ return { written, moved, failed }
79
+ }
80
+
81
+ /**
82
+ * Removes the directories a move emptied.
83
+ *
84
+ * Git tracks files rather than directories, so moving the last file out of one
85
+ * leaves the directory on disk with nothing in it and nothing in the diff to
86
+ * say so. A renamed skill folder that keeps its old shell beside the new one
87
+ * reads to a person, and to a plugin loader walking the tree, as though the
88
+ * rename only half happened.
89
+ *
90
+ * `rmdir` refuses a directory that still holds anything, which is the guard:
91
+ * a folder with an untracked file in it stays, and the refusal is ignored
92
+ * rather than reported because it means the folder was not empty to begin
93
+ * with.
94
+ */
95
+ async function pruneEmptyParents(
96
+ root: string,
97
+ directories: readonly string[],
98
+ ): Promise<void> {
99
+ const deepestFirst = [...new Set(directories)].sort(
100
+ (left, right) => right.length - left.length,
101
+ )
102
+
103
+ for (const directory of deepestFirst) {
104
+ let current = directory
105
+
106
+ while (current !== '.' && !current.startsWith('..')) {
107
+ const removed = await rmdir(join(root, current))
108
+ .then(() => true)
109
+ .catch(() => false)
110
+ if (!removed) break
111
+
112
+ current = relative(root, join(root, current, '..'))
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ isExcludedPath,
3
+ renamePath,
4
+ renameText,
5
+ scanText,
6
+ } from '@/migrate/rename'
7
+
8
+ /** One tracked file, as the planner reads it. */
9
+ export interface RenameSource {
10
+ readonly path: string
11
+ readonly text: string
12
+ }
13
+
14
+ /**
15
+ * One file the sweep touches. `text` is absent when only the path moves, so a
16
+ * caller writing the plan can tell a content rewrite from a pure move and
17
+ * leave a binary file's bytes alone.
18
+ */
19
+ export interface RenameEntry {
20
+ readonly path: string
21
+ readonly movesTo?: string
22
+ readonly text?: string
23
+ readonly renamed: number
24
+ readonly protectedCount: number
25
+ }
26
+
27
+ export interface RenamePlan {
28
+ readonly entries: readonly RenameEntry[]
29
+ readonly excluded: readonly string[]
30
+ readonly renamed: number
31
+ readonly protectedCount: number
32
+ readonly moves: number
33
+ }
34
+
35
+ /**
36
+ * Folders a target receives from the toolkit and does not author.
37
+ *
38
+ * The rename may rewrite these in a consuming project, because their content
39
+ * came from here and a stale spelling in them is the toolkit's own defect.
40
+ * Everything else in a target is prose that project wrote, where a citation is
41
+ * reported for a person to decide rather than rewritten underneath them.
42
+ */
43
+ const TOOLKIT_OWNED: readonly string[] = [
44
+ '.claude/aitk/',
45
+ '.claude/canon/',
46
+ '.claude/rules/',
47
+ '.claude/tooling/',
48
+ ]
49
+
50
+ export function isToolkitOwned(path: string): boolean {
51
+ return TOOLKIT_OWNED.some((owned) => path.startsWith(owned))
52
+ }
53
+
54
+ /**
55
+ * What the sweep would do to a set of files.
56
+ *
57
+ * Pure, so the decision and the write are separable and the same plan can be
58
+ * reported or applied. A file whose content and path both stay put is dropped
59
+ * rather than carried as a no-op entry, which keeps the reported count equal
60
+ * to the number of files the sweep actually changes.
61
+ */
62
+ export function planRename(sources: readonly RenameSource[]): RenamePlan {
63
+ const entries: RenameEntry[] = []
64
+ const excluded: string[] = []
65
+
66
+ for (const source of sources) {
67
+ if (isExcludedPath(source.path)) {
68
+ excluded.push(source.path)
69
+ continue
70
+ }
71
+
72
+ const movesTo = renamePath(source.path)
73
+ const rewritten = renameText(source.text)
74
+ const counts = scanText(source.text)
75
+ const moved = movesTo !== source.path
76
+ const changed = rewritten !== source.text
77
+
78
+ if (!moved && !changed) continue
79
+
80
+ entries.push({
81
+ path: source.path,
82
+ ...(moved ? { movesTo } : {}),
83
+ ...(changed ? { text: rewritten } : {}),
84
+ renamed: counts.renamed,
85
+ protectedCount: counts.protectedCount,
86
+ })
87
+ }
88
+
89
+ return {
90
+ entries,
91
+ excluded,
92
+ renamed: total(entries, (entry) => entry.renamed),
93
+ protectedCount: total(entries, (entry) => entry.protectedCount),
94
+ moves: entries.filter((entry) => entry.movesTo !== undefined).length,
95
+ }
96
+ }
97
+
98
+ function total(
99
+ entries: readonly RenameEntry[],
100
+ read: (entry: RenameEntry) => number,
101
+ ): number {
102
+ return entries.reduce((sum, entry) => sum + read(entry), 0)
103
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The token rewrite behind the `aitk` to `canon` rename.
3
+ *
4
+ * The sweep is mechanical and its danger is entirely in what it must not
5
+ * touch, so the scanner is one pass with the protected forms tried first
6
+ * rather than a chain of replacements. A chain reprocesses its own output,
7
+ * which is how a protected form that contains the token gets rewritten by a
8
+ * later rule that cannot see it was already decided.
9
+ */
10
+
11
+ /**
12
+ * Forms carrying the token that name something other than this tool, matched
13
+ * ahead of the token itself so they pass through untouched.
14
+ *
15
+ * `aitk-sandbox` is a separate repository that is not being renamed. It has to
16
+ * win against the bare token, and it also has to win against the owner-scoped
17
+ * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
18
+ * `erclx/canon-sandbox` and name a repository that does not exist.
19
+ */
20
+ const PROTECTED = ['aitk-sandbox'] as const
21
+
22
+ /**
23
+ * Every spelling of the token, and what each becomes. Case is carried in the
24
+ * map rather than derived, because the uppercase form is an environment
25
+ * variable prefix and the title-case form is a heading word, and a derived
26
+ * transform would have to guess which convention it was looking at.
27
+ */
28
+ const REPLACEMENT: Readonly<Record<string, string>> = {
29
+ aitk: 'canon',
30
+ AITK: 'CANON',
31
+ Aitk: 'Canon',
32
+ }
33
+
34
+ /**
35
+ * One alternation so the engine decides each position once. Group 1 is a
36
+ * protected form and group 2 is a token to rewrite, and the protected branch
37
+ * sits first because a regex alternation is ordered.
38
+ */
39
+ const SCAN = new RegExp(
40
+ `(${PROTECTED.join('|')})|(${Object.keys(REPLACEMENT).join('|')})`,
41
+ 'g',
42
+ )
43
+
44
+ /**
45
+ * Files whose content is left alone entirely.
46
+ *
47
+ * The changelog is release history. Its entries record what shipped under the
48
+ * old name, so rewriting them falsifies the record, and the pull request links
49
+ * it carries keep resolving because GitHub redirects a renamed repository's
50
+ * old URLs.
51
+ *
52
+ * The sweep's own source is the other member, and it is not a preference. This
53
+ * module states the token map as literal keys, so rewriting it turns every key
54
+ * into its own replacement and leaves a rewriter that maps `canon` to `canon`
55
+ * and matches nothing. Its tests name both spellings on purpose for the same
56
+ * reason, and the command's help text documents the old name a caller is
57
+ * migrating off. Whatever these four files should say after the rename is
58
+ * written by hand, because the sweep cannot be the thing that decides it.
59
+ */
60
+ /**
61
+ * An eval result is a transcript. It records the commands a session actually
62
+ * ran and the paths it actually opened, under whatever name was current when
63
+ * the run happened, so rewriting one makes it testify to a session that never
64
+ * took place. The changelog is excluded for the same reason and differs only
65
+ * in living at a fixed path.
66
+ */
67
+ const EXCLUDED_PREFIXES: readonly string[] = [
68
+ 'src/migrate/',
69
+ 'scripts/eval/result-',
70
+ ]
71
+
72
+ /**
73
+ * The four test files below exist to prove the retired spellings still
74
+ * resolve, so both names appear in each on purpose. Rewriting one is worse
75
+ * than a broken test: the retired-variable case would collapse into a copy of
76
+ * the current-variable case beside it and keep passing, reporting coverage for
77
+ * a fallback nothing exercises any more.
78
+ */
79
+ const EXCLUDED_PATHS: readonly string[] = [
80
+ 'CHANGELOG.md',
81
+ 'src/commands/migrate.ts',
82
+ 'src/sync/stamp.test.ts',
83
+ 'src/targets/registry.test.ts',
84
+ 'src/targets/sweep.test.ts',
85
+ 'src/ui.test.ts',
86
+ ]
87
+
88
+ export interface ScanCount {
89
+ readonly renamed: number
90
+ readonly protectedCount: number
91
+ }
92
+
93
+ export function isExcludedPath(path: string): boolean {
94
+ if (EXCLUDED_PATHS.includes(path)) return true
95
+ return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
96
+ }
97
+
98
+ /**
99
+ * An indefinite article that agreed with the retired name and no longer
100
+ * agrees with the replacement.
101
+ *
102
+ * The old name opens on a vowel sound and the new one does not, so every
103
+ * `an aitk` in the corpus reads wrong the moment the token moves. This matches
104
+ * against the already-rewritten text rather than the source, which is what
105
+ * keeps it clear of the protected forms: `an aitk-sandbox` still says
106
+ * `aitk-sandbox` afterward, so it never matches here.
107
+ *
108
+ * The tail rejects a following letter rather than asking for a word boundary,
109
+ * which is what separates `an canonical` from `an CANON_STATE_DIR`. A boundary
110
+ * treats the underscore as part of the word and declines the environment
111
+ * variable, where the whole identifier is the token continuing.
112
+ */
113
+ const ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
114
+
115
+ /**
116
+ * Marks a line that names the retired spelling on purpose.
117
+ *
118
+ * A fallback path, a retired environment variable, and a dictionary entry
119
+ * covering the record corpora all have to keep saying the old name, and a
120
+ * second run over an already-renamed tree would otherwise strip exactly the
121
+ * compatibility this rename shipped. The marker sits on the line itself or on
122
+ * the one above it, which is the same placement `canon-allow-superseded`
123
+ * already uses in this repository.
124
+ */
125
+ const KEEP_MARKER = 'canon-keep-retired'
126
+
127
+ /** Rewrites every unprotected spelling of the token. */
128
+ export function renameText(text: string): string {
129
+ const lines = text.split('\n')
130
+ const rewritten = lines.map((line, index) =>
131
+ isKept(lines, index) ? line : renameLine(line),
132
+ )
133
+
134
+ return rewritten.join('\n')
135
+ }
136
+
137
+ function isKept(lines: readonly string[], index: number): boolean {
138
+ if (lines[index]?.includes(KEEP_MARKER)) return true
139
+ return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
140
+ }
141
+
142
+ function renameLine(line: string): string {
143
+ const replaced = line.replace(SCAN, (match, guarded: string | undefined) =>
144
+ guarded === undefined ? REPLACEMENT[match] : guarded,
145
+ )
146
+
147
+ return replaced.replace(
148
+ ARTICLE,
149
+ (_match, article: string, gap: string, token: string) =>
150
+ `${article}${gap}${token}`,
151
+ )
152
+ }
153
+
154
+ /**
155
+ * What `renameText` would do, without doing it. Reported rather than inferred
156
+ * from a diff so a run can say how much it protected, which is the number a
157
+ * reader needs to trust that the exclusions fired at all.
158
+ */
159
+ export function scanText(text: string): ScanCount {
160
+ let renamed = 0
161
+ let protectedCount = 0
162
+ const lines = text.split('\n')
163
+
164
+ for (const [index, line] of lines.entries()) {
165
+ if (isKept(lines, index)) continue
166
+
167
+ for (const [, guarded] of line.matchAll(SCAN)) {
168
+ if (guarded === undefined) renamed += 1
169
+ else protectedCount += 1
170
+ }
171
+ }
172
+
173
+ return { renamed, protectedCount }
174
+ }
175
+
176
+ /**
177
+ * The path a file moves to. Runs the same scanner as the content rewrite, so
178
+ * a protected form appearing in a path is protected there too and the two
179
+ * halves of the sweep cannot disagree about what the token means.
180
+ */
181
+ export function renamePath(path: string): string {
182
+ return renameText(path)
183
+ }