@erclx/aitk 0.66.1 → 0.68.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/claude-orchestrate/REQUIREMENT.md +3 -0
- package/claude/skills/claude-orchestrate/SKILL.md +4 -6
- package/claude/skills/claude-orchestrate/references/orchestrator-handoff.md +12 -7
- package/claude/skills/claude-worktree/REQUIREMENT.md +8 -5
- package/claude/skills/claude-worktree/SKILL.md +24 -4
- package/claude/skills/claude-worktree/references/branch.md +60 -0
- package/claude/skills/git-branch/references/branch.md +1 -1
- package/claude/skills/git-followup/REQUIREMENT.md +3 -3
- package/claude/skills/git-followup/SKILL.md +3 -2
- package/claude/skills/git-pr/references/branch.md +1 -1
- package/claude/skills/git-split/references/branch.md +1 -1
- package/claude/skills/project-commands/SKILL.md +13 -5
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit.md +5 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +23 -0
- package/docs/agents/markdown-audit.md +26 -6
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +5 -5
- package/package.json +1 -1
- package/scripts/core/verify.sh +34 -0
- package/src/commands/markdown.ts +27 -6
- package/src/commands/sync.ts +51 -6
- package/src/commands/tooling.ts +35 -0
- package/src/markdown/gate.ts +28 -0
- package/src/sync/check.ts +125 -8
- package/src/sync/stamp.ts +64 -4
- package/src/tooling/stamp.ts +44 -0
- package/standards/bundled/branch.md +1 -1
- package/standards/slug.md +4 -2
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/reference.md +1 -1
- package/tooling/claude/seeds/CLAUDE.md +1 -1
- package/tooling/vite-react/reference.md +1 -1
- package/tooling/web/reference.md +1 -1
package/scripts/core/verify.sh
CHANGED
|
@@ -272,6 +272,40 @@ main() {
|
|
|
272
272
|
run_check "cd $PROJECT_ROOT && bun src/cli.ts context audit --citations-only" "A cited context path does not resolve. Run bun src/cli.ts context audit."
|
|
273
273
|
log_info "Context citations resolve"
|
|
274
274
|
|
|
275
|
+
# A banned character, word, or spelling is a fact rather than a threshold, so
|
|
276
|
+
# it fails the push while bullet, paragraph, and depth weight stay advisory
|
|
277
|
+
# for the reason the stage above leaves its own thresholds so.
|
|
278
|
+
#
|
|
279
|
+
# The whole corpus is measured rather than the changed files, because a
|
|
280
|
+
# `Do not use` bullet added to a standard bans a token retroactively and no
|
|
281
|
+
# file in the push that adds it was edited.
|
|
282
|
+
#
|
|
283
|
+
# `--json` sends the record to stdout and the frame to stderr, so a passing
|
|
284
|
+
# run stays silent and a failing one is re-run for its frame rather than
|
|
285
|
+
# parsed out of a stream this script would have to strip. `bun src/cli.ts`
|
|
286
|
+
# rather than `aitk` for the reason the stage above uses it.
|
|
287
|
+
log_step "Markdown bans"
|
|
288
|
+
local ban_status=0 ban_frame
|
|
289
|
+
(cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit --json >/dev/null 2>&1) || ban_status=$?
|
|
290
|
+
case $ban_status in
|
|
291
|
+
0)
|
|
292
|
+
log_info "No banned character, word, or spelling"
|
|
293
|
+
;;
|
|
294
|
+
1)
|
|
295
|
+
log_warn "Skipped, the markdown audit refused and measured nothing"
|
|
296
|
+
;;
|
|
297
|
+
2)
|
|
298
|
+
# `|| true` because the re-run exits non-zero by construction, and `set -e`
|
|
299
|
+
# would take the script down before log_error names the remedy.
|
|
300
|
+
ban_frame=$(cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit 2>&1 || true)
|
|
301
|
+
echo "$ban_frame" | pipe_output
|
|
302
|
+
log_error "Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion."
|
|
303
|
+
;;
|
|
304
|
+
*)
|
|
305
|
+
log_error "The markdown audit exited $ban_status, which is neither a pass nor a finding."
|
|
306
|
+
;;
|
|
307
|
+
esac
|
|
308
|
+
|
|
275
309
|
# The stage above audits this repository. Its seed tree ships into every
|
|
276
310
|
# scaffolded project, so a seed breaking the standard it seeds propagates
|
|
277
311
|
# instead of sitting still, and no rule path reaches the tree to report it.
|
package/src/commands/markdown.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { type BanReport, banReport, loadStandards } from '@/markdown/bans'
|
|
5
5
|
import { resolveMarkdown } from '@/markdown/files'
|
|
6
|
+
import { isGating } from '@/markdown/gate'
|
|
6
7
|
import { type BanFinding, bodyLines, scanBans } from '@/markdown/scan'
|
|
7
8
|
import {
|
|
8
9
|
type Checkpoints,
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
plural,
|
|
21
22
|
} from '@/ui'
|
|
22
23
|
|
|
24
|
+
const EXIT_GATE = 2
|
|
25
|
+
|
|
23
26
|
interface AuditCommandOptions {
|
|
24
27
|
readonly json?: boolean
|
|
25
28
|
}
|
|
@@ -39,7 +42,7 @@ export function register(program: Command): void {
|
|
|
39
42
|
markdown
|
|
40
43
|
.command('audit')
|
|
41
44
|
.description(
|
|
42
|
-
'
|
|
45
|
+
'Fail on a banned character, word, or spelling, and report bullet, paragraph, and depth weight',
|
|
43
46
|
)
|
|
44
47
|
.argument(
|
|
45
48
|
'[path...]',
|
|
@@ -52,12 +55,18 @@ export function register(program: Command): void {
|
|
|
52
55
|
[
|
|
53
56
|
'',
|
|
54
57
|
'Exit codes:',
|
|
55
|
-
' 0 the audit completed',
|
|
58
|
+
' 0 the audit completed with no gating finding',
|
|
56
59
|
' 1 refused, with the reason on stderr',
|
|
60
|
+
' 2 a banned character, word, or spelling is present',
|
|
61
|
+
'',
|
|
62
|
+
'A ban hit is a fact and gates unconditionally. Bullet, paragraph, and',
|
|
63
|
+
'depth weight are judgments a reader settles, so all three report and',
|
|
64
|
+
'none of them fails a run.',
|
|
57
65
|
'',
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'
|
|
66
|
+
'Rewrite the sentence carrying a hit rather than swapping the token for',
|
|
67
|
+
'a near-synonym. A code span clears the report and is the answer only',
|
|
68
|
+
'where the token is genuinely an identifier under discussion, which is',
|
|
69
|
+
'what markdown.md reserves the span for.',
|
|
61
70
|
'',
|
|
62
71
|
'Bans and checkpoints are read from markdown.md and prose.md, resolved',
|
|
63
72
|
'under .claude/standards/ then standards/. No folder has to resolve and',
|
|
@@ -156,7 +165,12 @@ async function runAudit(
|
|
|
156
165
|
)
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
|
|
168
|
+
const gating = isGating({
|
|
169
|
+
bans: reports.flatMap((report) => report.bans),
|
|
170
|
+
structure: reports.map((report) => report.structure),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return gating ? EXIT_GATE : 0
|
|
160
174
|
}
|
|
161
175
|
|
|
162
176
|
function refuse(message: string): number {
|
|
@@ -218,6 +232,13 @@ function reportBans(reports: readonly FileReport[], bans: BanReport): void {
|
|
|
218
232
|
|
|
219
233
|
const total = carrying.reduce((sum, report) => sum + report.bans.length, 0)
|
|
220
234
|
logWarn(`${plural(total, 'hit')} across ${plural(carrying.length, 'file')}`)
|
|
235
|
+
logWarn('This fails the run. Every other check below reports.')
|
|
236
|
+
logInfo(
|
|
237
|
+
'Rewrite the sentence rather than swapping the token for a near-synonym.',
|
|
238
|
+
)
|
|
239
|
+
logInfo(
|
|
240
|
+
'A code span clears the report and is the answer only where the token is genuinely an identifier under discussion, which is what markdown.md reserves the span for.',
|
|
241
|
+
)
|
|
221
242
|
pipeOutput(
|
|
222
243
|
carrying
|
|
223
244
|
.map(
|
package/src/commands/sync.ts
CHANGED
|
@@ -3,9 +3,13 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { cliPath, cliRun } from '@/cli-run'
|
|
5
5
|
import { PROJECT_ROOT } from '@/exec'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildCheckReport,
|
|
8
|
+
type CheckReport,
|
|
9
|
+
hasDrift,
|
|
10
|
+
SCANNED_DOMAINS,
|
|
11
|
+
} from '@/sync/check'
|
|
7
12
|
import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
|
|
8
|
-
import { STAMP_DOMAINS } from '@/sync/stamp'
|
|
9
13
|
import {
|
|
10
14
|
detectDomains,
|
|
11
15
|
installedDomains,
|
|
@@ -133,6 +137,8 @@ function renderCheck(report: CheckReport): void {
|
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
renderTooling(report)
|
|
141
|
+
|
|
136
142
|
for (const entry of report.unmigrated) {
|
|
137
143
|
logStep(`${entry.domain} (not migrated)`)
|
|
138
144
|
logWarn(
|
|
@@ -157,17 +163,56 @@ function renderCheck(report: CheckReport): void {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
outro()
|
|
166
|
+
// Scanned domains only. Tooling renders a section on every managed target, so
|
|
167
|
+
// naming it here repeats what that section already said under a second
|
|
168
|
+
// remedy, where a scanned domain nobody installed has no section at all and
|
|
169
|
+
// this line is the only place it appears.
|
|
160
170
|
const unmigrated = report.unmigrated.map((entry) => entry.domain)
|
|
161
|
-
const uncovered =
|
|
171
|
+
const uncovered = SCANNED_DOMAINS.filter(
|
|
162
172
|
(domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
|
|
163
173
|
)
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
if (uncovered.length === 0) return
|
|
175
|
+
|
|
166
176
|
process.stderr.write(
|
|
167
|
-
`${GREY}${
|
|
177
|
+
`${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
|
|
168
178
|
)
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Tooling prints whether it was measured before it prints any count, because a
|
|
183
|
+
* target with no chain recorded produces the same zero a current target does.
|
|
184
|
+
* Naming the state is the whole reason the section exists.
|
|
185
|
+
*/
|
|
186
|
+
function renderTooling(report: CheckReport): void {
|
|
187
|
+
const { tooling } = report
|
|
188
|
+
logStep('tooling')
|
|
189
|
+
|
|
190
|
+
if (!tooling.measured) {
|
|
191
|
+
logWarn(
|
|
192
|
+
tooling.chain.length === 0
|
|
193
|
+
? 'Not stamped. No chain recorded, so tooling drift is unmeasured.'
|
|
194
|
+
: `Recorded chain names no stack this toolkit ships: ${tooling.chain.join(' < ')}.`,
|
|
195
|
+
)
|
|
196
|
+
logInfo('Run `aitk tooling sync <stack>` to record what this target holds.')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logInfo(`Chain: ${tooling.chain.join(' < ')}`)
|
|
201
|
+
if (tooling.commit !== undefined) {
|
|
202
|
+
logInfo(`Synced from ${tooling.commit} on ${tooling.syncedAt}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (tooling.changes === 0) {
|
|
206
|
+
logInfo('up to date')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const [category, count] of Object.entries(tooling.counts)) {
|
|
211
|
+
if (count > 0) logWarn(`${count} ${category}`)
|
|
212
|
+
}
|
|
213
|
+
logInfo('Run `aitk tooling sync` to reconcile these.')
|
|
214
|
+
}
|
|
215
|
+
|
|
171
216
|
/**
|
|
172
217
|
* Seeds print their own section because no sync command applies them. A `stale`
|
|
173
218
|
* seed is safe to take whole and a `drifted` one holds edits, which is the split
|
package/src/commands/tooling.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
stackExists,
|
|
19
19
|
} from '@/tooling/manifest'
|
|
20
20
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
21
|
+
import { recordToolingChain } from '@/tooling/stamp'
|
|
21
22
|
import { intro, logAdd, logInfo, logStep, logWarn, outro, select } from '@/ui'
|
|
22
23
|
|
|
23
24
|
const GREEN = '\x1b[0;32m'
|
|
@@ -219,6 +220,7 @@ async function runSync(
|
|
|
219
220
|
report(result, includeReferences)
|
|
220
221
|
|
|
221
222
|
if (result.totalChanges === 0) {
|
|
223
|
+
await stampChain(prepared.chain, prepared.target)
|
|
222
224
|
outro()
|
|
223
225
|
process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
|
|
224
226
|
return 0
|
|
@@ -255,11 +257,35 @@ async function runSync(
|
|
|
255
257
|
await applyReferences(prepared.chain, prepared.target, pending)
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
await stampChain(prepared.chain, prepared.target)
|
|
261
|
+
|
|
258
262
|
outro()
|
|
259
263
|
process.stderr.write(`${GREEN}✓ Tooling sync complete${NC}\n`)
|
|
260
264
|
return 0
|
|
261
265
|
}
|
|
262
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Writes the chain after the copies land, so a partial apply that throws leaves
|
|
269
|
+
* the previous record rather than a claim the target does not meet.
|
|
270
|
+
*/
|
|
271
|
+
async function stampChain(chain: Manifest[], target: string): Promise<void> {
|
|
272
|
+
const recorded = await recordToolingChain(
|
|
273
|
+
PROJECT_ROOT,
|
|
274
|
+
target,
|
|
275
|
+
chain,
|
|
276
|
+
new Date(),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if (recorded) {
|
|
280
|
+
logInfo(`Recorded chain: ${chain.map((entry) => entry.name).join(' < ')}`)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
logWarn(
|
|
285
|
+
'Workspace root: no chain recorded. Tooling reports unmeasured because the answer differs per package.',
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
263
289
|
async function runInject(
|
|
264
290
|
stack: string,
|
|
265
291
|
target: string,
|
|
@@ -290,6 +316,15 @@ async function runInject(
|
|
|
290
316
|
await injectGitignore(prepared.chain, prepared.target)
|
|
291
317
|
}
|
|
292
318
|
|
|
319
|
+
// Only a whole-stack inject records the chain. A flag-scoped run installs one
|
|
320
|
+
// category, and a chain recorded from it would send the report scanning for
|
|
321
|
+
// configs and deps the caller never asked to install. The claude stack is
|
|
322
|
+
// excluded here rather than in `prepare`, which is what keeps `aitk claude`
|
|
323
|
+
// able to drive injection while its stack stays out of the tooling record.
|
|
324
|
+
if (applyAll && !isStackExcluded(stack)) {
|
|
325
|
+
await stampChain(prepared.chain, prepared.target)
|
|
326
|
+
}
|
|
327
|
+
|
|
293
328
|
if (framed) outro()
|
|
294
329
|
return 0
|
|
295
330
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BanFinding } from '@/markdown/scan'
|
|
2
|
+
import type { StructureReport } from '@/markdown/structure'
|
|
3
|
+
|
|
4
|
+
export interface GateInput {
|
|
5
|
+
/** Every ban hit across every file measured, flattened. */
|
|
6
|
+
readonly bans: readonly BanFinding[]
|
|
7
|
+
/**
|
|
8
|
+
* Every structural measure the run made, read by nothing here.
|
|
9
|
+
*
|
|
10
|
+
* Naming it is what makes the split checkable: bullet, paragraph, and depth
|
|
11
|
+
* weight are judgments a reader settles, and a push failing on one teaches a
|
|
12
|
+
* contributor to route around the stage. A signature taking the ban list
|
|
13
|
+
* alone states the same rule and leaves no place to assert it.
|
|
14
|
+
*/
|
|
15
|
+
readonly structure: readonly StructureReport[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the audit found something that should fail the caller.
|
|
20
|
+
*
|
|
21
|
+
* A banned character, word, or spelling is a fact rather than a threshold, so
|
|
22
|
+
* it gates unconditionally and there is no widened mode to reach for. The
|
|
23
|
+
* standards decide what counts as banned, which keeps this answering how many
|
|
24
|
+
* rather than which.
|
|
25
|
+
*/
|
|
26
|
+
export function isGating({ bans }: GateInput): boolean {
|
|
27
|
+
return bans.length > 0
|
|
28
|
+
}
|
package/src/sync/check.ts
CHANGED
|
@@ -13,32 +13,47 @@ import {
|
|
|
13
13
|
import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
|
|
14
14
|
import {
|
|
15
15
|
readStamp,
|
|
16
|
-
STAMP_DOMAINS,
|
|
17
16
|
type Stamp,
|
|
17
|
+
stampedChain,
|
|
18
18
|
stampedCommit,
|
|
19
19
|
type StampDomain,
|
|
20
20
|
} from '@/sync/stamp'
|
|
21
21
|
import { createStandardsAdapter } from '@/standards/adapter'
|
|
22
22
|
import { isDirectory } from '@/target'
|
|
23
|
+
import { loadManifest } from '@/tooling/manifest'
|
|
24
|
+
import { scan } from '@/tooling/scan'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Domains the sync engine walks file by file. Tooling is a stamp domain without
|
|
28
|
+
* being one of these, because `src/tooling/` never calls `planSync`, so the
|
|
29
|
+
* three lookups below have no entry to offer it.
|
|
30
|
+
*/
|
|
31
|
+
export const SCANNED_DOMAINS = [
|
|
32
|
+
'standards',
|
|
33
|
+
'snippets',
|
|
34
|
+
'governance',
|
|
35
|
+
] as const satisfies readonly StampDomain[]
|
|
36
|
+
|
|
37
|
+
export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
|
|
23
38
|
|
|
24
39
|
/**
|
|
25
40
|
* The toolkit path whose commits change what each domain holds. `claude/skills/`
|
|
26
41
|
* is deliberately absent: skills load live from the plugin directory, so they
|
|
27
42
|
* never go stale and belong in the read-only section instead.
|
|
28
43
|
*/
|
|
29
|
-
const SYNCED_SOURCES: Record<
|
|
44
|
+
const SYNCED_SOURCES: Record<ScannedDomain, string> = {
|
|
30
45
|
standards: 'standards/',
|
|
31
46
|
snippets: 'snippets/',
|
|
32
47
|
governance: 'governance/rules/',
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
const ADAPTERS: Record<
|
|
50
|
+
const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
|
|
36
51
|
standards: createStandardsAdapter,
|
|
37
52
|
snippets: createSnippetsAdapter,
|
|
38
53
|
governance: createGovAdapter,
|
|
39
54
|
}
|
|
40
55
|
|
|
41
|
-
const INSTALL_MARKERS: Record<
|
|
56
|
+
const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
|
|
42
57
|
standards: ['.claude', 'standards'],
|
|
43
58
|
snippets: ['.claude', 'snippets'],
|
|
44
59
|
governance: ['.claude', 'rules'],
|
|
@@ -54,7 +69,7 @@ export interface StateCounts {
|
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
export interface DomainReport {
|
|
57
|
-
readonly domain:
|
|
72
|
+
readonly domain: ScannedDomain
|
|
58
73
|
readonly stamped: boolean
|
|
59
74
|
/** This domain's own anchor, not the target's most recent sync. */
|
|
60
75
|
readonly commit?: string
|
|
@@ -74,11 +89,60 @@ export interface UpstreamCommit {
|
|
|
74
89
|
readonly subject: string
|
|
75
90
|
}
|
|
76
91
|
|
|
92
|
+
/** Pending changes per category, from the same scan `aitk tooling sync` reads. */
|
|
93
|
+
export interface ToolingCounts {
|
|
94
|
+
readonly configs: number
|
|
95
|
+
readonly seeds: number
|
|
96
|
+
readonly scripts: number
|
|
97
|
+
readonly deps: number
|
|
98
|
+
readonly gitignore: number
|
|
99
|
+
readonly references: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tooling's own section. `measured` is the field the report exists for: without
|
|
104
|
+
* it a target that never installed tooling and a target whose tooling is current
|
|
105
|
+
* both render as zero changes, which is a claim rather than an absence of one.
|
|
106
|
+
*/
|
|
107
|
+
export interface ToolingReport {
|
|
108
|
+
readonly measured: boolean
|
|
109
|
+
/**
|
|
110
|
+
* Stack names the install resolved, nearest first. Carried even when the
|
|
111
|
+
* report is unmeasured, so a chain naming stacks this toolkit no longer ships
|
|
112
|
+
* stays distinguishable from a target that recorded none.
|
|
113
|
+
*/
|
|
114
|
+
readonly chain: readonly string[]
|
|
115
|
+
readonly commit?: string
|
|
116
|
+
readonly syncedAt?: string
|
|
117
|
+
readonly counts: ToolingCounts
|
|
118
|
+
readonly changes: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const UNMEASURED_TOOLING: ToolingReport = {
|
|
122
|
+
measured: false,
|
|
123
|
+
chain: [],
|
|
124
|
+
counts: {
|
|
125
|
+
configs: 0,
|
|
126
|
+
seeds: 0,
|
|
127
|
+
scripts: 0,
|
|
128
|
+
deps: 0,
|
|
129
|
+
gitignore: 0,
|
|
130
|
+
references: 0,
|
|
131
|
+
},
|
|
132
|
+
changes: 0,
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
export interface CheckReport {
|
|
78
136
|
readonly covers: readonly StampDomain[]
|
|
79
137
|
/** False when the target is not a toolkit project, so every section stays empty. */
|
|
80
138
|
readonly managed: boolean
|
|
81
139
|
readonly domains: readonly DomainReport[]
|
|
140
|
+
/**
|
|
141
|
+
* Reported beside the domains rather than as one of them, because tooling
|
|
142
|
+
* carries no per-file entries and no upstream range, so it fills almost none
|
|
143
|
+
* of `DomainReport`.
|
|
144
|
+
*/
|
|
145
|
+
readonly tooling: ToolingReport
|
|
82
146
|
/**
|
|
83
147
|
* Reported beside the domains rather than as one of them, because seeds carry
|
|
84
148
|
* no stamp and produce no change. See `@/sync/seeds-report`.
|
|
@@ -89,12 +153,58 @@ export interface CheckReport {
|
|
|
89
153
|
readonly newSkills: readonly string[]
|
|
90
154
|
}
|
|
91
155
|
|
|
92
|
-
export function installedStampDomains(target: string):
|
|
93
|
-
return
|
|
156
|
+
export function installedStampDomains(target: string): ScannedDomain[] {
|
|
157
|
+
return SCANNED_DOMAINS.filter((domain) =>
|
|
94
158
|
isDirectory(join(target, ...INSTALL_MARKERS[domain])),
|
|
95
159
|
)
|
|
96
160
|
}
|
|
97
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Reads the chain the install recorded and scans against those stacks rather
|
|
164
|
+
* than re-resolving from the leaf. A run that passed `--skip` installed fewer
|
|
165
|
+
* layers than the leaf's own chain reproduces, so re-resolving would report
|
|
166
|
+
* drift against a layer the target deliberately does not carry.
|
|
167
|
+
*
|
|
168
|
+
* A recorded stack the toolkit no longer ships resolves to nothing, and a chain
|
|
169
|
+
* where none resolve reads as unmeasured. Scanning the survivors would measure
|
|
170
|
+
* against a chain neither side agrees on.
|
|
171
|
+
*/
|
|
172
|
+
export function buildToolingReport(
|
|
173
|
+
toolkitRoot: string,
|
|
174
|
+
target: string,
|
|
175
|
+
stamp: Stamp | undefined,
|
|
176
|
+
): ToolingReport {
|
|
177
|
+
const chain = stampedChain(stamp)
|
|
178
|
+
const manifests = chain
|
|
179
|
+
.map((name) => loadManifest(toolkitRoot, name))
|
|
180
|
+
.filter((manifest) => manifest !== undefined)
|
|
181
|
+
|
|
182
|
+
if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
|
|
183
|
+
|
|
184
|
+
const result = scan(manifests, target, { includeReferences: true })
|
|
185
|
+
const record = stamp?.domains.tooling
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
measured: true,
|
|
189
|
+
chain,
|
|
190
|
+
commit: record?.commit,
|
|
191
|
+
syncedAt: record?.syncedAt,
|
|
192
|
+
counts: {
|
|
193
|
+
configs: result.configs.filter((entry) => entry.state !== 'matching')
|
|
194
|
+
.length,
|
|
195
|
+
seeds: result.seeds.filter((entry) => entry.state === 'missing').length,
|
|
196
|
+
scripts: result.scripts.filter((entry) => entry.state !== 'matching')
|
|
197
|
+
.length,
|
|
198
|
+
deps: result.deps.filter((entry) => entry.state === 'missing').length,
|
|
199
|
+
gitignore: result.gitignore.filter((entry) => entry.state === 'missing')
|
|
200
|
+
.length,
|
|
201
|
+
references: result.references.filter((entry) => entry.state === 'pending')
|
|
202
|
+
.length,
|
|
203
|
+
},
|
|
204
|
+
changes: result.totalChanges,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
98
208
|
/**
|
|
99
209
|
* Whether the target is a toolkit-managed project at all. Seeds are enumerated
|
|
100
210
|
* from the source rather than from what a target installed, so without this gate
|
|
@@ -143,6 +253,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
143
253
|
* the user can move content they wrote, so failing a job on it leaves the job
|
|
144
254
|
* red with no mechanical remedy. Seeds are excluded on the same grounds, since
|
|
145
255
|
* every seed a project edits would otherwise fail the check forever.
|
|
256
|
+
*
|
|
257
|
+
* Tooling is excluded on exactly the seeds grounds: a golden config is one the
|
|
258
|
+
* project is expected to edit, so a job counting it stays red with no remedy.
|
|
259
|
+
* Being unmeasured is not what excludes it, since an unmeasured report carries
|
|
260
|
+
* zero changes and would pass a count either way.
|
|
146
261
|
*/
|
|
147
262
|
export function hasDrift(report: CheckReport): boolean {
|
|
148
263
|
if (report.unmigrated.length > 0) return true
|
|
@@ -186,6 +301,7 @@ export async function buildCheckReport(
|
|
|
186
301
|
covers: [],
|
|
187
302
|
managed,
|
|
188
303
|
domains: [],
|
|
304
|
+
tooling: UNMEASURED_TOOLING,
|
|
189
305
|
seeds: { entries: [], historyUnavailable: false },
|
|
190
306
|
superseded: [],
|
|
191
307
|
unmigrated: [],
|
|
@@ -197,6 +313,7 @@ export async function buildCheckReport(
|
|
|
197
313
|
covers: stamp?.covers ?? [],
|
|
198
314
|
managed,
|
|
199
315
|
domains,
|
|
316
|
+
tooling: buildToolingReport(toolkitRoot, target, stamp),
|
|
200
317
|
seeds: buildSeedsReport(toolkitRoot, target),
|
|
201
318
|
superseded: collectSuperseded(target),
|
|
202
319
|
unmigrated,
|
|
@@ -208,7 +325,7 @@ async function buildDomainReport(
|
|
|
208
325
|
toolkitRoot: string,
|
|
209
326
|
target: string,
|
|
210
327
|
stamp: Stamp | undefined,
|
|
211
|
-
domain:
|
|
328
|
+
domain: ScannedDomain,
|
|
212
329
|
): Promise<DomainReport> {
|
|
213
330
|
const plan = planSync(ADAPTERS[domain](toolkitRoot), target)
|
|
214
331
|
const record = stamp?.domains[domain]
|
package/src/sync/stamp.ts
CHANGED
|
@@ -5,10 +5,16 @@ import { dirname, join, sep } from 'node:path'
|
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Domains the stamp can
|
|
9
|
-
*
|
|
8
|
+
* Domains the stamp can record. The first three attribute file by file through
|
|
9
|
+
* the sync engine. Tooling runs its own inject and manifest machinery, so it
|
|
10
|
+
* records the stack chain it resolved instead and carries no file hashes.
|
|
10
11
|
*/
|
|
11
|
-
export const STAMP_DOMAINS = [
|
|
12
|
+
export const STAMP_DOMAINS = [
|
|
13
|
+
'standards',
|
|
14
|
+
'snippets',
|
|
15
|
+
'governance',
|
|
16
|
+
'tooling',
|
|
17
|
+
] as const
|
|
12
18
|
|
|
13
19
|
export type StampDomain = (typeof STAMP_DOMAINS)[number]
|
|
14
20
|
|
|
@@ -31,6 +37,13 @@ export interface DomainStamp {
|
|
|
31
37
|
readonly commit?: string
|
|
32
38
|
readonly syncedAt: string
|
|
33
39
|
readonly files: DomainHashes
|
|
40
|
+
/**
|
|
41
|
+
* Stack names the install resolved, nearest stack first. Present only for
|
|
42
|
+
* tooling. An ordered chain rather than the leaf name, because a stack that
|
|
43
|
+
* extends another cannot be reinstalled from its leaf alone, and a `--skip`
|
|
44
|
+
* run installs fewer layers than the leaf's own chain would reproduce.
|
|
45
|
+
*/
|
|
46
|
+
readonly chain?: readonly string[]
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export interface Stamp {
|
|
@@ -91,6 +104,15 @@ export function stampedHashes(
|
|
|
91
104
|
return stamp.domains[domain]?.files ?? {}
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The stack chain tooling last installed. An empty result is the state every
|
|
109
|
+
* target predating the tooling record sits in, and the report reads it as
|
|
110
|
+
* unmeasured rather than as clean.
|
|
111
|
+
*/
|
|
112
|
+
export function stampedChain(stamp: Stamp | undefined): readonly string[] {
|
|
113
|
+
return stamp?.domains.tooling?.chain ?? []
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
/**
|
|
95
117
|
* Replaces one domain's record and leaves the others untouched, because domains
|
|
96
118
|
* install and sync independently but share the one file.
|
|
@@ -100,6 +122,34 @@ export async function writeStamp(
|
|
|
100
122
|
source: StampSource,
|
|
101
123
|
hashes: DomainHashes,
|
|
102
124
|
now: Date,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await putDomain(target, source, { files: sortKeys(hashes) }, now)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Records what tooling installed. `files` stays empty because `src/tooling/`
|
|
131
|
+
* never runs the sync engine, so there is no per-file attribution to store and
|
|
132
|
+
* the chain is the whole record.
|
|
133
|
+
*/
|
|
134
|
+
export async function writeChainStamp(
|
|
135
|
+
target: string,
|
|
136
|
+
toolkitRoot: string,
|
|
137
|
+
chain: readonly string[],
|
|
138
|
+
now: Date,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await putDomain(
|
|
141
|
+
target,
|
|
142
|
+
{ domain: 'tooling', toolkitRoot },
|
|
143
|
+
{ files: {}, chain: [...chain] },
|
|
144
|
+
now,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function putDomain(
|
|
149
|
+
target: string,
|
|
150
|
+
source: StampSource,
|
|
151
|
+
payload: Pick<DomainStamp, 'files' | 'chain'>,
|
|
152
|
+
now: Date,
|
|
103
153
|
): Promise<void> {
|
|
104
154
|
const previous = readStamp(target)
|
|
105
155
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
@@ -107,7 +157,7 @@ export async function writeStamp(
|
|
|
107
157
|
const record: DomainStamp = {
|
|
108
158
|
...(commit === undefined ? {} : { commit }),
|
|
109
159
|
syncedAt: now.toISOString(),
|
|
110
|
-
|
|
160
|
+
...payload,
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
const domains = sortDomains({
|
|
@@ -187,16 +237,26 @@ function isStamp(value: unknown): value is Stamp {
|
|
|
187
237
|
)
|
|
188
238
|
}
|
|
189
239
|
|
|
240
|
+
/**
|
|
241
|
+
* `chain` is optional, which is what keeps a stamp written before tooling
|
|
242
|
+
* joined the domains readable rather than parsing as corrupt and discarding
|
|
243
|
+
* the three records it does carry.
|
|
244
|
+
*/
|
|
190
245
|
function isDomainStamp(value: unknown): value is DomainStamp {
|
|
191
246
|
if (!isRecord(value) || !isRecord(value.files)) return false
|
|
192
247
|
if (typeof value.syncedAt !== 'string') return false
|
|
193
248
|
if (value.commit !== undefined && typeof value.commit !== 'string') {
|
|
194
249
|
return false
|
|
195
250
|
}
|
|
251
|
+
if (value.chain !== undefined && !isStringArray(value.chain)) return false
|
|
196
252
|
|
|
197
253
|
return Object.values(value.files).every((hash) => typeof hash === 'string')
|
|
198
254
|
}
|
|
199
255
|
|
|
256
|
+
function isStringArray(value: unknown): value is string[] {
|
|
257
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
258
|
+
}
|
|
259
|
+
|
|
200
260
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
201
261
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
202
262
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { writeChainStamp } from '@/sync/stamp'
|
|
4
|
+
import type { Manifest } from '@/tooling/manifest'
|
|
5
|
+
import { readPackage } from '@/tooling/package'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the target is a workspace root, where every package resolves its own
|
|
9
|
+
* stack. `package.json` covers bun, npm, and yarn, and pnpm declares the same
|
|
10
|
+
* fact in its own file instead.
|
|
11
|
+
*/
|
|
12
|
+
export function isWorkspaceRoot(target: string): boolean {
|
|
13
|
+
if (existsSync(join(target, 'pnpm-workspace.yaml'))) return true
|
|
14
|
+
|
|
15
|
+
const workspaces = readPackage(join(target, 'package.json'))?.workspaces
|
|
16
|
+
return Array.isArray(workspaces) || isRecord(workspaces)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Records the chain an install resolved, and returns whether it wrote one. A
|
|
21
|
+
* workspace root is the single refusal: one chain recorded there would be a
|
|
22
|
+
* guess at what its packages hold, and the report reading tooling as unmeasured
|
|
23
|
+
* is the true answer rather than a clean one.
|
|
24
|
+
*/
|
|
25
|
+
export async function recordToolingChain(
|
|
26
|
+
toolkitRoot: string,
|
|
27
|
+
target: string,
|
|
28
|
+
chain: readonly Manifest[],
|
|
29
|
+
now: Date,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
if (isWorkspaceRoot(target)) return false
|
|
32
|
+
|
|
33
|
+
await writeChainStamp(
|
|
34
|
+
target,
|
|
35
|
+
toolkitRoot,
|
|
36
|
+
chain.map((manifest) => manifest.name),
|
|
37
|
+
now,
|
|
38
|
+
)
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
44
|
+
}
|