diff --git a/.changeset/spicy-laws-burn.md b/.changeset/spicy-laws-burn.md new file mode 100644 index 0000000..05335ed --- /dev/null +++ b/.changeset/spicy-laws-burn.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +- Added Deno monorepo setup coverage so setup-github-actions writes workflows at the workspace root and preserves monorepo-aware path substitutions. diff --git a/.changeset/vast-bags-switch.md b/.changeset/vast-bags-switch.md new file mode 100644 index 0000000..67d10df --- /dev/null +++ b/.changeset/vast-bags-switch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix intent stale so monorepo package paths resolve to the targeted workspace package instead of scanning the whole workspace. diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 84db99e..962a8cf 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from './cli-error.js' +import { resolveProjectContext } from './core/project-context.js' import type { ScanResult, StalenessReport } from './types.js' export function printWarnings(warnings: Array): void { @@ -47,10 +48,29 @@ export async function resolveStaleTargets( targetDir?: string, ): Promise<{ reports: Array }> { const resolvedRoot = targetDir - ? join(process.cwd(), targetDir) + ? resolve(process.cwd(), targetDir) : process.cwd() + const context = resolveProjectContext({ + cwd: process.cwd(), + targetPath: targetDir, + }) const { checkStaleness } = await import('./staleness.js') + const targetsResolvedPackage = + context.packageRoot !== null && + (context.targetSkillsDir !== null || resolvedRoot !== context.workspaceRoot) + + if (targetsResolvedPackage && context.packageRoot) { + return { + reports: [ + await checkStaleness( + context.packageRoot, + readPackageName(context.packageRoot), + ), + ], + } + } + if (existsSync(join(resolvedRoot, 'skills'))) { return { reports: [ diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 8afa392..e07d1af 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -27,6 +27,76 @@ function hasPackageJson(dir: string): boolean { return existsSync(join(dir, 'package.json')) } +function stripJsonCommentsAndTrailingCommas(source: string): string { + let result = '' + let inString = false + let escaped = false + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]! + const next = source[index + 1] + + if (inString) { + result += char + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + result += char + continue + } + + if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') { + index += 1 + } + if (index < source.length) { + result += source[index]! + } + continue + } + + if (char === '/' && next === '*') { + index += 2 + while ( + index < source.length && + !(source[index] === '*' && source[index + 1] === '/') + ) { + index += 1 + } + index += 1 + continue + } + + if (char === ',') { + let lookahead = index + 1 + while (lookahead < source.length && /\s/.test(source[lookahead]!)) { + lookahead += 1 + } + if (source[lookahead] === '}' || source[lookahead] === ']') { + continue + } + } + + result += char + } + + return result +} + +function readJsonFile(path: string, jsonc = false): unknown { + const source = readFileSync(path, 'utf8') + return JSON.parse(jsonc ? stripJsonCommentsAndTrailingCommas(source) : source) +} + export function readWorkspacePatterns(root: string): Array | null { const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { @@ -49,10 +119,16 @@ export function readWorkspacePatterns(root: string): Array | null { const pkgPath = join(root, 'package.json') if (existsSync(pkgPath)) { try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const pkg = readJsonFile(pkgPath) as { + workspaces?: unknown | { packages?: unknown } + } const patterns = parseWorkspacePatterns(pkg.workspaces) ?? - parseWorkspacePatterns(pkg.workspaces?.packages) + parseWorkspacePatterns( + typeof pkg.workspaces === 'object' && pkg.workspaces !== null + ? (pkg.workspaces as Record).packages + : undefined, + ) if (patterns) { return patterns } @@ -63,6 +139,30 @@ export function readWorkspacePatterns(root: string): Array | null { } } + for (const denoConfigName of ['deno.json', 'deno.jsonc']) { + const denoConfigPath = join(root, denoConfigName) + if (!existsSync(denoConfigPath)) { + continue + } + + try { + const denoConfig = readJsonFile( + denoConfigPath, + denoConfigName.endsWith('.jsonc'), + ) as { + workspace?: unknown + } + const patterns = parseWorkspacePatterns(denoConfig.workspace) + if (patterns) { + return patterns + } + } catch (err: unknown) { + console.error( + `Warning: failed to parse ${denoConfigPath}: ${err instanceof Error ? err.message : err}`, + ) + } + } + return null } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 1f2e2e8..6f130cc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -229,6 +229,43 @@ describe('cli commands', () => { expect(output).toContain('Template variables applied:') }) + it('copies github workflow templates to the workspace root', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-setup-gha-mono-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + }) + + process.chdir(join(root, 'packages', 'router')) + + const exitCode = await main(['setup-github-actions']) + const rootWorkflowsDir = join(root, '.github', 'workflows') + const packageWorkflowsDir = join( + root, + 'packages', + 'router', + '.github', + 'workflows', + ) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(existsSync(rootWorkflowsDir)).toBe(true) + expect(existsSync(packageWorkflowsDir)).toBe(false) + expect(output).toContain('Mode: monorepo') + }) + it('lists installed intent packages as json', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-')) tempDirs.push(root) @@ -484,6 +521,92 @@ describe('cli commands', () => { fetchSpy.mockRestore() }) + + it('checks only the targeted workspace package for staleness', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-target-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeJson(join(root, 'packages', 'query', 'package.json'), { + name: '@tanstack/query', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + writeSkillMd(join(root, 'packages', 'query', 'skills', 'cache'), { + name: 'cache', + description: 'Caching skill', + library_version: '1.0.0', + }) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', 'packages/router/skills', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ library: string }> + + expect(exitCode).toBe(0) + expect(reports).toHaveLength(1) + expect(reports[0]!.library).toBe('@tanstack/router') + + fetchSpy.mockRestore() + }) + + it('checks the current workspace package for staleness from package cwd', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-package-cwd-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeJson(join(root, 'packages', 'query', 'package.json'), { + name: '@tanstack/query', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + writeSkillMd(join(root, 'packages', 'query', 'skills', 'cache'), { + name: 'cache', + description: 'Caching skill', + library_version: '1.0.0', + }) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(join(root, 'packages', 'router')) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ library: string }> + + expect(exitCode).toBe(0) + expect(reports).toHaveLength(1) + expect(reports[0]!.library).toBe('@tanstack/router') + + fetchSpy.mockRestore() + }) }) describe('package metadata', () => { diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts index c16b64e..0416d91 100644 --- a/packages/intent/tests/project-context.test.ts +++ b/packages/intent/tests/project-context.test.ts @@ -136,4 +136,31 @@ describe('resolveProjectContext', () => { expect(context.isMonorepo).toBe(true) expect(context.packageRoot).toBe(packageRoot) }) + + it('detects Deno workspaces from a workspace package cwd', () => { + const root = createRoot() + writeJson(join(root, 'package.json'), { name: 'repo-root', private: true }) + writeFileSync( + join(root, 'deno.jsonc'), + `{ + "workspace": [ + "packages/*", + ], + } + `, + ) + const packageRoot = createWorkspacePackage(root, 'router') + + const context = resolveProjectContext({ cwd: packageRoot }) + + expect(context).toEqual({ + cwd: packageRoot, + workspaceRoot: root, + packageRoot, + isMonorepo: true, + workspacePatterns: ['packages/*'], + targetPackageJsonPath: join(packageRoot, 'package.json'), + targetSkillsDir: join(packageRoot, 'skills'), + }) + }) }) diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 6911a8c..181cff6 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -344,6 +344,72 @@ describe('runSetupGithubActions', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + + it('writes workflows to the Deno workspace root from a workspace package', () => { + const monoRoot = createMonorepo({ + usePackageJsonWorkspaces: true, + packages: [ + { name: 'router', hasSkills: true }, + { name: 'start', hasSkills: true }, + ], + }) + + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify({ name: 'root', private: true }, null, 2), + ) + writeFileSync( + join(monoRoot, 'deno.jsonc'), + `{ + // Deno workspace config should be used for monorepo resolution. + "workspace": [ + "packages/*", + ], + } + `, + ) + writeFileSync( + join(monoRoot, 'packages', 'router', 'package.json'), + JSON.stringify( + { + name: '@tanstack/react-router', + intent: { repo: 'TanStack/router', docs: 'docs/' }, + }, + null, + 2, + ), + ) + mkdirSync(join(monoRoot, 'packages', 'router', 'src'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'router', 'docs'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'start', 'src'), { recursive: true }) + + const result = runSetupGithubActions( + join(monoRoot, 'packages', 'router'), + metaDir, + ) + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + ]), + ) + expect( + existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), + ).toBe(false) + + const notifyContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: @tanstack/router') + expect(notifyContent).toContain('repo: TanStack/router') + expect(notifyContent).toContain("- 'packages/router/docs/**'") + expect(notifyContent).toContain("- 'packages/router/src/**'") + expect(notifyContent).toContain("- 'packages/start/src/**'") + + rmSync(monoRoot, { recursive: true, force: true }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index e48f646..15f3fdd 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -7,7 +7,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { findPackagesWithSkills, findWorkspaceRoot, @@ -78,6 +78,70 @@ describe('readWorkspacePatterns', () => { 'packages/*', ]) }) + + it('reads workspace patterns from deno.json', () => { + const root = createRoot() + + writeFileSync( + join(root, 'deno.json'), + JSON.stringify({ + workspace: ['', './apps/*/', 'packages\\*', 'apps/*'], + }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('reads workspace patterns from deno.jsonc', () => { + const root = createRoot() + + writeFileSync( + join(root, 'deno.jsonc'), + `{ + // Deno supports JSONC config files. + "workspace": [ + "./packages/*/", + "apps/*", + ], + } + `, + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('prefers package.json workspaces over Deno workspace config', () => { + const root = createRoot() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ) + writeFileSync( + join(root, 'deno.json'), + JSON.stringify({ workspace: ['apps/*'] }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['packages/*']) + }) + + it('warns and returns null for invalid Deno config', () => { + const root = createRoot() + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + writeFileSync(join(root, 'deno.jsonc'), '{ invalid jsonc') + + expect(readWorkspacePatterns(root)).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: failed to parse ${join(root, 'deno.jsonc')}`, + ), + ) + + consoleErrorSpy.mockRestore() + }) }) describe('resolveWorkspacePackages', () => {