* Deepen SDK package seam and converge runtime skills policy * fix(sdk): unified install-root resolution for workflows and agents (CR finding 1) Use the already-resolved gsdInstallDir constant instead of calling resolveLegacyInstallDir() again when computing agentsDir, ensuring workflowsDir and agentsDir share the same install root. * fix(sdk): tilde shortening requires path-boundary match (CR finding 2) Both renderGlobalSkillsBaseDisplayPath and renderGlobalSkillDisplayPath used startsWith(home) which could incorrectly shorten unrelated paths sharing the same prefix. Now checks for home === base or base.startsWith(home + sep) to ensure a real directory boundary. * fix(sdk): validate loadConfig export before invocation (CR finding 3) After requiring core.cjs, check typeof mod.loadConfig === 'function' before calling it. Throws a classified GSDError with the module path if the export is missing, rather than a generic TypeError. * fix(test): guard root lookup before .path dereference (CR finding 4) Added assert.ok() guards for claudeRoot and codexRoot after the .find() calls so that a missing root produces an explicit assertion failure rather than a TypeError on .path dereference. * fix(ci): fail-safe on transient API errors in approval dismissal (CR finding 6) resolveRole() returns 'unknown' for non-404 errors (rate limits, 5xx, network blips). shouldDismissReviewer() now treats 'unknown' as unresolvable and skips dismissal, preventing legitimate approvals from being dismissed due to a transient API failure. Only 'none' (true 404) is treated as a confirmed non-collaborator. * changeset: pr=3238 SDK package seam and runtime skills convergence * fix(sdk): harden resolveGlobalSkillDir against path traversal (CR finding 1) Use resolve+relative to validate that skillName cannot escape the global skills base directory. Values like "../../foo" or absolute paths now return null instead of joining directly. All imports (resolve, relative, isAbsolute) were already present in helpers.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): split skill-dir-resolution and skill-not-found warnings (CR finding 2) After resolveGlobalSkillDir's hardening can return null for traversal attempts, the old single-branch warning "Global skill not found at ..." was misleading. Split into two distinct cases: - skillDir === null → "Could not resolve global skill directory for ..." - skillMd missing → "Global skill not found at ..." Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: lock skill path-traversal rejection in resolveGlobalSkillDir Regression test verifying that traversal segments (../../foo, ../escape), empty string, and absolute paths are all rejected (return null), while a legitimate skill name resolves correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): align display-path contract + traversal coverage for resolveGlobalSkillMarkdownPath (CR nitpicks) - renderGlobalSkillsBaseDisplayPath now returns a non-null string for unsupported runtimes (e.g. cline → "(cline does not use a skills directory)") matching the existing renderGlobalSkillDisplayPath contract; callers of both helpers no longer need null-checks for unsupported runtimes. - Remove now-redundant ! non-null assertion on renderGlobalSkillsBaseDisplayPath calls in skill-manifest.ts (return type is string, not string | null). - Extend the path-traversal test block to assert resolveGlobalSkillMarkdownPath also propagates null for ../../foo, ../escape, empty, and /abs/path inputs, locking the null-propagation contract against future refactors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
/**
|
|
* Tests for profile / learnings query handlers (filesystem writes use temp dirs).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { generateDevPreferences, writeProfile } from './profile-output.js';
|
|
import { learningsCopy } from './profile.js';
|
|
|
|
describe('writeProfile', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-profile-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes USER-PROFILE.md from --input JSON (CJS template + dimensions shape)', async () => {
|
|
const analysisPath = join(tmpDir, 'analysis.json');
|
|
const outPath = join(tmpDir, '.planning', 'USER-PROFILE.md');
|
|
await writeFile(
|
|
analysisPath,
|
|
JSON.stringify({
|
|
profile_version: '1.0',
|
|
data_source: 'test',
|
|
dimensions: {
|
|
communication_style: {
|
|
rating: 'terse-direct',
|
|
confidence: 'HIGH',
|
|
claude_instruction: 'Keep it short.',
|
|
summary: 'Test summary.',
|
|
evidence: [],
|
|
},
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const result = await writeProfile(['--input', analysisPath, '--output', outPath], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data.profile_path).toBe(outPath);
|
|
expect(data.dimensions_scored).toBe(1);
|
|
const md = await readFile(outPath, 'utf-8');
|
|
expect(md).toContain('Developer Profile');
|
|
expect(md).toMatch(/Communication Style/i);
|
|
});
|
|
});
|
|
|
|
describe('generateDevPreferences', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-dev-preferences-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes to runtime-global skills dir by default', async () => {
|
|
const analysisPath = join(tmpDir, 'analysis.json');
|
|
const codexHome = join(tmpDir, 'codex-home');
|
|
await writeFile(
|
|
analysisPath,
|
|
JSON.stringify({
|
|
data_source: 'test',
|
|
dimensions: {
|
|
communication_style: { rating: 'terse', confidence: 'HIGH', claude_instruction: 'Keep it short.' },
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' }), 'utf-8');
|
|
|
|
const prevCodexHome = process.env.CODEX_HOME;
|
|
process.env.CODEX_HOME = codexHome;
|
|
try {
|
|
const result = await generateDevPreferences(['--analysis', analysisPath], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
const expectedPath = join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
|
expect(data.command_path).toBe(expectedPath);
|
|
const md = await readFile(expectedPath, 'utf-8');
|
|
expect(md).toContain('Behavioral Directives');
|
|
} finally {
|
|
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = prevCodexHome;
|
|
}
|
|
});
|
|
|
|
it('requires --output when runtime has no skills dir', async () => {
|
|
const analysisPath = join(tmpDir, 'analysis.json');
|
|
await writeFile(
|
|
analysisPath,
|
|
JSON.stringify({
|
|
data_source: 'test',
|
|
dimensions: {
|
|
communication_style: { rating: 'terse', confidence: 'HIGH', claude_instruction: 'Keep it short.' },
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'cline' }), 'utf-8');
|
|
|
|
await expect(generateDevPreferences(['--analysis', analysisPath], tmpDir)).rejects.toThrow(
|
|
'Runtime "cline" does not use a skills directory; pass --output to choose a path explicitly.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('learningsCopy', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-learn-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns zero counts when LEARNINGS.md is missing (matches learnings.cjs)', async () => {
|
|
const result = await learningsCopy([], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data.total).toBe(0);
|
|
expect(data.created).toBe(0);
|
|
expect(data.skipped).toBe(0);
|
|
});
|
|
});
|