@erclx/aitk 3.43.1 → 3.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/{toolkit-cli → aitk-cli}/REQUIREMENT.md +4 -4
- package/claude/skills/{toolkit-cli → aitk-cli}/SKILL.md +1 -1
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/SKILL.md +2 -2
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/SKILL.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/SKILL.md +3 -3
- package/claude/skills/{claude-screencast → aitk-screencast}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-screencast → aitk-screencast}/SKILL.md +2 -2
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/SKILL.md +1 -1
- package/claude/skills/{cli-script → bash-cli-script}/REQUIREMENT.md +2 -2
- package/claude/skills/{cli-script → bash-cli-script}/SKILL.md +2 -2
- package/claude/skills/bash-script/REQUIREMENT.md +2 -2
- package/claude/skills/bash-script/SKILL.md +2 -2
- package/claude/skills/ci-workflow/REQUIREMENT.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +2 -2
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +2 -2
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +1 -1
- package/claude/skills/git-ship/SKILL.md +1 -2
- package/claude/skills/git-worktree/REQUIREMENT.md +1 -1
- package/claude/skills/git-worktree/SKILL.md +7 -4
- package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
- package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
- package/claude/skills/setup-init/REQUIREMENT.md +1 -1
- package/claude/skills/setup-init/SKILL.md +1 -1
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/demo.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/overview.md +2 -2
- package/docs/agents/scripting.md +1 -1
- package/docs/agents/sessions.md +11 -5
- package/docs/agents/targets.md +83 -0
- package/docs/ai-workflow.md +16 -16
- package/docs/target-projects.md +1 -1
- package/governance/rules/lang/120-bash.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-tooling-paths.sh +1 -1
- package/scripts/core/verify.sh +1 -1
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/claude-workflow.ts +2 -2
- package/src/claude/cases/setup.ts +6 -6
- package/src/cli.ts +3 -0
- package/src/commands/demo.ts +1 -1
- package/src/commands/sessions.ts +24 -8
- package/src/commands/targets.ts +319 -0
- package/src/demo/beats.ts +1 -1
- package/src/sessions/claim.ts +7 -0
- package/src/sync/stamp.ts +9 -0
- package/src/targets/pulls.ts +250 -0
- package/src/targets/registry.ts +161 -0
- package/src/targets/resolve.ts +145 -0
- package/src/targets/sweep.ts +246 -0
- package/standards/issue.md +1 -1
- /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One project the toolkit has installed into, as the install recorded it.
|
|
7
|
+
*
|
|
8
|
+
* The path is the target root rather than its git directory, because the
|
|
9
|
+
* record is written by a sync that knows where it wrote and not by anything
|
|
10
|
+
* that resolved a repository. Whether two entries are one project is a
|
|
11
|
+
* question about their origins, which `src/targets/sweep.ts` answers.
|
|
12
|
+
*/
|
|
13
|
+
export interface TargetRecord {
|
|
14
|
+
readonly path: string
|
|
15
|
+
/** ISO stamp of the most recent sync that recorded this target. */
|
|
16
|
+
readonly stampedAt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An absent file and an empty one are separate answers, the same split
|
|
21
|
+
* `src/sessions/registry.ts` draws.
|
|
22
|
+
*
|
|
23
|
+
* The first means no sync has ever recorded a target on this machine, so the
|
|
24
|
+
* population is unknown and the sweep is the only reading available. The
|
|
25
|
+
* second means the file was read and holds no usable row, which a caller
|
|
26
|
+
* should be able to tell apart from a lookup that never ran.
|
|
27
|
+
*/
|
|
28
|
+
export type TargetRegistry =
|
|
29
|
+
| { readonly kind: 'absent'; readonly path: string }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'read'
|
|
32
|
+
readonly path: string
|
|
33
|
+
readonly targets: readonly TargetRecord[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface StoredRegistry {
|
|
37
|
+
readonly version: number
|
|
38
|
+
readonly targets: readonly TargetRecord[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const VERSION = 1
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the folder holding this machine's toolkit state.
|
|
45
|
+
*
|
|
46
|
+
* Twin of `sandboxTree` in `src/commands/sandbox.ts`, which resolves the same
|
|
47
|
+
* three sources in the same order. The override exists so a test never writes
|
|
48
|
+
* into the home directory of whoever runs it.
|
|
49
|
+
*/
|
|
50
|
+
export function stateDir(): string {
|
|
51
|
+
const override = process.env.AITK_STATE_DIR
|
|
52
|
+
if (override !== undefined && override !== '') return override
|
|
53
|
+
|
|
54
|
+
const state = process.env.XDG_STATE_HOME
|
|
55
|
+
const base =
|
|
56
|
+
state !== undefined && state !== ''
|
|
57
|
+
? state
|
|
58
|
+
: join(homedir(), '.local', 'state')
|
|
59
|
+
|
|
60
|
+
return join(base, 'aitk')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registryPath(): string {
|
|
64
|
+
return join(stateDir(), 'targets.json')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
|
|
68
|
+
return (
|
|
69
|
+
typeof value.path === 'string' &&
|
|
70
|
+
value.path.length > 0 &&
|
|
71
|
+
typeof value.stampedAt === 'string' &&
|
|
72
|
+
value.stampedAt.length > 0
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads every recorded target, sorted by path.
|
|
78
|
+
*
|
|
79
|
+
* A row missing either field is dropped rather than reported. This module is
|
|
80
|
+
* the file's only writer, so a malformed row is a hand edit or a truncated
|
|
81
|
+
* write and neither is a finding the caller can act on. What a caller can act
|
|
82
|
+
* on is the file being absent, which is its own kind above.
|
|
83
|
+
*/
|
|
84
|
+
export function readTargetRegistry(
|
|
85
|
+
path: string = registryPath(),
|
|
86
|
+
): TargetRegistry {
|
|
87
|
+
let text: string
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(path, 'utf8')
|
|
90
|
+
} catch {
|
|
91
|
+
return { kind: 'absent', path }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let parsed: unknown
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(text)
|
|
97
|
+
} catch {
|
|
98
|
+
return { kind: 'read', path, targets: [] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
102
|
+
return { kind: 'read', path, targets: [] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stored = parsed as Partial<StoredRegistry>
|
|
106
|
+
const rows = Array.isArray(stored.targets) ? stored.targets : []
|
|
107
|
+
const targets = rows
|
|
108
|
+
.filter((row): row is TargetRecord =>
|
|
109
|
+
isRecord(row as Partial<TargetRecord>),
|
|
110
|
+
)
|
|
111
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
112
|
+
|
|
113
|
+
return { kind: 'read', path, targets }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Why a record attempt did not land, so a caller can say so rather than assume it did. */
|
|
117
|
+
export type RecordOutcome = 'recorded' | 'unwritten'
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Records one target, keyed by its resolved path and replacing any row already
|
|
121
|
+
* held for it.
|
|
122
|
+
*
|
|
123
|
+
* The write is a temp file plus a rename, so a reader never meets a half
|
|
124
|
+
* written file. Two syncs finishing together still resolve last-writer-wins on
|
|
125
|
+
* the merged set, which can drop the row the loser added. That is left rather
|
|
126
|
+
* than locked: the authoritative record of an install is the stamp inside the
|
|
127
|
+
* target, this index is a cache over those, and the next sync of the dropped
|
|
128
|
+
* target restores its row.
|
|
129
|
+
*
|
|
130
|
+
* Nothing removes a row either, so a target that was deleted or that dropped
|
|
131
|
+
* the toolkit stays here and the count drifts upward. `aitk targets pulls`
|
|
132
|
+
* meets that on use, since it refuses a path it cannot open rather than
|
|
133
|
+
* reading it as a target with no work, but `aitk targets list` does not: it
|
|
134
|
+
* never opens a recorded path, and the count is its whole output.
|
|
135
|
+
*/
|
|
136
|
+
export function recordTarget(
|
|
137
|
+
target: string,
|
|
138
|
+
now: Date,
|
|
139
|
+
path: string = registryPath(),
|
|
140
|
+
): RecordOutcome {
|
|
141
|
+
const resolved = resolve(target)
|
|
142
|
+
const current = readTargetRegistry(path)
|
|
143
|
+
const existing = current.kind === 'read' ? current.targets : []
|
|
144
|
+
|
|
145
|
+
const targets = [
|
|
146
|
+
...existing.filter((row) => row.path !== resolved),
|
|
147
|
+
{ path: resolved, stampedAt: now.toISOString() },
|
|
148
|
+
].sort((a, b) => a.path.localeCompare(b.path))
|
|
149
|
+
|
|
150
|
+
const payload: StoredRegistry = { version: VERSION, targets }
|
|
151
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
155
|
+
writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`)
|
|
156
|
+
renameSync(temp, path)
|
|
157
|
+
return 'recorded'
|
|
158
|
+
} catch {
|
|
159
|
+
return 'unwritten'
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { isLegacyStamped } from '@/sync/stamp'
|
|
3
|
+
import {
|
|
4
|
+
readTargetRegistry,
|
|
5
|
+
type TargetRegistry,
|
|
6
|
+
registryPath,
|
|
7
|
+
} from '@/targets/registry'
|
|
8
|
+
import {
|
|
9
|
+
type SweepBound,
|
|
10
|
+
type SweepOptions,
|
|
11
|
+
sweepTargets,
|
|
12
|
+
} from '@/targets/sweep'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where a target came from, carried on the row rather than inferred.
|
|
16
|
+
*
|
|
17
|
+
* A caller deciding whether an answer is trustworthy needs to know which rows
|
|
18
|
+
* the machine recorded for itself and which a walk guessed at, and the two
|
|
19
|
+
* carry different bounds.
|
|
20
|
+
*/
|
|
21
|
+
export type TargetSource = 'given' | 'record' | 'sweep'
|
|
22
|
+
|
|
23
|
+
export interface KnownTarget {
|
|
24
|
+
/** Every checkout of this project on this machine, one for the ordinary case. */
|
|
25
|
+
readonly paths: readonly string[]
|
|
26
|
+
readonly origin: string | null
|
|
27
|
+
readonly source: TargetSource
|
|
28
|
+
/** When a sync last recorded this target, or null for a row only a sweep found. */
|
|
29
|
+
readonly stampedAt: string | null
|
|
30
|
+
/** True while the install stamp still sits at the retired path. */
|
|
31
|
+
readonly legacy: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolvedTargets {
|
|
35
|
+
readonly targets: readonly KnownTarget[]
|
|
36
|
+
/** Null when the caller named its targets, so no registry read was attempted. */
|
|
37
|
+
readonly registry: TargetRegistry | null
|
|
38
|
+
/** Null when no sweep ran, which is the ordinary case. */
|
|
39
|
+
readonly bound: SweepBound | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolveTargetsOptions extends SweepOptions {
|
|
43
|
+
/** Paths the caller named. These win outright and suppress both other sources. */
|
|
44
|
+
readonly paths?: readonly string[]
|
|
45
|
+
/** Roots to walk, supplementing the record rather than replacing it. */
|
|
46
|
+
readonly sweep?: readonly string[]
|
|
47
|
+
readonly registryFile?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Answers which projects the toolkit has installed into.
|
|
52
|
+
*
|
|
53
|
+
* The record written at install time is the primary source and a walk is the
|
|
54
|
+
* fallback, which is the shape the population needs: a sweep alone cannot see
|
|
55
|
+
* another machine or a clone under a path nobody named, and that is exactly how
|
|
56
|
+
* the count moved from four to seven inside one pass and was then wrong in both
|
|
57
|
+
* directions at once.
|
|
58
|
+
*
|
|
59
|
+
* A caller naming paths gets those and no lookup at all, since it has already
|
|
60
|
+
* answered the question this resolves.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveTargets(
|
|
63
|
+
opts: ResolveTargetsOptions = {},
|
|
64
|
+
): Promise<ResolvedTargets> {
|
|
65
|
+
if (opts.paths !== undefined && opts.paths.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
targets: opts.paths.map((path) => given(resolve(path))),
|
|
68
|
+
registry: null,
|
|
69
|
+
bound: null,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const file = opts.registryFile ?? registryPath()
|
|
74
|
+
const registry = readTargetRegistry(file)
|
|
75
|
+
|
|
76
|
+
const recorded: KnownTarget[] =
|
|
77
|
+
registry.kind === 'read'
|
|
78
|
+
? registry.targets.map((row) => ({
|
|
79
|
+
paths: [row.path],
|
|
80
|
+
origin: null,
|
|
81
|
+
source: 'record' as const,
|
|
82
|
+
stampedAt: row.stampedAt,
|
|
83
|
+
legacy: isLegacyStamped(row.path),
|
|
84
|
+
}))
|
|
85
|
+
: []
|
|
86
|
+
|
|
87
|
+
if (opts.sweep === undefined || opts.sweep.length === 0) {
|
|
88
|
+
return { targets: recorded, registry, bound: null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const swept = await sweepTargets(opts.sweep, opts)
|
|
92
|
+
const known = new Set(recorded.flatMap((target) => target.paths))
|
|
93
|
+
|
|
94
|
+
// A sweep row whose paths the record already holds is the same project read
|
|
95
|
+
// twice, so it adds nothing. A row holding one known path and one unknown one
|
|
96
|
+
// is the second-clone case, and it replaces the record's row rather than
|
|
97
|
+
// sitting beside it, since the sweep is the only source that can see both.
|
|
98
|
+
const added: KnownTarget[] = []
|
|
99
|
+
const superseded = new Set<string>()
|
|
100
|
+
|
|
101
|
+
for (const target of swept.targets) {
|
|
102
|
+
const overlap = target.paths.filter((path) => known.has(path))
|
|
103
|
+
|
|
104
|
+
if (overlap.length === target.paths.length) continue
|
|
105
|
+
|
|
106
|
+
for (const path of overlap) superseded.add(path)
|
|
107
|
+
|
|
108
|
+
// The recorded clone leads, because the record only names one a sync
|
|
109
|
+
// actually ran in, where the rest are checkouts a walk happened to find.
|
|
110
|
+
// Every caller reading a single path takes the first, and picking that by
|
|
111
|
+
// sort order is how a repair ran in one clone while the count was taken
|
|
112
|
+
// against another and the target read as untouched.
|
|
113
|
+
added.push({
|
|
114
|
+
paths: [...overlap, ...target.paths.filter((path) => !known.has(path))],
|
|
115
|
+
origin: target.origin,
|
|
116
|
+
source: overlap.length > 0 ? 'record' : 'sweep',
|
|
117
|
+
stampedAt:
|
|
118
|
+
recorded.find((row) => overlap.includes(row.paths[0] ?? ''))
|
|
119
|
+
?.stampedAt ?? null,
|
|
120
|
+
legacy: target.legacy,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const kept = recorded.filter(
|
|
125
|
+
(row) => !row.paths.some((path) => superseded.has(path)),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
targets: [...kept, ...added].sort((a, b) =>
|
|
130
|
+
(a.paths[0] ?? '').localeCompare(b.paths[0] ?? ''),
|
|
131
|
+
),
|
|
132
|
+
registry,
|
|
133
|
+
bound: swept.bound,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function given(path: string): KnownTarget {
|
|
138
|
+
return {
|
|
139
|
+
paths: [path],
|
|
140
|
+
origin: null,
|
|
141
|
+
source: 'given',
|
|
142
|
+
stampedAt: null,
|
|
143
|
+
legacy: isLegacyStamped(path),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { gitEnv } from '@/git-env'
|
|
5
|
+
import { isLegacyStamped, legacyStampPath, stampPath } from '@/sync/stamp'
|
|
6
|
+
import { isDirectory } from '@/target'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Folders a walk never descends into.
|
|
10
|
+
*
|
|
11
|
+
* Most hold vendored trees or the repository's own object store, where a target
|
|
12
|
+
* cannot live and walking costs the sweep most of its time. `.claude` is here
|
|
13
|
+
* for a different reason: a stamped folder is found by testing the folder
|
|
14
|
+
* itself rather than by walking into its `.claude`, so descending finds nothing
|
|
15
|
+
* new, and `.claude/worktrees/` holds a full checkout per linked worktree that
|
|
16
|
+
* carries a copy of its own target's stamp. One target on this machine had five
|
|
17
|
+
* of them, each of which would have reported as a target of its own.
|
|
18
|
+
*/
|
|
19
|
+
const SKIP = new Set([
|
|
20
|
+
'node_modules',
|
|
21
|
+
'.git',
|
|
22
|
+
'.claude',
|
|
23
|
+
'dist',
|
|
24
|
+
'build',
|
|
25
|
+
'vendor',
|
|
26
|
+
'target',
|
|
27
|
+
'.next',
|
|
28
|
+
'.venv',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/** How deep below a root the walk goes before it stops and says so. */
|
|
32
|
+
export const DEFAULT_DEPTH = 4
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One project, with every path on this machine that holds a stamp for it.
|
|
36
|
+
*
|
|
37
|
+
* The paths are plural because a project can be cloned more than once, which
|
|
38
|
+
* is not a hypothetical: the census taken on 2026-08-28 counted `caret` at the
|
|
39
|
+
* clone it walked while the repair had run in a second clone it never saw, and
|
|
40
|
+
* that gap is what left a task carrying a ticked outcome its own finding
|
|
41
|
+
* contradicted.
|
|
42
|
+
*/
|
|
43
|
+
export interface SweptTarget {
|
|
44
|
+
readonly paths: readonly string[]
|
|
45
|
+
/** The origin every path agrees on, or null when git resolved none. */
|
|
46
|
+
readonly origin: string | null
|
|
47
|
+
/** True while every path still carries its stamp at the retired location. */
|
|
48
|
+
readonly legacy: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the answer is bounded by, reported alongside it.
|
|
53
|
+
*
|
|
54
|
+
* A sweep cannot see another machine, a clone under a path nobody named, or a
|
|
55
|
+
* tree it lacked permission to read. Stating the bound is what makes an
|
|
56
|
+
* incomplete answer legible as incomplete, which the two undercounts this
|
|
57
|
+
* exists to replace were not.
|
|
58
|
+
*/
|
|
59
|
+
export interface SweepBound {
|
|
60
|
+
readonly roots: readonly string[]
|
|
61
|
+
readonly depth: number
|
|
62
|
+
/** Folders the walk stopped at on reaching the depth cap, so a target below one is unseen. */
|
|
63
|
+
readonly truncated: readonly string[]
|
|
64
|
+
/** Roots that could not be listed at all, as opposed to holding nothing. */
|
|
65
|
+
readonly unreadable: readonly string[]
|
|
66
|
+
/**
|
|
67
|
+
* Symlinks to directories, which the walk does not follow.
|
|
68
|
+
*
|
|
69
|
+
* `readdirSync` with `withFileTypes` answers `isDirectory()` false for one, so
|
|
70
|
+
* without this field a target reached only through a symlink is dropped
|
|
71
|
+
* before the walk and named nowhere, which is a third silent undercount
|
|
72
|
+
* beside the two this module replaces.
|
|
73
|
+
*/
|
|
74
|
+
readonly symlinks: readonly string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SweepReport {
|
|
78
|
+
readonly targets: readonly SweptTarget[]
|
|
79
|
+
readonly bound: SweepBound
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SweepOptions {
|
|
83
|
+
readonly depth?: number
|
|
84
|
+
/** Resolves a checkout's origin. Injected so a test needs no remote. */
|
|
85
|
+
readonly originOf?: (path: string) => Promise<string | null>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether a folder carries an install stamp at either the current or the retired path. */
|
|
89
|
+
function isStamped(path: string): boolean {
|
|
90
|
+
return (
|
|
91
|
+
Bun.file(stampPath(path)).size > 0 ||
|
|
92
|
+
Bun.file(legacyStampPath(path)).size > 0
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reads the origin a checkout pushes to, trimmed to a form two clones of one
|
|
98
|
+
* project agree on.
|
|
99
|
+
*
|
|
100
|
+
* The scheme and the `.git` suffix are dropped because one project is commonly
|
|
101
|
+
* cloned over ssh in one place and https in another, and a comparison keeping
|
|
102
|
+
* either reports those as two projects, which is the count this exists to fix.
|
|
103
|
+
*
|
|
104
|
+
* Userinfo goes with them, and it is the component that has to. A checkout
|
|
105
|
+
* cloned as `https://x-access-token:<token>@github.com/owner/repo.git` puts the
|
|
106
|
+
* token in the key, and the key is returned on `SweptTarget.origin` and printed
|
|
107
|
+
* by `aitk targets list --json`. Dropping it also fixes the count, since
|
|
108
|
+
* `https://someuser@github.com/owner/repo.git` keyed on the user and so failed
|
|
109
|
+
* to group with the same project's ssh clone.
|
|
110
|
+
*/
|
|
111
|
+
export async function originOf(path: string): Promise<string | null> {
|
|
112
|
+
const result = await $`git -C ${path} remote get-url origin`
|
|
113
|
+
.env(gitEnv())
|
|
114
|
+
.quiet()
|
|
115
|
+
.nothrow()
|
|
116
|
+
|
|
117
|
+
if (result.exitCode !== 0) return null
|
|
118
|
+
|
|
119
|
+
const raw = result.stdout.toString().trim()
|
|
120
|
+
if (raw.length === 0) return null
|
|
121
|
+
|
|
122
|
+
return raw
|
|
123
|
+
.replace(/^[a-z+]+:\/\//, '')
|
|
124
|
+
.replace(/^[^/@]*@/, '')
|
|
125
|
+
.replace(/:/, '/')
|
|
126
|
+
.replace(/\.git$/, '')
|
|
127
|
+
.replace(/\/$/, '')
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Walks the given roots for installed targets and reports what bounds the walk.
|
|
133
|
+
*
|
|
134
|
+
* A stamped folder is descended into like any other, because a stamp is written
|
|
135
|
+
* by an install someone ran in that folder and a target can hold others. The
|
|
136
|
+
* machine this was measured on has exactly that shape: one stamped repository
|
|
137
|
+
* holds the eight the hand census walked, so stopping at the outer one would
|
|
138
|
+
* have hidden every target the sweep exists to find.
|
|
139
|
+
*/
|
|
140
|
+
export async function sweepTargets(
|
|
141
|
+
roots: readonly string[],
|
|
142
|
+
opts: SweepOptions = {},
|
|
143
|
+
): Promise<SweepReport> {
|
|
144
|
+
const depth = opts.depth ?? DEFAULT_DEPTH
|
|
145
|
+
const resolveOrigin = opts.originOf ?? originOf
|
|
146
|
+
|
|
147
|
+
const found: string[] = []
|
|
148
|
+
const truncated: string[] = []
|
|
149
|
+
const unreadable: string[] = []
|
|
150
|
+
const symlinks: string[] = []
|
|
151
|
+
const seen = new Set<string>()
|
|
152
|
+
|
|
153
|
+
const walk = (dir: string, level: number): void => {
|
|
154
|
+
if (seen.has(dir)) return
|
|
155
|
+
seen.add(dir)
|
|
156
|
+
|
|
157
|
+
if (isStamped(dir)) found.push(dir)
|
|
158
|
+
|
|
159
|
+
if (level >= depth) {
|
|
160
|
+
truncated.push(dir)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let entries: string[]
|
|
165
|
+
try {
|
|
166
|
+
const listed = readdirSync(dir, { withFileTypes: true }).filter(
|
|
167
|
+
(entry) => !SKIP.has(entry.name),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// A symlink is reported as neither a directory nor walked, so a directory
|
|
171
|
+
// symlink is named here rather than dropped. Following one is available,
|
|
172
|
+
// since `seen` already closes the cycle, and naming it is the answer the
|
|
173
|
+
// bound asks for: what the walk did not reach, stated rather than
|
|
174
|
+
// omitted. `isDirectory` follows the link, so a symlink to a file or a
|
|
175
|
+
// broken one is excluded rather than read as an unfollowed directory.
|
|
176
|
+
for (const entry of listed) {
|
|
177
|
+
if (!entry.isSymbolicLink()) continue
|
|
178
|
+
const full = join(dir, entry.name)
|
|
179
|
+
if (isDirectory(full)) symlinks.push(full)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
entries = listed
|
|
183
|
+
.filter((entry) => entry.isDirectory())
|
|
184
|
+
.map((entry) => entry.name)
|
|
185
|
+
} catch {
|
|
186
|
+
unreadable.push(dir)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const name of entries) walk(join(dir, name), level + 1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const resolved = roots.map((root) => resolve(root))
|
|
194
|
+
|
|
195
|
+
for (const root of resolved) {
|
|
196
|
+
if (!isDirectory(root)) {
|
|
197
|
+
unreadable.push(root)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
walk(root, 0)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
targets: await group(found.sort(), resolveOrigin),
|
|
205
|
+
bound: { roots: resolved, depth, truncated, unreadable, symlinks },
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Collapses the paths sharing one origin into a single target.
|
|
211
|
+
*
|
|
212
|
+
* A path whose origin does not resolve stays on its own, since two checkouts
|
|
213
|
+
* with no remote cannot be shown to be the same project and merging them on
|
|
214
|
+
* that absence would undercount in the other direction.
|
|
215
|
+
*/
|
|
216
|
+
async function group(
|
|
217
|
+
paths: readonly string[],
|
|
218
|
+
resolveOrigin: (path: string) => Promise<string | null>,
|
|
219
|
+
): Promise<readonly SweptTarget[]> {
|
|
220
|
+
const origins = await Promise.all(paths.map(resolveOrigin))
|
|
221
|
+
const byOrigin = new Map<string, string[]>()
|
|
222
|
+
const alone: SweptTarget[] = []
|
|
223
|
+
|
|
224
|
+
paths.forEach((path, index) => {
|
|
225
|
+
const origin = origins[index]
|
|
226
|
+
|
|
227
|
+
if (origin === null || origin === undefined) {
|
|
228
|
+
alone.push({ paths: [path], origin: null, legacy: isLegacyStamped(path) })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const group = byOrigin.get(origin)
|
|
233
|
+
if (group === undefined) byOrigin.set(origin, [path])
|
|
234
|
+
else group.push(path)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const merged = [...byOrigin.entries()].map(([origin, group]) => ({
|
|
238
|
+
paths: group,
|
|
239
|
+
origin,
|
|
240
|
+
legacy: group.every((path) => isLegacyStamped(path)),
|
|
241
|
+
}))
|
|
242
|
+
|
|
243
|
+
return [...merged, ...alone].sort((a, b) =>
|
|
244
|
+
(a.paths[0] ?? '').localeCompare(b.paths[0] ?? ''),
|
|
245
|
+
)
|
|
246
|
+
}
|
package/standards/issue.md
CHANGED
|
@@ -58,7 +58,7 @@ Fix the feedback CLI so it applies the `feedback` label.
|
|
|
58
58
|
|
|
59
59
|
## Details
|
|
60
60
|
|
|
61
|
-
`aitk feedback --github` opens an issue with no label, so `
|
|
61
|
+
`aitk feedback --github` opens an issue with no label, so `aitk-feedback-triage` never lists it.
|
|
62
62
|
|
|
63
63
|
## Context
|
|
64
64
|
|
|
File without changes
|