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
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
55 changes: 55 additions & 0 deletions src/commands/doc-graph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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}`);
}

try {
persistGraphArtifacts(repoPath, repoGraph);
} catch (err) {
spinner.stop(pc.red('Failed to save graph'));
throw new CliError(`Failed to persist graph artifacts: ${err.message}`);
}

spinner.stop(pc.green('Graph saved'));

// 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