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
2 changes: 2 additions & 0 deletions src/__tests__/maintenance-promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ describe('executePromotion', () => {
expect(targetPath).toContain('skills/candidate.md');
expect(marked).toBeNull();
expect(fs.existsSync(path.join(tmpDir, 'skills', 'candidate.md'))).toBe(false);
// Not even the category directory: a preview creates nothing (#900).
expect(fs.existsSync(path.join(tmpDir, 'skills'))).toBe(false);
});

it('copies file and marks original with promoted_to', async () => {
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/maintenance-two-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ describe('maintenance across the write root and the inherited root', () => {
expect(fs.readFileSync(path.join(inherited, 'old.md'), 'utf8')).toContain('confidence: 0.1');
});

it('under dryRun names the files it would write and writes none (#900)', async () => {
// Its own content: gray-matter caches a parse by its input string.
fs.writeFileSync(path.join(inherited, 'old.md'), '---\ntitle: preview\nconfidence: 0.1\n---\nbody');

const written = await writeBackConfidence(
[writeRoot, inherited],
new Map([['old', 0.9]]),
writeRoot,
{ dryRun: true },
);

expect(written).toEqual([path.join(writeRoot, 'old.md')]);
expect(fs.existsSync(path.join(writeRoot, 'old.md'))).toBe(false);
expect(fs.readFileSync(path.join(inherited, 'old.md'), 'utf8')).toContain('confidence: 0.1');
});

it('updates a learning already in the write root in place', async () => {
fs.writeFileSync(path.join(writeRoot, 'new.md'), '---\ntitle: new\nconfidence: 0.1\n---\nbody');

Expand Down
101 changes: 101 additions & 0 deletions src/__tests__/recall-maintenance-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// `recall maintenance` and `recall promote` declare their own `--dry-run`, but
// the root program declares one too, and Commander gives it to the root
// wherever it appears. These actions read only their own options, so a preview
// pruned, drafted and promoted for real (#900). They are driven through the
// real command table here, because that is where the flag went missing.
vi.mock('../migrate.js', () => ({
maybeMigrate: vi.fn(async () => undefined),
queueKeptInCheckout: vi.fn(async () => null),
}));
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
autoDetectInit: vi.fn(async () => ({
localConfig: { repo: { localPath: '/team-repo', remote: 'owner/repo' }, username: 'dev' },
teamConfig: { team: 'demo', repo: 'owner/repo' },
})),
}));
const candidate = { filename: 'l.md', path: '/l.md', confidence: 0.05, lastActivity: '', reason: 'low', docId: 'l' };
vi.mock('../maintenance/index.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../maintenance/index.js')>()),
resolveMaintenancePaths: vi.fn(async () => ({
repoPath: '/team-repo', votesDir: '/votes', learningsWriteDir: '/learnings', learningsReadDirs: ['/learnings'],
})),
findPruneCandidates: vi.fn(async () => [candidate]),
executePrune: vi.fn(async (_dir: string, _c: unknown, o: { dryRun?: boolean }) =>
(o.dryRun ? { archived: 0, removed: 0, changed: [] } : { archived: 0, removed: 1, changed: ['/l.md'] })),
computeAllConfidence: vi.fn(async () => new Map([['l', 0.9]])),
writeBackConfidence: vi.fn(async () => ['/learnings/l.md']),
findStaleEntries: vi.fn(async () => [{ path: '/team-repo/docs/a.md' }]),
reportStaleEntries: vi.fn(),
findRelatedAdoptedLearnings: vi.fn(async () => []),
generateUpdateDraft: vi.fn(async () => null),
findPromotionCandidates: vi.fn(async () => [{ ...candidate, suggestedCategory: 'docs' }]),
executePromotion: vi.fn(async (_c: unknown, _r: string, o: { dryRun?: boolean }) =>
({ targetPath: '/team-repo/docs/l.md', marked: o.dryRun ? null : '/learnings/l.md' })),
}));
vi.mock('../utils/learnings-publish.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/learnings-publish.js')>()),
publishLearningsMaintenance: vi.fn(async () => ({ status: 'published' })),
}));
vi.mock('../utils/logger.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/logger.js')>()),
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() },
}));

beforeEach(() => {
vi.stubEnv('TEAMAI_COMMAND_TABLE_ONLY', '1');
});

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
vi.clearAllMocks();
});

async function run(args: string[]): Promise<void> {
const { program } = await import('../index.js');
await program.parseAsync(['node', 'teamai', ...args]);
}

async function mocks() {
return {
...(await import('../maintenance/index.js')),
...(await import('../utils/learnings-publish.js')),
...(await import('../config.js')),
};
}

const PREVIEWS: Array<[string, string[]]> = [
['recall maintenance --prune --dry-run', ['recall', 'maintenance', '--prune', '--dry-run']],
['--dry-run recall maintenance --prune', ['--dry-run', 'recall', 'maintenance', '--prune']],
['recall maintenance --confidence-writeback --dry-run', ['recall', 'maintenance', '--confidence-writeback', '--dry-run']],
['recall maintenance --update-quality --dry-run', ['recall', 'maintenance', '--update-quality', '--dry-run']],
['recall promote <id> --dry-run', ['recall', 'promote', 'l', '--dry-run']],
];

