diff --git a/.changeset/maintainer-release-hardening.md b/.changeset/maintainer-release-hardening.md new file mode 100644 index 00000000..44b643d7 --- /dev/null +++ b/.changeset/maintainer-release-hardening.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Generate commands for installed Intent, reject unsafe setup write paths before creating records, and validate distribution selections before writes. Check JavaScript library contracts and JSX examples across supported Markdown fences, and reuse validation results in maintainer reports to avoid compiling the same examples twice. diff --git a/packages/intent/meta/generate-skill/SKILL.md b/packages/intent/meta/generate-skill/SKILL.md index e575bba3..81b6e4fa 100644 --- a/packages/intent/meta/generate-skill/SKILL.md +++ b/packages/intent/meta/generate-skill/SKILL.md @@ -72,7 +72,7 @@ For new guidance and updates that change a recommended behavior, follow [task qu For new or changed descriptions, also follow [discovery checks](references/task-quality.md#check-discovery-separately). Keep activation evidence separate from task correctness. -Run `npx @tanstack/intent@latest validate ` with the actual owning package's skill directory (or the repository's installed `intent`). Fix errors without weakening validation. Keep every SKILL.md within the 500-line limit. Review packaging warnings separately; they do not require installing dependencies or changing publishing configuration during authoring. +Run `npm exec --no -- intent validate ` with the actual owning package's skill directory (or the repository's installed `intent`). Fix errors without weakening validation. Keep every SKILL.md within the 500-line limit. Review packaging warnings separately; they do not require installing dependencies or changing publishing configuration during authoring. Check that every reference and prerequisite resolves, every changed claim matches the cited source/version, and examples use actual supported APIs. Exercise the relevant example or package check where available. Intent's structural validation does not prove semantic correctness or agent behavior. If a check cannot run, report it as not verified with the reason. diff --git a/packages/intent/meta/tree-generator/SKILL.md b/packages/intent/meta/tree-generator/SKILL.md index 4f22b715..c0ea1f46 100644 --- a/packages/intent/meta/tree-generator/SKILL.md +++ b/packages/intent/meta/tree-generator/SKILL.md @@ -29,7 +29,7 @@ Every skill has a `metadata.type` field in its frontmatter. Valid types: | `composition` | Integration between two or more libraries | `electric-drizzle` | | `security` | Audit checklist or security validation | `electric-security-check` | -Agents discover skills via `npx @tanstack/intent list` and read them directly from `node_modules`. Framework skills declare a `requires` dependency on their core skill so agents load them in the right order. +Agents discover skills via `npm exec --no -- intent list` and read them directly from `node_modules`. Framework skills declare a `requires` dependency on their core skill so agents load them in the right order. There are two workflows. Detect which applies. @@ -225,7 +225,7 @@ packages/ │ └── package.json # Add "skills" to files array ``` -Publishing configuration is separate from authoring. When the maintainer requests it, `npx @tanstack/intent@latest edit-package-json` prepares the package; review its resulting diff. +Publishing configuration is separate from authoring. When the maintainer requests it, `npm exec --no -- intent edit-package-json` prepares the package; review its resulting diff. ### Steps 2–7 — Write skills diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 2de5f655..2072c425 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -50,11 +50,11 @@ Follow these steps in order: - If not found: continue to step 2. 2. DISCOVER AVAILABLE SKILLS - Run: \`npx @tanstack/intent@latest list\` + Run: \`npm exec --no -- intent list\` This scans project-local node_modules by default and outputs each package and skill's name, description, and source. If the user explicitly wants globally installed skills included, run: - \`npx @tanstack/intent@latest list --global\` + \`npm exec --no -- intent list --global\` This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop with node_modules enabled). If no skills are found, do not create a config file. Report: "No intent-enabled skills found." @@ -72,7 +72,7 @@ Follow these steps in order: - Include slash-named sub-skills when no parent mapping exists, or when they describe distinct user tasks. - If the proposed block would exceed 12 mappings, show the full discovered list and ask which packages or skill groups to include before writing. - - Add one fallback note telling the agent to run \`npx @tanstack/intent@latest list\` for less common local skills. + - Add one fallback note telling the agent to run \`npm exec --no -- intent list\` for less common local skills. Based on the repository scan and the coverage rule, propose the skill-to-task mappings. For each one explain: @@ -97,7 +97,7 @@ Follow these steps in order: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@scope/package#skill-name" - run: "npx @tanstack/intent@latest load @scope/package#skill-name" + run: "npm exec --no -- intent load @scope/package#skill-name" for: "describe the task or code area here" diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 9a43f7af..7d4ff4cb 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -1,9 +1,13 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' import { parse as parseYaml } from 'yaml' import { formatIntentCommand } from '../../shared/command-runner.js' +import { repositoryWritePath } from '../../shared/write-path.js' +import { writeChanges } from '../../maintainer/files.js' +import { parseIntentInvocation } from '../../hooks/policy.js' import { isGeneratedMappingSkill } from '../../skills/categories.js' import { formatSkillUse, parseSkillUse } from '../../skills/use.js' +import type { FileChange } from '../../maintainer/files.js' import type { ScanResult, SkillEntry } from '../../shared/types.js' type GuidanceNamespace = 'intent-skills' | 'intent-maintainer' @@ -137,10 +141,8 @@ function containsLocalPathValue(value: string): boolean { } function parseLoadedSkillUse(command: string): string | null { - const match = command.match( - /(?:^|&&|\|\||;|\|)\s*(?:bunx\s+@tanstack\/intent(?:@latest)?|pnpm\s+exec\s+intent|pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?|npx\s+@tanstack\/intent(?:@latest)?|yarn\s+dlx\s+@tanstack\/intent(?:@latest)?|intent)\s+load\s+([^\s|;&]+)/i, - ) - return match?.[1] ?? null + const invocation = parseIntentInvocation(command) + return invocation?.action === 'load' ? (invocation.skillUse ?? null) : null } export function verifyIntentSkillsBlockFile({ @@ -283,7 +285,7 @@ export function resolveIntentSkillsBlockTargetPath( root, namespace === 'intent-skills' ? 'intent-maintainer' : 'intent-skills', )?.filePath ?? - join(root, 'AGENTS.md') + repositoryWritePath(root, join(root, 'AGENTS.md')) ) } @@ -355,6 +357,7 @@ export function buildIntentSkillGuidanceBlock( INTENT_SKILLS_START, '## Skill Loading', '', + 'Use the repository’s installed Intent. If it is unavailable, report the missing dependency instead of downloading a replacement.', 'Before editing files for a substantial task:', `- Run \`${listCommand}\` from the workspace root to see available local skills.`, `- If a listed skill matches the task, run \`${loadCommand}\` before changing files.`, @@ -380,6 +383,7 @@ export function buildMaintainerGuidanceBlock( '', '## Library Skill Maintenance', '', + 'Use the repository’s installed Intent. If it is unavailable, report the missing dependency instead of downloading a replacement.', `Before substantial library source, documentation, examples, tests, or skill work, run \`${command}\` and follow the packaged maintainer procedure.`, 'Use the current request and repository evidence. For initial skills, propose a useful batch and reuse any scope already agreed with the maintainer.', `Before handing off a skill batch or library change, run \`${reviewCommand}\`. Follow the maintainer procedure to update affected guidance, run task checks, and record completed review outcomes. Report an evidence-backed no-op or missing evidence explicitly.`, @@ -410,7 +414,7 @@ function findExistingConfigWithManagedBlock( managedBlock: ManagedBlock } | null { for (const file of SUPPORTED_AGENT_CONFIG_FILES) { - const filePath = join(root, file) + const filePath = repositoryWritePath(root, join(root, file)) if (!existsSync(filePath)) continue const content = readFileSync(filePath, 'utf8') @@ -439,18 +443,23 @@ function replaceManagedBlock( return `${content.slice(0, managedBlock.start)}${styledBlock}${content.slice(managedBlock.end)}` } -export function writeIntentSkillsBlock({ +export function planIntentSkillsBlock({ block, mappingCount, root, skipWhenEmpty = true, namespace = 'intent-skills', -}: WriteIntentSkillsBlockOptions): WriteIntentSkillsBlockResult { +}: WriteIntentSkillsBlockOptions): { + result: WriteIntentSkillsBlockResult + change?: FileChange +} { if (mappingCount === 0 && skipWhenEmpty) { return { - mappingCount, - status: 'skipped', - targetPath: null, + result: { + mappingCount, + status: 'skipped', + targetPath: null, + }, } } @@ -465,17 +474,25 @@ export function writeIntentSkillsBlock({ ) if (nextContent === existingTarget.content) { return { - mappingCount, - status: 'unchanged', - targetPath, + result: { + mappingCount, + status: 'unchanged', + targetPath, + }, } } - writeFileSync(targetPath, nextContent) return { - mappingCount, - status: 'updated', - targetPath, + change: { + path: targetPath, + source: existingTarget.content, + content: nextContent, + }, + result: { + mappingCount, + status: 'updated', + targetPath, + }, } } @@ -485,19 +502,34 @@ export function writeIntentSkillsBlock({ const separator = currentContent === '' ? '' : newline const nextContent = `${withNewlineStyle(block, newline)}${separator}${currentContent}` - writeFileSync(targetPath, nextContent) return { - mappingCount, - status: 'updated', - targetPath, + change: { + path: targetPath, + source: currentContent, + content: nextContent, + }, + result: { + mappingCount, + status: 'updated', + targetPath, + }, } } - mkdirSync(dirname(targetPath), { recursive: true }) - writeFileSync(targetPath, block) return { - mappingCount, - status: 'created', - targetPath, + change: { path: targetPath, source: null, content: block }, + result: { + mappingCount, + status: 'created', + targetPath, + }, } } + +export function writeIntentSkillsBlock( + options: WriteIntentSkillsBlockOptions, +): WriteIntentSkillsBlockResult { + const plan = planIntentSkillsBlock(options) + if (plan.change) writeChanges(options.root, [plan.change]) + return plan.result +} diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index fb64f76f..9874faf1 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -1,12 +1,12 @@ import { appendFileSync } from 'node:fs' -import { dirname, relative } from 'node:path' +import { dirname, relative, resolve } from 'node:path' import { isCI } from 'std-env' import { resolveProjectContext } from '../core/project-context.js' import { fail } from '../shared/cli-error.js' import { + planSetupRecords, readRecord, resolveMaintainerProject, - setupRecords, } from '../maintainer/project.js' import { addSkill, planAddSkills } from '../maintainer/add.js' import { findExistingSkills } from '../maintainer/existing.js' @@ -15,16 +15,16 @@ import { planMaintainerSync } from '../maintainer/sync.js' import { withMaintainerLock, writeChanges } from '../maintainer/files.js' import { createReview, recordPendingReview } from '../review/review.js' import { - configureDistribution, + planDistributionChoice, readDistribution, } from '../maintainer/distribution.js' -import { runSetupGithubActions } from '../setup/index.js' +import { planSetupGithubActions } from '../setup/index.js' import { detectIntentCommandPackageManager } from '../shared/command-runner.js' import { describeSkillExamples } from '../validate/blocks.js' import { getMetaDir } from './support.js' import { buildMaintainerGuidanceBlock, - writeIntentSkillsBlock, + planIntentSkillsBlock, } from './install/guidance.js' import { runReviewCommand } from './review.js' import { runValidateCommand } from './validate.js' @@ -332,9 +332,8 @@ export async function runMaintainerCommand( if (['setup', 'add', 'remove', 'sync'].includes(action)) { await withMaintainerLock(project.root, () => { if (action === 'setup') { - const created = setupRecords(project) - configureDistribution(project, options) - const existing = findExistingSkills(project) + const created = planSetupRecords(project) + const existing = findExistingSkills(project, created) const candidates = existing.filter((skill) => !skill.problems.length) const registered = planAddSkills( project, @@ -346,7 +345,7 @@ export async function runMaintainerCommand( domain: skill.domain, }, })), - [], + created, (index, error) => { const candidate = candidates[index]! // A blank domain only comes from a domain_map.yaml entry, and @@ -360,9 +359,17 @@ export async function runMaintainerCommand( ) }, ) - writeChanges(project.root, registered.changes) - runSetupGithubActions(project.root, getMetaDir()) - writeIntentSkillsBlock({ + const distributionChange = planDistributionChoice( + project, + options, + registered.changes, + ) + const workflow = planSetupGithubActions( + project.root, + getMetaDir(), + project.artifacts, + ) + const guidance = planIntentSkillsBlock({ ...buildMaintainerGuidanceBlock( detectIntentCommandPackageManager(project.root), ), @@ -370,6 +377,16 @@ export async function runMaintainerCommand( namespace: 'intent-maintainer', skipWhenEmpty: false, }) + const changes = [ + ...registered.changes, + ...(distributionChange ? [distributionChange] : []), + ...workflow.changes, + ...(guidance.change ? [guidance.change] : []), + ] + writeChanges(project.root, [ + ...new Map(changes.map((change) => [change.path, change])).values(), + ]) + for (const message of workflow.messages) console.log(message) console.log( `Maintainer records: ${project.artifacts} (${created.length} created).`, ) @@ -448,15 +465,35 @@ export async function runMaintainerCommand( }, review, } - const headline = `${status.skills.length} skill(s), ${status.staleFiles.length} file(s) to sync, ${status.problems.length} authoring issue(s), ${review.items.length} pending review item(s).` - const examples = options.json - ? new Map() - : describeSkillExamples( - project.root, - review.items - .filter((item) => item.kind === 'skill' && !item.problems.length) - .map((item) => item.path), + const validatedExamples = new Map() + let validation: unknown + if (action === 'check') { + // Include default workspace skills and any custom registered roots. Their errors land in + // one report and one summary section, and the failure is rethrown after + // the check summary below so the two sections keep their order. + try { + await runValidateCommand( + undefined, + { githubSummary: options.githubSummary }, + plan.skills.map((path) => dirname(dirname(path))), + validatedExamples, ) + } catch (err) { + validation = err + } + } + const headline = `${status.skills.length} skill(s), ${status.staleFiles.length} file(s) to sync, ${status.problems.length} authoring issue(s), ${review.items.length} pending review item(s).` + const examples = + action === 'check' + ? validatedExamples + : options.json + ? new Map() + : describeSkillExamples( + project.root, + review.items + .filter((item) => item.kind === 'skill' && !item.problems.length) + .map((item) => item.path), + ) const lines = [ ...status.problems, ...status.staleFiles.map((path) => `Run intent maintainer sync: ${path}`), @@ -472,7 +509,9 @@ export async function runMaintainerCommand( : item.changedFiles.length ? `changed ${item.changedFiles.join(', ')}` : 'no recorded review' - const example = examples.get(item.path) + const example = examples.get( + action === 'check' ? resolve(project.root, item.path) : item.path, + ) return `${label} ${item.path}: ${detail}${example ? `; ${example}` : ''}` }), ] @@ -482,19 +521,6 @@ export async function runMaintainerCommand( for (const line of lines) console.log(` ${line}`) } if (action === 'check') { - // Include default workspace skills and any custom registered roots. Their errors land in - // one report and one summary section, and the failure is rethrown after - // the check summary below so the two sections keep their order. - let validation: unknown - try { - await runValidateCommand( - undefined, - { githubSummary: options.githubSummary }, - plan.skills.map((path) => dirname(dirname(path))), - ) - } catch (err) { - validation = err - } if (options.githubSummary) writeGithubCheckSummary({ headline, diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index de55f182..f6a600c9 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -27,7 +27,7 @@ export interface StaleTargetResult { workflowAdvisories: Array } -export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 4 +export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 5 export function getMetaDir(): string { return findMetaDir(dirname(fileURLToPath(import.meta.url))) @@ -78,7 +78,7 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { if (installedVersion >= INTENT_CHECK_SKILLS_WORKFLOW_VERSION) return [] return [ - `Intent workflow update available: run \`npx @tanstack/intent@latest setup\` to refresh ${relative(process.cwd(), workflowPath) || workflowPath}.`, + `Intent workflow update available: review ${relative(process.cwd(), workflowPath) || workflowPath}, then move it aside and run the installed \`intent setup\` to regenerate it. Setup preserves existing workflows.`, ] } diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 829ee5cb..6cc0c00a 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -9,7 +9,7 @@ import { fail, isCliFailure } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../setup/workspace-patterns.js' import { createIntentFsCache } from '../discovery/fs-cache.js' -import { checkSkillBlocks } from '../validate/blocks.js' +import { checkSkillBlocks, summarizeSkillExamples } from '../validate/blocks.js' import { printWarnings } from './support.js' import type { ProjectContext } from '../core/project-context.js' @@ -406,6 +406,7 @@ export async function runValidateCommand( dir?: string | Array, options: ValidateCommandOptions = {}, additionalDirs: Array = [], + exampleSummaries?: Map, ): Promise { if (options.fix && options.check) { fail('Cannot combine --fix and --check') @@ -424,12 +425,22 @@ export async function runValidateCommand( } if (!options.githubSummary) { - await runValidateCommandInternal(dir, options, additionalDirs) + await runValidateCommandInternal( + dir, + options, + additionalDirs, + exampleSummaries, + ) return } try { - await runValidateCommandInternal(dir, options, additionalDirs) + await runValidateCommandInternal( + dir, + options, + additionalDirs, + exampleSummaries, + ) writeGithubValidationSummary({ ok: true }) } catch (err) { writeGithubValidationSummary({ @@ -444,6 +455,7 @@ async function runValidateCommandInternal( dir?: string | Array, options: ValidateCommandOptions = {}, additionalDirs: Array = [], + exampleSummaries?: Map, ): Promise { const [{ parse: parseYaml }, { readScalarField }] = await Promise.all([ import('yaml'), @@ -673,6 +685,9 @@ async function runValidateCommandInternal( library, skills, }) + if (exampleSummaries) + for (const [file, summary] of summarizeSkillExamples(result, skills)) + exampleSummaries.set(resolve(process.cwd(), file), summary) if (result.skipped) skippedBlockChecks.add(result.skipped) for (const finding of result.findings) { if (finding.severity === 'error') @@ -763,11 +778,16 @@ async function runValidateCommandInternal( await applyFrontmatterFixes(fixPlans) console.log(`✅ Fixed ${fixPlans.length} skill files`) } - await runValidateCommandInternal(dir, { - ...options, - fix: false, - setVersion: undefined, - }) + await runValidateCommandInternal( + dir, + { + ...options, + fix: false, + setVersion: undefined, + }, + additionalDirs, + exampleSummaries, + ) return } @@ -811,7 +831,7 @@ function writeGithubValidationSummary({ 'Run locally:', '', '```bash', - 'npx @tanstack/intent@latest validate', + 'intent validate', '```', '', 'Command output:', diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index 9a5e5aa3..c0580c1b 100644 --- a/packages/intent/src/hooks/policy.ts +++ b/packages/intent/src/hooks/policy.ts @@ -26,7 +26,7 @@ export function parseIntentInvocation( // `node_modules/.bin/intent`, which the session catalog suggests when the // project has the CLI installed. const match = command.match( - /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+@tanstack\/intent(?:@latest)?)|(?:pnpm\s+exec\s+intent)|(?:pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:npx\s+@tanstack\/intent(?:@latest)?)|(?:yarn\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:(?:[^\s|;&]*[\\/])?intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i, + /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+--no-install\s+--package\s+@tanstack\/intent\s+intent)|(?:npm\s+exec\s+--no\s+--\s+intent)|(?:yarn\s+exec\s+intent)|(?:bunx\s+@tanstack\/intent(?:@latest)?)|(?:pnpm\s+exec\s+intent)|(?:pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:npx\s+@tanstack\/intent(?:@latest)?)|(?:yarn\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:(?:[^\s|;&]*[\\/])?intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i, ) if (!match?.[1] || !match[2]) { diff --git a/packages/intent/src/maintainer/distribution.ts b/packages/intent/src/maintainer/distribution.ts index 7c3cc316..6e6d247b 100644 --- a/packages/intent/src/maintainer/distribution.ts +++ b/packages/intent/src/maintainer/distribution.ts @@ -11,7 +11,6 @@ import { skillEntries, skillPath, } from './project.js' -import { writeChanges } from './files.js' import type { MaintainerProject, SkillEntry } from './project.js' import type { FileChange } from './files.js' @@ -76,15 +75,7 @@ function inferDistributionRepository(project: MaintainerProject): string { : '' } -export function configureDistribution( - project: MaintainerProject, - options: DistributionOptions, -): void { - const change = planDistributionChoice(project, options) - if (change) writeChanges(project.root, [change]) -} - -function planDistributionChoice( +export function planDistributionChoice( project: MaintainerProject, options: DistributionOptions, changes: ReadonlyArray = [], diff --git a/packages/intent/src/maintainer/existing.ts b/packages/intent/src/maintainer/existing.ts index 5f3178c6..0434ca06 100644 --- a/packages/intent/src/maintainer/existing.ts +++ b/packages/intent/src/maintainer/existing.ts @@ -14,6 +14,7 @@ import { skillPath, } from './project.js' import type { MaintainerProject } from './project.js' +import type { FileChange } from './files.js' export interface ExistingSkill { id: string @@ -31,6 +32,7 @@ const defaultDomain = 'uncategorized' // dependencies, and packages outside the workspace are not library skills. export function findExistingSkills( project: MaintainerProject, + changes: ReadonlyArray = [], ): Array { const files = execFileSync( 'git', @@ -47,7 +49,7 @@ export function findExistingSkills( ) .split('\0') .filter(Boolean) - const tree = readRecord(project, 'skill_tree.yaml') + const tree = readRecord(project, 'skill_tree.yaml', changes) const entries = skillEntries(project, tree) const registered = new Set( entries.map((entry) => @@ -58,9 +60,10 @@ export function findExistingSkills( entries.map((entry) => String(entry.slug ?? entry.name)), ) const mapPath = recordPath(project, 'domain_map.yaml') - const mapped: Array = existsSync(mapPath) - ? readRecord(project, 'domain_map.yaml').document.toJS().skills - : [] + const mapped: Array = + existsSync(mapPath) || changes.some((change) => change.path === mapPath) + ? readRecord(project, 'domain_map.yaml', changes).document.toJS().skills + : [] const packageRoots = new Set([ project.root, ...resolveWorkspacePackages( diff --git a/packages/intent/src/maintainer/project.ts b/packages/intent/src/maintainer/project.ts index fc01fd02..e3eba691 100644 --- a/packages/intent/src/maintainer/project.ts +++ b/packages/intent/src/maintainer/project.ts @@ -3,7 +3,6 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' import { parseDocument, stringify } from 'yaml' import { resolveProjectContext } from '../core/project-context.js' -import { writeChanges } from './files.js' import type { FileChange } from './files.js' export const authoringMarker = '' @@ -165,15 +164,9 @@ export function skillPath( return path } -export function setupRecords(project: MaintainerProject): Array { - const changes = planSetupRecords(project) - writeChanges(project.root, changes) - return changes.map((change) => - relative(project.root, change.path).replaceAll('\\', '/'), - ) -} - -function planSetupRecords(project: MaintainerProject): Array { +export function planSetupRecords( + project: MaintainerProject, +): Array { const { root } = project const context = resolveProjectContext({ cwd: root }) if (!context.packageRoot) diff --git a/packages/intent/src/setup/project-setup.ts b/packages/intent/src/setup/project-setup.ts index 8594b5f4..c646dc49 100644 --- a/packages/intent/src/setup/project-setup.ts +++ b/packages/intent/src/setup/project-setup.ts @@ -444,7 +444,11 @@ export function runEditPackageJsonAll( // Command: setup-github-actions // --------------------------------------------------------------------------- -function planSetupGithubActions(root: string, metaDir: string, artifacts = '') { +export function planSetupGithubActions( + root: string, + metaDir: string, + artifacts = '', +) { const workspaceRoot = findWorkspaceRoot(root) ?? root const packageDirs = findPackagesWithSkills(workspaceRoot) const vars = detectVars( diff --git a/packages/intent/src/shared/command-runner.ts b/packages/intent/src/shared/command-runner.ts index a908a610..51eb4496 100644 --- a/packages/intent/src/shared/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -4,11 +4,11 @@ import type { PackageManager } from './types.js' export { detectPackageManager as detectIntentCommandPackageManager } const runnerByPackageManager: Record = { - bun: 'bunx @tanstack/intent@latest', - npm: 'npx @tanstack/intent@latest', - pnpm: 'pnpm dlx @tanstack/intent@latest', - unknown: 'npx @tanstack/intent@latest', - yarn: 'yarn dlx @tanstack/intent@latest', + bun: 'bunx --no-install --package @tanstack/intent intent', + npm: 'npm exec --no -- intent', + pnpm: 'pnpm exec intent', + unknown: 'npm exec --no -- intent', + yarn: 'yarn exec intent', } /** Use argument arrays for discovered identifiers; strings are trusted templates. */ diff --git a/packages/intent/src/skills/paths.ts b/packages/intent/src/skills/paths.ts index 9dc68e61..6980a02e 100644 --- a/packages/intent/src/skills/paths.ts +++ b/packages/intent/src/skills/paths.ts @@ -6,7 +6,7 @@ import type { SkillUse } from './use.js' import type { SkillEntry } from '../shared/types.js' const RUNTIME_SKILL_LOOKUP_COMMENT_PATTERN = - /^Runtime lookup only: run `npx @tanstack\/intent@latest load [^`]+ --path`, and load its reported path for this session\. Do not copy the resolved path into this file\.$/ + /^Runtime lookup only: run `(?:npx @tanstack\/intent@latest|npm exec --no -- intent) load [^`]+ --path`, and load its reported path for this session\. Do not copy the resolved path into this file\.$/ export function isAbsolutePath(path: string): boolean { return ( diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index 1eb9ecf6..a5e732a4 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -30,8 +30,45 @@ interface CodeBlock { extension: 'ts' | 'tsx' | 'js' | 'jsx' } -const codeFence = - /^ {0,3}(`{3,}|~{3,})[ \t]*([A-Za-z0-9_-]*)[^\n]*\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm +// Scan whole fences before selecting languages: a Markdown example can contain +// shorter fences, closing fences may be longer, and EOF also closes a fence. +function codeFences(content: string) { + const lines = content.split(/\r?\n/) + const fences: Array<{ + start: number + end: number + language: string + code: string + }> = [] + for (let start = 0; start < lines.length; start++) { + const opening = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(lines[start]!) + if (!opening || (opening[2]![0] === '`' && opening[3]!.includes('`'))) + continue + const marker = opening[2]! + let end = start + 1 + for (; end < lines.length; end++) { + const closing = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(lines[end]!) + if ( + closing && + closing[1]![0] === marker[0] && + closing[1]!.length >= marker.length + ) + break + } + const dedent = new RegExp(`^ {0,${opening[1]!.length}}`) + fences.push({ + start, + end, + language: opening[3]!.trim().split(/\s+/)[0]!.toLowerCase(), + code: lines + .slice(start + 1, end) + .map((line) => line.replace(dedent, '')) + .join('\n'), + }) + start = end + } + return fences +} const checkedLanguages = new Set([ 'ts', 'tsx', @@ -48,7 +85,7 @@ const markdownLink = /\[[^\]]*\]\((?:<([^>]*)>|([^)\s]+))(?:\s+"[^"]*")?\)/g // contract or a genuinely broken example. const partialSnippetCodes = new Set([ 1375, 2304, 2318, 2503, 2552, 2580, 2581, 2582, 2583, 2584, 2591, 2592, 2593, - 2602, 2686, 2688, 7006, 7026, 7031, 17004, + 2602, 2686, 2688, 7006, 7026, 7031, 17004, 18004, ]) const missingModuleCodes = new Set([2307, 2792]) @@ -65,17 +102,17 @@ function loadTypeScript(root: string): typeof TS | null { function extractCodeBlocks(file: string, content: string): Array { const blocks: Array = [] - for (const match of content.matchAll(codeFence)) { - const language = match[2]!.toLowerCase() + for (const fence of codeFences(content)) { + const language = fence.language if (!checkedLanguages.has(language)) continue - const line = content.slice(0, match.index).split('\n').length + 1 + const line = fence.start + 2 const extension = language === 'tsx' || language === 'jsx' ? language : language.startsWith('j') ? 'js' : 'ts' - blocks.push({ file, line, code: match[3]!, extension }) + blocks.push({ file, line, code: fence.code, extension }) } return blocks } @@ -88,9 +125,15 @@ function checkSkillLinks( const findings: Array = [] const absolute = resolve(root, file) // Blank out fenced examples, keeping newlines so line numbers still match. - const prose = content.replace(codeFence, (block) => - block.replace(/[^\n]/g, ' '), - ) + const lines = content.split(/\r?\n/) + for (const fence of codeFences(content)) + for ( + let index = fence.start; + index <= fence.end && index < lines.length; + index++ + ) + lines[index] = '' + const prose = lines.join('\n') for (const match of prose.matchAll(markdownLink)) { const target = match[1] ?? match[2]! if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) continue @@ -99,7 +142,7 @@ function checkSkillLinks( if (!existsSync(resolve(dirname(absolute), path))) findings.push({ file, - line: content.slice(0, match.index).split('\n').length, + line: prose.slice(0, match.index).split('\n').length, message: `Link target not found: ${target}`, severity: 'error', }) @@ -129,7 +172,15 @@ function libraryEntry(packageDir: string): string | null { isRecord(exportsRoot) && isRecord(exportsRoot.import) ? exportsRoot.import.types : undefined, + isRecord(exportsRoot) && isRecord(exportsRoot.require) + ? exportsRoot.require.types + : undefined, typeof exportsRoot === 'string' ? exportsRoot : undefined, + isRecord(exportsRoot) ? exportsRoot.import : undefined, + isRecord(exportsRoot) ? exportsRoot.require : undefined, + isRecord(exportsRoot) ? exportsRoot.default : undefined, + manifest.module, + manifest.main, ].find((value): value is string => typeof value === 'string') const candidates: Array = [] if (declared) { @@ -146,9 +197,26 @@ function libraryEntry(packageDir: string): string | null { `src/${name}.d.ts`, `src/${name}.d.cts`, `src/${name}.d.mts`, + `src/${name}.js`, + `src/${name}.jsx`, + `src/${name}.mjs`, + `src/${name}.cjs`, ) } - candidates.push('src/index.ts', 'src/index.tsx', 'index.ts', 'index.d.ts') + candidates.push( + 'src/index.ts', + 'src/index.tsx', + 'index.ts', + 'index.d.ts', + 'src/index.js', + 'src/index.jsx', + 'src/index.mjs', + 'src/index.cjs', + 'index.js', + 'index.jsx', + 'index.mjs', + 'index.cjs', + ) for (const candidate of candidates) { const path = resolve(packageDir, candidate) if (existsSync(path)) return path @@ -260,6 +328,9 @@ export function checkSkillBlocks( const compilerOptions: TS.CompilerOptions = { noEmit: true, strict: false, + // Router and other conditional APIs require null and undefined to stay + // distinct. Partial examples still tolerate omitted names and implicit any. + strictNullChecks: true, skipLibCheck: true, allowJs: true, checkJs: true, @@ -414,20 +485,31 @@ export function describeSkillExamples( library: group.library, skills, }) - if (result.skipped) continue - for (const skill of skills) { - if (!extractCodeBlocks(skill.file, skill.content).length) continue - const errors = result.findings.filter( - (finding) => - finding.file === skill.file && finding.severity === 'error', - ) - summaries.set( - skill.file, - errors.length - ? `${errors.length} example error(s), first at line ${errors[0]!.line}` - : 'examples still compile', - ) - } + for (const [file, summary] of summarizeSkillExamples(result, skills)) + summaries.set(file, summary) + } + return summaries +} + +// Share the result of validation with the maintainer report, without retaining +// compiler state across invocations or skipping any validation roots. +export function summarizeSkillExamples( + result: SkillBlockCheck, + skills: ReadonlyArray<{ file: string; content: string }>, +): Map { + const summaries = new Map() + if (result.skipped) return summaries + for (const skill of skills) { + if (!extractCodeBlocks(skill.file, skill.content).length) continue + const errors = result.findings.filter( + (finding) => finding.file === skill.file && finding.severity === 'error', + ) + summaries.set( + skill.file, + errors.length + ? `${errors.length} example error(s), first at line ${errors[0]!.line}` + : 'examples still compile', + ) } return summaries } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index a0c0255b..d86c37aa 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -324,7 +324,7 @@ describe('cli commands', () => { expect(output).toContain('tanstackIntent:') expect(output).toContain(' - id: "@scope/package#skill-name"') expect(output).toContain( - ' run: "npx @tanstack/intent@latest load @scope/package#skill-name"', + ' run: "npm exec --no -- intent load @scope/package#skill-name"', ) expect(output).toContain(' for: "describe the task or code area here"') expect(output).not.toContain('skills:\n - when:') @@ -471,7 +471,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with skill loading guidance.') expect(content).toContain('## Skill Loading') - expect(content).toContain('npx @tanstack/intent@latest list') + expect(content).toContain('npm exec --no -- intent list') expect(content).toContain('If a listed skill matches the task') expect(content).toContain('before changing files') expect(content).toContain('Monorepos:') @@ -660,7 +660,7 @@ describe('cli commands', () => { 'Created AGENTS.md with skill loading guidance.', ) expect(output).toContain('Available: 1 skill from 1 package.') - expect(output).toContain('Next: npx @tanstack/intent@latest list') + expect(output).toContain('Next: npm exec --no -- intent list') expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( '## Skill Loading', ) @@ -889,10 +889,8 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Generated skill loading guidance for AGENTS.md.') - expect(output).toContain('npx @tanstack/intent@latest list') - expect(output).toContain( - 'npx @tanstack/intent@latest load #', - ) + expect(output).toContain('npm exec --no -- intent list') + expect(output).toContain('npm exec --no -- intent load #') expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) @@ -949,10 +947,8 @@ describe('cli commands', () => { const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(output).toContain('pnpm dlx @tanstack/intent@latest list') - expect(output).toContain( - 'pnpm dlx @tanstack/intent@latest load #', - ) + expect(output).toContain('pnpm exec intent list') + expect(output).toContain('pnpm exec intent load #') }) it('writes skill loading guidance even with no discovered skills', async () => { @@ -976,7 +972,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with skill loading guidance.') expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( - 'npx @tanstack/intent@latest list', + 'npm exec --no -- intent list', ) }) @@ -1064,7 +1060,7 @@ describe('cli commands', () => { expect(content).toContain('for: "Query data fetching patterns"') expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).toContain( - 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', + 'run: "npm exec --no -- intent load @tanstack/query#fetching"', ) expect(content).not.toContain('load:') expect(content).not.toContain(root) @@ -1418,10 +1414,10 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching', + 'Load: npm exec --no -- intent load @tanstack/query#fetching', ) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#query/cache', + 'Load: npm exec --no -- intent load @tanstack/query#query/cache', ) }) @@ -1541,9 +1537,9 @@ describe('cli commands', () => { }) it.each([ - ['pnpm-lock.yaml', 'pnpm dlx @tanstack/intent@latest'], - ['yarn.lock', 'yarn dlx @tanstack/intent@latest'], - ['bun.lock', 'bunx @tanstack/intent@latest'], + ['pnpm-lock.yaml', 'pnpm exec intent'], + ['yarn.lock', 'yarn exec intent'], + ['bun.lock', 'bunx --no-install --package @tanstack/intent intent'], ])( 'prints %s load commands for human list output', async (lockfile, runner) => { @@ -1867,7 +1863,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Global fetching skill') expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching --global', + 'Load: npm exec --no -- intent load @tanstack/query#fetching --global', ) expect(output).not.toContain(globalPkgDir) }) diff --git a/packages/intent/tests/discovery-safety.test.ts b/packages/intent/tests/discovery-safety.test.ts index 6eddf249..86e2bd67 100644 --- a/packages/intent/tests/discovery-safety.test.ts +++ b/packages/intent/tests/discovery-safety.test.ts @@ -89,7 +89,7 @@ describe('discovered command arguments', () => { const hintCommand = hint.split('`')[1]! const runners = ['npm', 'pnpm', 'yarn', 'bun', 'unknown'] as const const stubs = - 'npx() { printf "%s\\n" "$@"; }; pnpm() { printf "%s\\n" "$@"; }; yarn() { printf "%s\\n" "$@"; }; bunx() { printf "%s\\n" "$@"; }; ' + 'npm() { printf "%s\\n" "$@"; }; pnpm() { printf "%s\\n" "$@"; }; yarn() { printf "%s\\n" "$@"; }; bunx() { printf "%s\\n" "$@"; }; ' for (const shell of ['/bin/sh', '/bin/bash', '/bin/zsh'].filter( existsSync, )) { @@ -105,8 +105,11 @@ describe('discovered command arguments', () => { .trim() .split('\n') expect(actual).toEqual([ - ...(runner === 'pnpm' || runner === 'yarn' ? ['dlx'] : []), - '@tanstack/intent@latest', + ...(runner === 'bun' + ? ['--no-install', '--package', '@tanstack/intent', 'intent'] + : runner === 'pnpm' || runner === 'yarn' + ? ['exec', 'intent'] + : ['exec', '--no', '--', 'intent']), 'load', use, '--global', @@ -116,12 +119,12 @@ describe('discovered command arguments', () => { execFileSync(shell, ['-c', stubs + command], { encoding: 'utf8' }) .trim() .split('\n'), - ).toEqual(['@tanstack/intent@latest', 'load', use]) + ).toEqual(['exec', '--no', '--', 'intent', 'load', use]) expect( execFileSync(shell, ['-c', stubs + hintCommand], { encoding: 'utf8' }) .trim() .split('\n'), - ).toEqual(['@tanstack/intent@latest', 'load', use, '--path']) + ).toEqual(['exec', '--no', '--', 'intent', 'load', use, '--path']) } }, ) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 7d89b23b..5293d114 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -48,6 +48,9 @@ describe('hook installer', () => { : { hookSpecificOutput: { permissionDecision: 'deny' } } const commands = [ ['intent list', true], + ['npm exec --no -- intent list', true], + ['yarn exec intent load @tanstack/router#routing', true], + ['bunx --no-install --package @tanstack/intent intent list', true], ['pnpm exec intent load @tanstack/router#routing', true], ['pnpm dlx @tanstack/intent@latest list --json', true], ['npx @tanstack/intent@latest load @tanstack/router#routing', true], @@ -84,7 +87,7 @@ describe('hook installer', () => { else expect(JSON.parse(after.stdout)).toMatchObject(denial) } }, - // Each case launches at least 30 real Node processes. + // Each case launches 39 real Node processes, including three for each runner. 30_000, ) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index e96adee1..bd56f5ec 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -95,7 +95,7 @@ const exampleBlock = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm exec intent load @tanstack/query#fetching" for: "Query data fetching" ` @@ -106,7 +106,7 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(0) expect(generated.block).toContain('## Skill Loading') - expect(generated.block).toContain('npx @tanstack/intent@latest list') + expect(generated.block).toContain('npm exec --no -- intent list') expect(generated.block).toContain('If a listed skill matches the task') expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') @@ -118,10 +118,8 @@ describe('install writer block builder', () => { it('builds package-manager-specific loading guidance', () => { const generated = buildIntentSkillGuidanceBlock('pnpm') - expect(generated.block).toContain('pnpm dlx @tanstack/intent@latest list') - expect(generated.block).toContain( - 'pnpm dlx @tanstack/intent@latest load #', - ) + expect(generated.block).toContain('pnpm exec intent list') + expect(generated.block).toContain('pnpm exec intent load #') }) it('builds a deterministic compact block', () => { @@ -160,13 +158,13 @@ describe('install writer block builder', () => { # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm exec intent load @tanstack/query#fetching" for: "Query data fetching patterns" - id: "@tanstack/query#mutations" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" + run: "pnpm exec intent load @tanstack/query#mutations" for: "Mutation patterns" - id: "@tanstack/router#routing" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" + run: "pnpm exec intent load @tanstack/router#routing" for: "Routing patterns" `) @@ -197,7 +195,7 @@ tanstackIntent: expect(generated.block).toContain('id: "@tanstack/query#global-fetching"') expect(generated.block).toContain('id: "@tanstack/query#pnpm-fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', + 'run: "pnpm exec intent load @tanstack/query#global-fetching"', ) expect(generated.block).not.toContain('/home/sarah') expect(generated.block).not.toContain('node_modules/.pnpm') @@ -236,12 +234,12 @@ tanstackIntent: expect(generated.block).toContain('for: "Core skill"') expect(generated.block).toContain('id: "@tanstack/query#core"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', + 'run: "pnpm exec intent load @tanstack/query#core"', ) expect(generated.block).toContain('for: "Sub-skill"') expect(generated.block).toContain('id: "@tanstack/query#core/fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', + 'run: "pnpm exec intent load @tanstack/query#core/fetching"', ) expect(generated.block).not.toContain('Reference material') expect(generated.block).not.toContain('Maintainer task') diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index abfc0b6d..f7533060 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -217,10 +217,10 @@ describe('packed release', () => { expect(result.stdout).toContain(`name: ${name}\n`) if (name === 'domain-discovery') { expect(result.stdout).toContain( - `](${join(installedRoot, 'meta', name, 'references', 'deep-read.md')})`, + `](${join(installedRoot, 'meta', name, 'references', 'deep-read.md').replaceAll('\\', '/')})`, ) expect(result.stdout).toContain( - `](${join(installedRoot, 'meta', name, 'references', 'artifacts.md')})`, + `](${join(installedRoot, 'meta', name, 'references', 'artifacts.md').replaceAll('\\', '/')})`, ) } const links: Array = markdownLinkExtractor(result.stdout) @@ -228,7 +228,10 @@ describe('packed release', () => { if (/^(https?:|#)/.test(link)) continue const target = link.split('#')[0]! expect(isAbsolute(target), link).toBe(true) - expect(target.startsWith(join(installedRoot, 'meta')), link).toBe(true) + expect( + resolve(target).startsWith(join(installedRoot, 'meta')), + link, + ).toBe(true) expect(statSync(target).isFile(), link).toBe(true) } } @@ -463,7 +466,7 @@ Existing fixture guidance, pending source review. ) expect(body).toContain('### Agent Review') expect(body).not.toContain('Paste this into your coding agent') - expect(body).toContain('npx @tanstack/intent@latest meta generate-skill') + expect(body).toContain('npm exec --no -- intent meta generate-skill') // Execute the advertised meta command with this extracted release. const procedure = run(['meta', 'generate-skill']) @@ -476,7 +479,7 @@ Existing fixture guidance, pending source review. 'references', 'review-signals.md', ) - expect(links).toContain(reviewReference) + expect(links).toContain(reviewReference.replaceAll('\\', '/')) const guidance = readFileSync(reviewReference, 'utf8') expect(guidance).toContain('stale-check-failed') expect(guidance).toContain('workflow-advisory') diff --git a/packages/intent/tests/maintainer-install.test.ts b/packages/intent/tests/maintainer-install.test.ts index 350c0d07..48b15707 100644 --- a/packages/intent/tests/maintainer-install.test.ts +++ b/packages/intent/tests/maintainer-install.test.ts @@ -81,7 +81,7 @@ describe('maintainer installation', () => { const manifest = readFileSync('package.json', 'utf8') expect(await main(['install', '--maintainer', '--dry-run'])).toBe(0) expect(log.mock.calls.flat().join('\n')).toContain( - 'pnpm dlx @tanstack/intent@latest meta generate-skill', + 'pnpm exec intent meta generate-skill', ) expect(existsSync('AGENTS.md')).toBe(false) expect(readFileSync('package.json', 'utf8')).toBe(manifest) diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index 8ff7eb63..80dc85ba 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -62,6 +63,38 @@ beforeEach(() => { ) }) +it('keeps the selected planning directory in the generated CI caller', async () => { + expect( + await main([ + 'maintainer', + 'setup', + '--artifacts', + 'planning records', + '--distribution', + 'none', + ]), + ).toBe(0) + expect( + parse(read('.github/workflows/check-skills.yml')).jobs.validate.with + .artifacts, + ).toBe('planning records') +}) + +it('rejects Actions expressions in the planning directory before setup writes', async () => { + expect( + await main([ + 'maintainer', + 'setup', + '--artifacts', + '${{ secrets.TOKEN }}', + '--distribution', + 'none', + ]), + ).toBe(1) + expect(existsSync(join(root, '${{ secrets.TOKEN }}'))).toBe(false) + expect(existsSync(join(root, '.github'))).toBe(false) +}) + it('checks the authored workflow, rejects stale outcomes, and reopens after source edits', async () => { write('src/query.ts', 'export const query = () => 1\n') expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) @@ -189,6 +222,110 @@ it('rejects paths outside the repository and symlinked directories before changi } }) +it.each(['instructions', 'workflow directory', 'dangling workflow'])( + 'rejects an escaping %s link before setup writes records', + async (target) => { + const outside = mkdtempSync(join(tmpdir(), 'intent-setup-outside-')) + try { + writeFileSync(join(outside, 'instructions.md'), '# Preserve me\n') + if (target === 'instructions') + symlinkSync(join(outside, 'instructions.md'), join(root, 'AGENTS.md')) + else if (target === 'workflow directory') + symlinkSync(outside, join(root, '.github')) + else { + mkdirSync(join(root, '.github/workflows'), { recursive: true }) + symlinkSync( + join(outside, 'workflow.yml'), + join(root, '.github/workflows/check-skills.yml'), + ) + } + expect(await main(['maintainer', 'setup'])).toBe(1) + expect(readFileSync(join(outside, 'instructions.md'), 'utf8')).toBe( + '# Preserve me\n', + ) + expect(existsSync(join(outside, 'workflow.yml'))).toBe(false) + expect(existsSync(join(outside, 'workflows'))).toBe(false) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }, +) + +it('rejects invalid distribution inputs before creating any planning records', async () => { + for (const args of [ + ['--distribution', 'invalid'], + ['--distribution', 'none', '--skill', 'query'], + [ + '--distribution', + 'repo', + '--skill', + 'missing', + '--repository', + 'owner/library', + ], + ]) { + expect(await main(['maintainer', 'setup', ...args])).toBe(1) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(existsSync(join(root, '.github'))).toBe(false) + } +}) + +it('preserves a safe AGENTS alias while updating its repository destination', async () => { + write('CLAUDE.md', '# Existing instructions\n') + symlinkSync('CLAUDE.md', join(root, 'AGENTS.md')) + expect(await main(['maintainer', 'setup'])).toBe(0) + expect(lstatSync(join(root, 'AGENTS.md')).isSymbolicLink()).toBe(true) + expect(read('CLAUDE.md')).toContain('# Existing instructions\n') + expect(read('CLAUDE.md')).toContain('') + expect(await main(['maintainer', 'setup'])).toBe(0) + expect( + read('CLAUDE.md').match(//g), + ).toHaveLength(1) +}) + +it.each(['.git/config', 'node_modules/instructions.md', 'missing.md'])( + 'rejects an instruction alias to %s before any setup writes', + async (target) => { + if (target.startsWith('node_modules')) + write(target, '# Dependency instructions\n') + const before = existsSync(join(root, target)) ? read(target) : undefined + symlinkSync(target, join(root, 'AGENTS.md')) + expect(await main(['maintainer', 'setup'])).toBe(1) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, '.github'))).toBe(false) + if (before !== undefined) expect(read(target)).toBe(before) + else expect(existsSync(join(root, target))).toBe(false) + }, +) + +it('can select an existing skill for distribution during first setup', async () => { + write( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Use when querying.\nsources: [package.json]\n---\nRead the package.\n', + ) + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + '--repository', + 'owner/library', + ]), + ).toBe(0) + expect( + parse(read('skills/_artifacts/skill_tree.yaml')).distribution, + ).toMatchObject({ + mode: 'repo', + repository: 'owner/library', + skills: ['query'], + }) +}) + it('rejects cyclic prerequisites without applying an otherwise valid package update', async () => { expect(await main(['maintainer', 'setup'])).toBe(0) for (const [name, dependency] of [ @@ -274,8 +411,10 @@ it('validates unregistered workspace skills as well as custom registered roots', ) expect(await main(['maintainer', 'check'])).toBe(1) const errors = vi.mocked(console.error).mock.calls.flat().join('\n') - expect(errors).toContain('guidance/query/SKILL.md') - expect(errors).toContain('packages/client/skills/missing/SKILL.md') + expect(errors).toContain(join('guidance', 'query', 'SKILL.md')) + expect(errors).toContain( + join('packages', 'client', 'skills', 'missing', 'SKILL.md'), + ) }) it('writes the check report to the GitHub step summary', async () => { diff --git a/packages/intent/tests/review.test.ts b/packages/intent/tests/review.test.ts index bacefa7b..7741909e 100644 --- a/packages/intent/tests/review.test.ts +++ b/packages/intent/tests/review.test.ts @@ -190,17 +190,25 @@ it('explains an existing recording lock without deleting another writer’s lock ).toBe('another writer') }) -it('reopens source edits and remembers their review before and after commit', () => { - accept() - write('src/request.ts', 'export const attempts = 4\n') - const report = createReview(root) - expect(report.items[0]?.changedFiles).toEqual(['src/request.ts']) - accept(report) - expect(createReview(root).items).toEqual([]) - git('add', 'src/request.ts') - git('commit', '-qm', 'changed') - expect(createReview(root).items).toEqual([]) -}) +it.each(['ts', 'tsx', 'js', 'jsx'])( + 'reopens %s source edits and remembers their review before and after commit', + (extension) => { + const source = `src/request.${extension}` + write(source, 'export const attempts = 3\n') + skill([`acme/library:src/**/*.${extension}`]) + git('add', '.') + git('commit', '--allow-empty', '-qm', 'source format') + accept() + write(source, 'export const attempts = 4\n') + const report = createReview(root) + expect(report.items[0]?.changedFiles).toEqual([source]) + accept(report) + expect(createReview(root).items).toEqual([]) + git('add', source) + git('commit', '-qm', 'changed') + expect(createReview(root).items).toEqual([]) + }, +) it('reopens changes to a skill reference', () => { accept() diff --git a/packages/intent/tests/skill-paths.test.ts b/packages/intent/tests/skill-paths.test.ts index 162af170..4759608a 100644 --- a/packages/intent/tests/skill-paths.test.ts +++ b/packages/intent/tests/skill-paths.test.ts @@ -136,7 +136,7 @@ describe('skill path helpers', () => { const hint = formatRuntimeSkillLookupHint(target) expect(comment).toContain( - 'npx @tanstack/intent@latest load @tanstack/query#query-core/fetching --path', + 'npm exec --no -- intent load @tanstack/query#query-core/fetching --path', ) expect(comment).toContain('Do not copy the resolved path into this file.') expect(comment).not.toContain('grep') @@ -146,7 +146,7 @@ describe('skill path helpers', () => { expect(isRuntimeSkillLookupComment(`# ${comment}`)).toBe(true) expect( isRuntimeSkillLookupComment( - 'Runtime lookup only: run `npx @tanstack/intent@latest load foo#bar`.', + 'Runtime lookup only: run `npm exec --no -- intent load foo#bar`.', ), ).toBe(false) }) diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index d2090e40..20db52a0 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -278,7 +278,7 @@ describe('getCheckSkillsWorkflowAdvisories', () => { ) expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([ - expect.stringContaining('npx @tanstack/intent@latest setup'), + expect.stringContaining('run the installed `intent setup`'), ]) }) diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 8cde1c19..45d1e06a 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -12,6 +12,7 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { main } from '../src/cli.js' import { checkSkillBlocks } from '../src/validate/blocks.js' +import * as blockChecks from '../src/validate/blocks.js' // Typechecking examples against a real package takes longer than a unit test. vi.setConfig({ testTimeout: 30_000 }) @@ -80,18 +81,52 @@ it('accepts a partial example whose only gaps are names the snippet leaves out', expect(result.findings).toEqual([]) }) -it('parses a plain ts block as TypeScript rather than TSX', () => { +it('supports APIs that require strict null checks while rejecting null arguments', () => { + write( + 'src/index.ts', + "export declare function createRouter(options: undefined extends number ? 'strictNullChecks must be enabled' : { routeTree: object }): void\n", + ) skill( - "```ts\nimport { retry } from '@acme/client'\nconst pick = (value: T) => value\nawait retry(() => Promise.resolve(), { max: pick(3) })\n```\n\n```tsx\nconst view =
{String(1)}
\n```\n", + "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: {} })\n```\n", ) expect(check().findings).toEqual([]) + skill( + "```ts\nimport { createRouter } from '@acme/client'\ncreateRouter({ routeTree: null })\n```\n", + ) + expect(check().findings).toContainEqual( + expect.objectContaining({ message: expect.stringMatching(/TS2322/) }), + ) +}) + +it('tolerates omitted shorthand values without suppressing incompatible options', () => { + skill( + "```ts\nimport { retry } from '@acme/client'\nconst context = { createContext }\nretry(() => fetchItems(context), { max: 'many' })\n```\n", + ) + expect(check().findings.map((finding) => finding.message)).toEqual([ + expect.stringMatching(/TS2322/), + ]) }) -it('keeps declarations in separate examples independent', () => { - skill('```ts\nconst count = 1\n```\n\n```ts\nconst count = 2\n```\n') +it('parses a plain ts block as TypeScript rather than TSX', () => { + skill( + "```ts\nimport { retry } from '@acme/client'\nconst pick = (value: T) => value\nawait retry(() => Promise.resolve(), { max: pick(3) })\n```\n\n```tsx\nconst view =
{String(1)}
\n```\n", + ) expect(check().findings).toEqual([]) }) +it.each(['ts', 'tsx', 'js', 'jsx'])( + 'keeps declarations in separate %s examples independent', + (language) => { + skill( + `\`\`\`${language}\nconst count = 1\n\`\`\`\n\n\`\`\`${language}\nconst count = 2\n\`\`\`\n`, + ) + const result = check() + expect(result.blocks).toBe(2) + expect(result.skipped).toBeUndefined() + expect(result.findings).toEqual([]) + }, +) + it.each(['js', 'jsx'])( 'checks library option types in %s examples', (language) => { @@ -107,6 +142,149 @@ it.each(['js', 'jsx'])( }, ) +it.each([ + ['javascript with CRLF', '```javascript\r\n', '\r\n```\r\n'], + ['JSX with a longer closing fence', '```jsx\n', '\n````\n'], + ['TypeScript with a tilde fence', '~~~typescript\n', '\n~~~~\n'], + ['an unclosed JavaScript fence', '```js\n', '\n'], +])('does not skip invalid examples in %s', (_name, opening, closing) => { + skill( + `${opening}import { retry } from '@acme/client'\nretry(() => Promise.resolve(), { max: 'many' })${closing}`, + ) + const result = check() + expect(result.blocks).toBe(1) + expect(result.skipped).toBeUndefined() + expect(result.findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) +}) + +it.each(['jsx', 'tsx'])( + 'checks actual component props and syntax in %s', + async (language) => { + write( + 'src/index.ts', + 'export function Counter(props: { count: number; children?: unknown }) { return null }\n', + ) + const example = (expression: string) => + `\`\`\`${language}\nimport { Counter } from '@acme/client'\nconst view = ${expression}\n\`\`\`\n` + skill(example('Ready')) + expect(check()).toMatchObject({ blocks: 1, findings: [] }) + expect(check().skipped).toBeUndefined() + expect(await main(['validate'])).toBe(0) + skill(example('')) + expect(check().findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) + expect(await main(['validate'])).toBe(1) + skill(example('')) + expect(check().findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS17008/), + }), + ) + }, +) + +it('checks JSDoc contracts from a JavaScript library instead of skipping it', () => { + rmSync(join(root, 'src/index.ts')) + write( + 'package.json', + JSON.stringify({ + name: '@acme/client', + version: '1.0.0', + exports: './src/index.js', + }), + ) + write( + 'src/index.js', + '/** @param {{ max: number }} options */\nexport function retry(options) { return options.max }\n', + ) + skill( + "```javascript\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n```\n", + ) + const result = check() + expect(result.skipped).toBeUndefined() + expect(result.findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) +}) + +it('never executes examples or the library while validating them', () => { + write( + 'src/index.ts', + `${read('src/index.ts')}\nthrow new Error('The validator executed the library')\n`, + ) + skill( + "```js\nimport { retry } from '@acme/client'\nimport { writeFileSync } from 'node:fs'\nwriteFileSync('example-executed', 'unsafe')\nretry(() => Promise.resolve(), { max: 3 })\n```\n", + ) + expect(check()).toMatchObject({ blocks: 1, findings: [] }) + expect(existsSync(join(root, 'example-executed'))).toBe(false) +}) + +it.each(['js', 'jsx'])( + 'checks a tracked %s entry declared outside src/index', + (extension) => { + rmSync(join(root, 'src'), { recursive: true }) + const entry = `lib/client.${extension}` + write( + 'package.json', + JSON.stringify({ + name: '@acme/client', + ...(extension === 'js' + ? { exports: { '.': { import: `./${entry}` } } } + : { main: entry }), + }), + ) + write( + entry, + '/** @param {{ max: number }} options */\nexport function retry(options) { return options.max }\n', + ) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { + cwd: root, + }) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', entry], { + cwd: root, + }) + skill( + `\`\`\`${extension}\nimport { retry } from '@acme/client'\nretry({ max: 'many' })\n\`\`\`\n`, + ) + const result = check() + expect(result.skipped).toBeUndefined() + expect(result.findings).toContainEqual( + expect.objectContaining({ + line: 10, + message: expect.stringMatching(/TS2322/), + }), + ) + }, +) + +it('keeps nested examples inside a Markdown fence and still checks following prose links', () => { + skill( + '````markdown\n```jsx\nconst view = \n```\n[example](not-a-real-link.md)\n`````\n\nSee [missing](missing.md).\n', + ) + expect(check()).toMatchObject({ + blocks: 0, + findings: [ + expect.objectContaining({ + line: 15, + message: 'Link target not found: missing.md', + }), + ], + }) +}) + it('reports a removed option, a missing export, and a broken example with the skill line', () => { skill( [ @@ -450,6 +628,23 @@ it('fails validate on a broken example and reports compile status on pending rev expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( 'Review skill skills/retries/SKILL.md: changed src/index.ts; examples still compile', ) + const descriptions = vi.spyOn(blockChecks, 'describeSkillExamples') + const checks = vi.spyOn(blockChecks, 'checkSkillBlocks') + expect(await main(['maintainer', 'check'])).toBe(1) // pending source review + expect(checks).toHaveBeenCalledTimes(1) + expect(descriptions).not.toHaveBeenCalled() + checks.mockClear() + write( + 'src/index.ts', + read('src/index.ts').replace('max: number', 'max: string'), + ) + vi.mocked(console.error).mockClear() + expect(await main(['maintainer', 'check'])).toBe(1) + expect(checks).toHaveBeenCalledTimes(1) + expect(descriptions).not.toHaveBeenCalled() + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + 'TS2322', + ) expect(existsSync(join(root, '.intent/skill-examples'))).toBe(false) }) diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index 64998f1b..b978971c 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -129,7 +129,7 @@ describe('workflow review helpers', () => { '- `missing-package-coverage` for `@tanstack/react-start-rsc`: workspace package is not represented', ) expect(body).toContain('`@tanstack/react-start-rsc`') - expect(body).toContain('npx @tanstack/intent@latest meta generate-skill') + expect(body).toContain('npm exec --no -- intent meta generate-skill') expect(body).toContain( 'Review signals are investigation inputs, not proof that content must change.', ) @@ -184,13 +184,13 @@ describe('workflow review helpers', () => { reasons: ['source changed'], }, ]) - expect(body).toContain('`npx @tanstack/intent@latest review --json`') + expect(body).toContain('`npm exec --no -- intent review --json`') expect(body).not.toContain('regenerate `intent review --json`') }) it('builds generated workflow advisory review items', () => { const items = createWorkflowAdvisoryReviewItems('@tanstack/router', [ - 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + 'Intent workflow update available: run `npm exec --no -- intent setup`.', ]) expect(items).toEqual([ @@ -199,7 +199,7 @@ describe('workflow review helpers', () => { library: '@tanstack/router', subject: 'check-skills.yml', reasons: [ - 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + 'Intent workflow update available: run `npm exec --no -- intent setup`.', ], }, ])