Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url';
import { scanCommand } from '../src/commands/scan.js';
import { docInitCommand } from '../src/commands/doc-init.js';
import { docSyncCommand } from '../src/commands/doc-sync.js';
import { docGraphCommand } from '../src/commands/doc-graph.js';
import { addCommand } from '../src/commands/add.js';
import { customizeCommand } from '../src/commands/customize.js';
import { CliError } from '../src/lib/errors.js';
Expand Down Expand Up @@ -158,6 +159,13 @@ doc
return docSyncCommand(path, options);
});

doc
.command('graph')
.description('Rebuild the import graph cache (.claude/graph.json)')
.argument('[path]', 'Path to repo', '.')
.option('--verbose', 'Show detailed graph info')
.action(docGraphCommand);

// Add command
program
.command('add')
Expand Down
50 changes: 50 additions & 0 deletions src/commands/doc-graph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { resolve } from 'path';
import pc from 'picocolors';
import * as p from '@clack/prompts';
import { scanRepo } from '../lib/scanner.js';
import { buildRepoGraph } from '../lib/graph-builder.js';
import { persistGraphArtifacts } from '../lib/graph-persistence.js';
import { CliError } from '../lib/errors.js';

export async function docGraphCommand(path, options) {
const repoPath = resolve(path);

p.intro(pc.cyan('aspens doc graph'));

const spinner = p.spinner();
spinner.start('Scanning repository...');

const scan = scanRepo(repoPath);
spinner.message('Building import graph...');

let repoGraph;
try {
repoGraph = await buildRepoGraph(repoPath, scan.languages);
} catch (err) {
spinner.stop(pc.red('Graph build failed'));
throw new CliError(`Failed to build import graph: ${err.message}`);
}

persistGraphArtifacts(repoPath, repoGraph);

spinner.stop(pc.green('Graph saved'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Print stats
console.log();
console.log(pc.dim(' Files: ') + repoGraph.stats.totalFiles);
console.log(pc.dim(' Edges: ') + repoGraph.stats.totalEdges);
console.log(pc.dim(' Hubs: ') + repoGraph.hubs.length);
console.log(pc.dim(' Clusters: ') + repoGraph.clusters.components.length);
console.log(pc.dim(' Hotspots: ') + repoGraph.hotspots.length);
console.log();

if (options.verbose && repoGraph.hubs.length > 0) {
console.log(pc.bold(' Top hubs:'));
for (const hub of repoGraph.hubs.slice(0, 10)) {
console.log(` ${pc.cyan(hub.path)} — ${hub.fanIn} dependents`);
}
console.log();
}

p.outro(pc.dim('Saved to .claude/graph.json + .claude/skills/code-map/skill.md'));
}
9 changes: 9 additions & 0 deletions src/commands/doc-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { scanRepo } from '../lib/scanner.js';
import { buildRepoGraph } from '../lib/graph-builder.js';
import { runClaude, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js';
import { writeSkillFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js';
import { persistGraphArtifacts } from '../lib/graph-persistence.js';
import { installGitHook } from './doc-sync.js';
import { CliError } from '../lib/errors.js';

Expand Down Expand Up @@ -90,6 +91,10 @@ export async function docInitCommand(path, options) {
let repoGraph = null;
try {
repoGraph = await buildRepoGraph(repoPath, scan.languages);
// Persist graph, code-map skill, and index for runtime use
try {
persistGraphArtifacts(repoPath, repoGraph);
} catch { /* graph persistence failed — non-fatal */ }
} catch { /* graph building failed — continue without it */ }

scanSpinner.stop(`Scanned ${pc.bold(scan.name)} (${scan.repoType})`);
Expand Down Expand Up @@ -498,6 +503,8 @@ async function installHooks(repoPath, options) {
const hookFiles = [
{ src: 'hooks/skill-activation-prompt.sh', dest: 'skill-activation-prompt.sh', chmod: true },
{ src: 'hooks/skill-activation-prompt.mjs', dest: 'skill-activation-prompt.mjs', chmod: false },
{ src: 'hooks/graph-context-prompt.sh', dest: 'graph-context-prompt.sh', chmod: true },
{ src: 'hooks/graph-context-prompt.mjs', dest: 'graph-context-prompt.mjs', chmod: false },
];

for (const hf of hookFiles) {
Expand Down Expand Up @@ -581,6 +588,8 @@ async function installHooks(repoPath, options) {
`${pc.green('+')} .claude/skills/skill-rules.json ${pc.dim(`(${skillCount} skills)`)}`,
`${pc.green('+')} .claude/hooks/skill-activation-prompt.sh`,
`${pc.green('+')} .claude/hooks/skill-activation-prompt.mjs`,
`${pc.green('+')} .claude/hooks/graph-context-prompt.sh`,
`${pc.green('+')} .claude/hooks/graph-context-prompt.mjs`,
`${pc.green('+')} .claude/hooks/post-tool-use-tracker.sh ${pc.dim('(with domain patterns)')}`,
`${existingSettings ? pc.yellow('~') : pc.green('+')} .claude/settings.json ${pc.dim(existingSettings ? '(merged)' : '(created)')}`,
];
Expand Down
40 changes: 36 additions & 4 deletions src/commands/doc-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import * as p from '@clack/prompts';
import { scanRepo } from '../lib/scanner.js';
import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js';
import { writeSkillFiles } from '../lib/skill-writer.js';
import { buildRepoGraph } from '../lib/graph-builder.js';
import { persistGraphArtifacts, loadGraph, extractSubgraph, formatNavigationContext } from '../lib/graph-persistence.js';
import { CliError } from '../lib/errors.js';

const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'];
Expand Down Expand Up @@ -67,7 +69,21 @@ export async function docSyncCommand(path, options) {
// Step 3: Find affected skills
const scan = scanRepo(repoPath);
const existingSkills = findExistingSkills(repoPath);
const affectedSkills = mapChangesToSkills(changedFiles, existingSkills, scan);

// Rebuild graph from current state (keeps graph fresh on every sync)
let repoGraph = null;
let graphContext = '';
try {
const rawGraph = await buildRepoGraph(repoPath, scan.languages);
persistGraphArtifacts(repoPath, rawGraph);
repoGraph = loadGraph(repoPath);
if (repoGraph) {
const subgraph = extractSubgraph(repoGraph, changedFiles);
graphContext = formatNavigationContext(subgraph);
}
} catch { /* proceed without graph */ }

const affectedSkills = mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph);

if (affectedSkills.length > 0) {
p.log.info(`Skills that may need updates: ${affectedSkills.map(s => pc.yellow(s.name)).join(', ')}`);
Expand Down Expand Up @@ -109,7 +125,7 @@ ${truncateDiff(diff, 15000)}

## Changed Files
${changedFiles.join('\n')}

${graphContext ? `\n## Import Graph Context\n${graphContext}` : ''}
## Existing Skills
${skillContents}

Expand Down Expand Up @@ -280,7 +296,7 @@ const GENERIC_PATH_SEGMENTS = new Set([
'public', 'assets', 'styles', 'scripts',
]);

function mapChangesToSkills(changedFiles, existingSkills, scan) {
function mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph = null) {
const affected = [];

for (const skill of existingSkills) {
Expand All @@ -292,7 +308,7 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) {

const activationBlock = activationMatch[0].toLowerCase();

const isAffected = changedFiles.some(file => {
let isAffected = changedFiles.some(file => {
const fileLower = file.toLowerCase();
// Check the filename itself (e.g., billing_service.py)
const fileName = fileLower.split('/').pop();
Expand All @@ -303,6 +319,22 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) {
return parts.some(part => activationBlock.includes(part));
});

// Graph-aware: check if changed files are imported by files in this skill's domain
if (!isAffected && repoGraph) {
isAffected = changedFiles.some(file => {
const info = repoGraph.files[file];
if (!info) return false;
// Check if any file that imports the changed file matches the activation block
return (info.importedBy || []).some(dep => {
const depLower = dep.toLowerCase();
const depName = depLower.split('/').pop();
if (activationBlock.includes(depName)) return true;
const parts = depLower.split('/').filter(p => !GENERIC_PATH_SEGMENTS.has(p) && p.length > 2);
return parts.some(part => activationBlock.includes(part));
});
});
}

if (isAffected) {
affected.push(skill);
}
Expand Down
Loading
Loading