describe('recall maintenance and recall promote under --dry-run (#900)', () => {
it.each(PREVIEWS)('%s loads, changes and publishes nothing', async (_label, args) => {
await run(args);
const m = await mocks();
expect(m.autoDetectInit).toHaveBeenCalledWith(undefined, { dryRun: true });
for (const call of vi.mocked(m.executePrune).mock.calls) expect(call[2]).toMatchObject({ dryRun: true });
for (const call of vi.mocked(m.executePromotion).mock.calls) expect(call[2]).toMatchObject({ dryRun: true });
for (const call of vi.mocked(m.writeBackConfidence).mock.calls) expect(call[3]).toEqual({ dryRun: true });
expect(m.generateUpdateDraft).not.toHaveBeenCalled();
expect(m.publishLearningsMaintenance).not.toHaveBeenCalled();
});

// The positive control: the same commands without the flag do reach the
// write, so the assertions above are not passing on a path that never gets there.
it.each([
['recall maintenance --prune', ['recall', 'maintenance', '--prune']],
['recall maintenance --confidence-writeback', ['recall', 'maintenance', '--confidence-writeback']],
['recall promote <id>', ['recall', 'promote', 'l']],
])('%s still publishes without the flag', async (_label, args) => {
await run(args);
const m = await mocks();
expect(m.publishLearningsMaintenance).toHaveBeenCalledTimes(1);
});
});
18 changes: 12 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,15 +1253,19 @@ recallCmd
.option('--confidence-writeback', 'Update frontmatter confidence scores')
.option('--update-quality', 'Find stale docs/rules/skills and suggest updates')
.option('--dry-run', 'Show what would be done without making changes')
.action(async (cmdOpts) => {
.action(async (localOpts) => {
// The root program takes `--dry-run` wherever it is written, so this
// command's own declaration never sets it: read it merged, as the other
// actions do (#900).
const cmdOpts = { ...(program.opts() as GlobalOptions), ...localOpts };
if (!cmdOpts.confidenceWriteback && !cmdOpts.prune && !cmdOpts.updateQuality) {
const { log } = await import('./utils/logger.js');
log.info('Usage: teamai recall maintenance --prune | --confidence-writeback | --update-quality');
return;
}

const { autoDetectInit } = await import('./config.js');
const { localConfig } = await autoDetectInit();
const { localConfig } = await autoDetectInit(undefined, { dryRun: cmdOpts.dryRun });
const paths = await maintenancePathsOrExit(localConfig);
if (!paths) return;
const {
Expand All @@ -1271,8 +1275,8 @@ recallCmd
if (cmdOpts.confidenceWriteback) {
const { computeAllConfidence, writeBackConfidence } = await import('./maintenance/index.js');
const map = await computeAllConfidence(votesDir);
const written = await writeBackConfidence(learningsReadDirs, map, learningsWriteDir);
if (written.length > 0) {
const written = await writeBackConfidence(learningsReadDirs, map, learningsWriteDir, { dryRun: cmdOpts.dryRun });
if (written.length > 0 && !cmdOpts.dryRun) {
await publishMaintenance(localConfig, `[teamai] Update confidence for ${written.length} learning(s)`, written);
}
return;
Expand Down Expand Up @@ -1340,9 +1344,11 @@ recallCmd
.description('Promote a high-confidence learning to formal knowledge (docs/skills/rules)')
.option('--category <cat>', 'Target category: skills | rules | docs')
.option('--dry-run', 'Show what would be done without making changes')
.action(async (learningId, cmdOpts) => {
.action(async (learningId, localOpts) => {
// As in `recall maintenance`: `--dry-run` reaches the root's options only (#900).
const cmdOpts = { ...(program.opts() as GlobalOptions), ...localOpts };
const { autoDetectInit } = await import('./config.js');
const { localConfig } = await autoDetectInit();
const { localConfig } = await autoDetectInit(undefined, { dryRun: cmdOpts.dryRun });
const {
findPromotionCandidates,
executePromotion,
Expand Down
10 changes: 7 additions & 3 deletions src/maintenance/confidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ export async function computeAllConfidence(votesDir: string): Promise<Map<string
* Write confidence scores back into learning document frontmatter.
* Only updates docs whose confidence changed by > 0.05.
* Returns the files it wrote, which are what a publish may stage (#823).
* Under `dryRun` it writes nothing and returns the files it would write (#900).
*/
export async function writeBackConfidence(
learningsDirs: readonly string[],
confidenceMap: Map<string, number>,
writeRoot?: string,
options: { dryRun?: boolean } = {},
): Promise<string[]> {
const written: string[] = [];
const files = await listLearningFiles(learningsDirs);
Expand Down Expand Up @@ -121,16 +123,18 @@ export async function writeBackConfidence(
const target = writeRoot && !isInWriteRoot(absPath, writeRoot)
? path.join(writeRoot, file)
: absPath;
await ensureDir(path.dirname(target));
await writeFile(target, newContent);
if (!options.dryRun) {
await ensureDir(path.dirname(target));
await writeFile(target, newContent);
}
written.push(target);
} catch {
log.debug(`confidence: failed to update frontmatter for: ${file}`);
}
}

if (written.length > 0) {
log.info(`Updated confidence scores for ${written.length} learning(s)`);
log.info(`${options.dryRun ? '[dry-run] Would update' : 'Updated'} confidence scores for ${written.length} learning(s)`);
}
return written;
}
2 changes: 0 additions & 2 deletions src/maintenance/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,6 @@ export async function executePromotion(
): Promise<{ targetPath: string; marked: string | null }> {
const category = options.category ?? candidate.suggestedCategory;
const targetDir = path.join(repoPath, category);
await ensureDir(targetDir);

const targetPath = path.join(targetDir, candidate.filename);

if (options.dryRun) {
Expand Down
Loading