Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reuse-maintainer-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Reuse parsed planning records, source hashes, and Git source matches within maintainer operations. Keep fresh checks for later reports and writes.
5 changes: 4 additions & 1 deletion packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { dirname, relative, resolve } from 'node:path'
import { isCI } from 'std-env'
import { fail } from '../shared/cli-error.js'
import {
readRecord,
resolveMaintainerProject,
setupRecords,
} from '../maintainer/project.js'
Expand Down Expand Up @@ -186,7 +187,9 @@ export async function runMaintainerCommand(
console.log(
'For existing skills, run intent maintainer adopt to review registrations.',
)
const distribution = readDistribution(project)
const distribution = readDistribution(
readRecord(project, 'skill_tree.yaml'),
)
if (!distribution) console.log(distributionChoice)
console.log(
`Repository distribution: ${distribution?.mode ?? 'unconfigured'}. Run maintainer sync after authoring to update export metadata.`,
Expand Down
2 changes: 1 addition & 1 deletion packages/intent/src/maintainer/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export function planAddSkills(
initialChanges: Array<FileChange> = [],
) {
const changes = [...initialChanges]
const entries = skillEntries(project, changes)
const tree = readRecord(project, 'skill_tree.yaml', changes)
const entries = skillEntries(project, tree)
const map = readRecord(project, 'domain_map.yaml', changes)
const specPath = recordPath(project, 'skill_spec.md')
const specChange = changes.find((change) => change.path === specPath)
Expand Down
8 changes: 5 additions & 3 deletions packages/intent/src/maintainer/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ export function createAdoptionPlan(
return [path, existsSync(path) ? readFileSync(path, 'utf8') : null]
},
)
const hasTree = existsSync(recordPath(project, 'skill_tree.yaml'))
const entries = hasTree ? skillEntries(project) : []
const tree = existsSync(recordPath(project, 'skill_tree.yaml'))
? readRecord(project, 'skill_tree.yaml')
: undefined
const entries = tree ? skillEntries(project, tree) : []
const registered = new Map(
entries.map((entry) => [
relative(project.root, skillPath(project, entry)).replaceAll('\\', '/'),
Expand Down Expand Up @@ -232,7 +234,7 @@ export function createAdoptionPlan(
fingerprint: createHash('sha256')
.update(JSON.stringify([project, directory, snapshot, skills]))
.digest('hex'),
distribution: (hasTree ? readDistribution(project) : undefined) ?? {
distribution: (tree ? readDistribution(tree) : undefined) ?? {
mode: 'unconfigured' as const,
repository: inferDistributionRepository(project),
},
Expand Down
26 changes: 12 additions & 14 deletions packages/intent/src/maintainer/distribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
skillPath,
} from './project.js'
import { writeChanges } from './files.js'
import type { MaintainerProject } from './project.js'
import type { MaintainerProject, SkillEntry } from './project.js'
import type { FileChange } from './files.js'

export interface DistributionOptions {
Expand All @@ -34,14 +34,9 @@ const repositoryPattern =
const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

export function readDistribution(
project: MaintainerProject,
changes: ReadonlyArray<FileChange> = [],
tree: ReturnType<typeof readRecord>,
): Distribution | undefined {
const value: unknown = readRecord(
project,
'skill_tree.yaml',
changes,
).document.toJS().distribution
const value: unknown = tree.document.toJS().distribution
if (value === undefined) return undefined
if (!isObject(value) || !['repo', 'none'].includes(String(value.mode)))
throw new Error('skill_tree.yaml distribution.mode must be repo or none.')
Expand Down Expand Up @@ -108,7 +103,8 @@ export function planDistributionChoice(
}
if (!['repo', 'none'].includes(options.distribution))
throw new Error('--distribution must be repo or none.')
const previous = readDistribution(project, changes)
const tree = readRecord(project, 'skill_tree.yaml', changes)
const previous = readDistribution(tree)
let distribution: Distribution
if (options.distribution === 'none') {
if (options.repository || options.pluginName || options.skill)
Expand Down Expand Up @@ -154,7 +150,7 @@ export function planDistributionChoice(
throw new Error(
'Select public skills explicitly with --skill <name> (repeat for multiple skills).',
)
const entries = skillEntries(project, changes)
const entries = skillEntries(project, tree)
for (const selected of skills) {
const entry = entries.find(
(skill) => (skill.slug ?? skill.name) === selected,
Expand All @@ -168,7 +164,6 @@ export function planDistributionChoice(
}
distribution = { mode: 'repo', repository, name, skills }
}
const tree = readRecord(project, 'skill_tree.yaml', changes)
if (JSON.stringify(previous) === JSON.stringify(distribution)) return
tree.document.set('distribution', distribution)
return {
Expand Down Expand Up @@ -213,8 +208,12 @@ function shellCommand(args: Array<string>): string {
.join(' ')
}

export function planDistribution(project: MaintainerProject) {
const config = readDistribution(project)
export function planDistribution(
project: MaintainerProject,
tree: ReturnType<typeof readRecord>,
entries: ReadonlyArray<SkillEntry>,
) {
const config = readDistribution(tree)
const changes: Array<FileChange> = []
const problems: Array<string> = []
const commands: Array<string> = []
Expand All @@ -228,7 +227,6 @@ export function planDistribution(project: MaintainerProject) {
if (config.mode === 'none' && !config.name)
return { changes, problems, commands, mode: config.mode }
const { name, repository } = config
const entries = skillEntries(project)
const selected = config.mode === 'repo' ? config.skills! : []
const exported: Array<{ name: string; path: string }> = []
for (const selectedName of selected) {
Expand Down
8 changes: 2 additions & 6 deletions packages/intent/src/maintainer/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,9 @@ export interface SkillEntry extends Record<string, unknown> {

export function skillEntries(
project: MaintainerProject,
changes: ReadonlyArray<FileChange> = [],
tree: ReturnType<typeof readRecord>,
): Array<SkillEntry> {
const entries: Array<unknown> = readRecord(
project,
'skill_tree.yaml',
changes,
).document.toJS().skills
const entries: Array<unknown> = tree.document.toJS().skills
const names = new Set<string>()
const paths = new Set<string>()
return entries.map((entry) => {
Expand Down
10 changes: 4 additions & 6 deletions packages/intent/src/maintainer/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import type { FileChange } from './files.js'

export function planMaintainerSync(project: MaintainerProject) {
const tree = readRecord(project, 'skill_tree.yaml')
const originalTree = JSON.stringify(tree.document.toJS())
const map = readRecord(project, 'domain_map.yaml').document.toJS()
const entries = skillEntries(project)
const entries = skillEntries(project, tree)
const changes: Array<FileChange> = []
const problems: Array<string> = []
const packages = new Map<string, Array<string>>()
Expand Down Expand Up @@ -195,15 +196,12 @@ export function planMaintainerSync(project: MaintainerProject) {
if (source !== content) changes.push({ path, source, content })
}
const nextTree = tree.document.toString()
if (
JSON.stringify(tree.document.toJS()) !==
JSON.stringify(readRecord(project, 'skill_tree.yaml').document.toJS())
)
if (JSON.stringify(tree.document.toJS()) !== originalTree)
changes.push({ path: tree.path, source: tree.source, content: nextTree })
const spec = readFileSync(recordPath(project, 'skill_spec.md'), 'utf8')
if (!spec.trim() || spec.includes(authoringMarker))
problems.push('skill_spec.md still needs authored coverage and decisions.')
const distribution = planDistribution(project)
const distribution = planDistribution(project, tree, entries)
changes.push(...distribution.changes)
problems.push(...distribution.problems)
return { changes, problems, skills, distribution }
Expand Down
71 changes: 36 additions & 35 deletions packages/intent/src/review/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,6 @@ function safePath(root: string, path: string): string {
return current
}

function fileHash(root: string, path: string): string | null {
const absolute = safePath(root, path)
try {
if (!lstatSync(absolute).isFile())
throw new Error(`Cannot review non-file: ${JSON.stringify(path)}`)
return digest(readFileSync(absolute))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}

function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
Expand Down Expand Up @@ -307,23 +295,6 @@ function sourcePattern(
return `:(top,glob)${path}`
}

function snapshot(
root: string,
paths: Array<string>,
problems: Array<string>,
): Snapshot {
return Object.fromEntries(
sorted(paths).map((path) => {
try {
return [path, fileHash(root, path)]
} catch (error) {
problems.push(error instanceof Error ? error.message : String(error))
return [path, null]
}
}),
)
}

export function createReview(cwd: string, baseRef?: string): ReviewReport {
let root: string
try {
Expand Down Expand Up @@ -383,14 +354,40 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
const names = repositoryNames(root)
const covered = new Set<string>()
const items: Array<ReviewItem> = []
const hashes = new Map<string, string | null>()
const sourceMatches = new Map<string, Array<string>>()
function fileHash(path: string): string | null {
const cached = hashes.get(path)
if (cached !== undefined) return cached
const absolute = safePath(root, path)
let hash: string | null = null
try {
if (!lstatSync(absolute).isFile())
throw new Error(`Cannot review non-file: ${JSON.stringify(path)}`)
hash = digest(readFileSync(absolute))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
hashes.set(path, hash)
return hash
}
function add(
kind: ReviewItem['kind'],
path: string,
paths: Array<string>,
problems: Array<string>,
) {
const id = `${kind}:${path}`
const current = snapshot(root, paths, problems)
const current: Snapshot = Object.fromEntries(
sorted(paths).map((file) => {
try {
return [file, fileHash(file)]
} catch (error) {
problems.push(error instanceof Error ? error.message : String(error))
return [file, null]
}
}),
)
const previous = state?.items[id]
const fingerprint = reviewFingerprint(id, current, problems)
if (
Expand Down Expand Up @@ -471,7 +468,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
let frontmatter: Record<string, unknown> | null
let skillHash: string | null
try {
skillHash = fileHash(root, file)
skillHash = fileHash(file)
if (skillHash === null) continue
frontmatter = parseFrontmatter(safePath(root, file))
} catch (error) {
Expand Down Expand Up @@ -505,7 +502,11 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
if (typeof source !== 'string')
throw new Error('Source entries must be strings.')
const pattern = sourcePattern(source, packageDir, names)
const matches = sorted([...list([pattern]), ...diff([pattern])])
let matches = sourceMatches.get(pattern)
if (matches === undefined) {
matches = sorted([...list([pattern]), ...diff([pattern])])
sourceMatches.set(pattern, matches)
}
if (matches.length === 0 && !sourceMappingWasRecorded)
throw new Error(`Source matched no available files: ${source}`)
sourceFiles.push(...matches)
Expand All @@ -525,7 +526,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
].some(
(path) =>
files.includes(path) &&
fileHash(root, path) !== null &&
fileHash(path) !== null &&
readFileSync(safePath(root, path), 'utf8').includes(
'<!-- intent-maintainer:start -->',
),
Expand Down Expand Up @@ -553,7 +554,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
)
continue
}
if (fileHash(root, path) === null) {
if (fileHash(path) === null) {
problems.push(`Missing required planning record: ${path}`)
continue
}
Expand Down Expand Up @@ -595,7 +596,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
if (!id.startsWith('skill:')) continue
const path = id.slice('skill:'.length)
if (files.includes(path)) continue
if (fileHash(root, path) === null && !changed.includes(path)) {
if (fileHash(path) === null && !changed.includes(path)) {
add('source', path, [path], [])
}
}
Expand Down
31 changes: 30 additions & 1 deletion packages/intent/tests/distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ import {
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { parse, stringify } from 'yaml'
import { parse, parseDocument, stringify } from 'yaml'
import { main } from '../src/cli.js'
import type * as Yaml from 'yaml'

vi.mock('yaml', async (importOriginal) => {
const actual = await importOriginal<typeof Yaml>()
return { ...actual, parseDocument: vi.fn(actual.parseDocument) }
})

let root: string
let previousCwd: string
Expand Down Expand Up @@ -85,6 +91,29 @@ afterEach(() => {
rmSync(root, { recursive: true, force: true })
})

it('plans selected distribution from one parsed skill tree', async () => {
expect(
await main([
'maintainer',
'setup',
'--distribution',
'repo',
'--skill',
'query',
]),
).toBe(0)
const tree = read('_artifacts/skill_tree.yaml')
vi.mocked(parseDocument).mockClear()

expect(await main(['maintainer', 'sync'])).toBe(0)
expect(
vi.mocked(parseDocument).mock.calls.filter(([source]) => source === tree),
).toHaveLength(1)
expect(readJson('.claude-plugin/plugin.json').skills).toEqual([
'./packages/client/skills/query',
])
})

it('explains the missing choice, remembers an opt-out, and does not ask again', async () => {
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(
'--distribution',
Expand Down
Loading
Loading