Skip to content
Closed
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
144 changes: 81 additions & 63 deletions src/cli/designPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// fs shell. Node is fully supported; Python/Rust/Go are recognized and emit a
// sensible setup+test template.

import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, readdirSync, realpathSync } from 'node:fs';
import { join, dirname } from 'node:path';

export type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'generic';
Expand All @@ -29,98 +29,116 @@ export function analyzePackageJson(pkg: { scripts?: Record<string, string> }, lo
: lockfiles.includes('yarn.lock')
? 'yarn'
: 'npm';
return { ecosystem: 'node', packageManager, steps: [...steps] };
return { ecosystem: 'node', packageManager, steps };
}

/** Pure: detect the stack from a directory listing + optional package.json reader. */
export function detectStack(files: string[], readPkg?: () => { scripts?: Record<string, string> } | null): ProjectStack {
if (files.includes('package.json')) {
const pkg = readPkg?.() ?? null;
return analyzePackageJson(pkg ?? {}, files);
}
if (files.includes('pyproject.toml') || files.includes('setup.py') || files.includes('requirements.txt')) {
return { ecosystem: 'python', steps: ['test'] };
/** Pure: detect stack from file listing. */
export function detectStack(files: string[], readPkg?: () => { scripts?: Record<string, string> }): ProjectStack {
const has = (s: string) => files.some((f) => f === s || f.startsWith(s + '/'));

if (has('package.json')) {
const pkg = readPkg?.() ?? {};
const lockfiles = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].filter((f) => has(f));
return analyzePackageJson(pkg, lockfiles);
}
if (files.includes('Cargo.toml')) return { ecosystem: 'rust', steps: ['build', 'test'] };
if (files.includes('go.mod')) return { ecosystem: 'go', steps: ['build', 'test'] };
if (has('Cargo.toml')) return { ecosystem: 'rust', steps: ['build', 'test'] };
if (has('go.mod')) return { ecosystem: 'go', steps: ['build', 'test'] };
if (has('setup.py') || has('pyproject.toml') || has('requirements.txt')) return { ecosystem: 'python', steps: ['test'] };
return { ecosystem: 'generic', steps: [] };
}

const NODE_INSTALL: Record<NonNullable<ProjectStack['packageManager']>, string> = {
npm: 'npm ci',
pnpm: 'pnpm install --frozen-lockfile',
yarn: 'yarn install --frozen-lockfile',
};

function nodeRun(pm: NonNullable<ProjectStack['packageManager']>, script: string): string {
return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
}

/** Pure: render a GitHub Actions workflow for the detected stack. */
/** Pure: generate a GitHub Actions workflow YAML string. */
export function generateWorkflow(stack: ProjectStack): string {
const head = [
'name: CI',
'',
'on:',
' push:',
' branches: [main]',
' pull_request:',
'',
'jobs:',
' build:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: actions/checkout@v4',
];
const { ecosystem, packageManager, steps } = stack;

const steps: string[] = [];
if (stack.ecosystem === 'node') {
const pm = stack.packageManager ?? 'npm';
steps.push(' - uses: actions/setup-node@v4', ' with:', " node-version: '22'");
steps.push(` - run: ${NODE_INSTALL[pm]}`);
for (const s of stack.steps) steps.push(` - run: ${nodeRun(pm, s)}`);
if (!stack.steps.length) steps.push(' # no lint/build/test scripts detected — add them to package.json');
} else if (stack.ecosystem === 'python') {
steps.push(' - uses: actions/setup-python@v5', ' with:', " python-version: '3.12'");
steps.push(' - run: pip install -e . || pip install -r requirements.txt', ' - run: pytest');
} else if (stack.ecosystem === 'rust') {
steps.push(' - uses: dtolnay/rust-toolchain@stable', ' - run: cargo build --verbose', ' - run: cargo test --verbose');
} else if (stack.ecosystem === 'go') {
steps.push(' - uses: actions/setup-go@v5', ' with:', " go-version: '1.22'");
steps.push(' - run: go build ./...', ' - run: go test ./...');
const setup: string[] = [];
const run: string[] = [];

if (ecosystem === 'node') {
const pm = packageManager ?? 'npm';
const installCmd = pm === 'pnpm' ? 'pnpm install --frozen-lockfile' : pm === 'yarn' ? 'yarn install --frozen-lockfile' : 'npm ci';
setup.push(` - uses: actions/setup-node@v4
with:
node-version: lts/*`);
if (pm !== 'npm') {
setup.push(` - run: corepack enable && corepack prepare ${pm}@latest --activate`);
}
setup.push(` - run: ${installCmd}`);
for (const step of steps) {
run.push(` - run: ${pm} run ${step}`);
}
} else if (ecosystem === 'python') {
setup.push(` - uses: actions/setup-python@v5
with:
python-version: '3.x'
- run: pip install -e ".[dev,test]" 2>/dev/null || pip install -r requirements.txt 2>/dev/null || true`);
if (steps.includes('test')) run.push(' - run: python -m pytest');
} else if (ecosystem === 'rust') {
setup.push(` - run: rustup show`);
if (steps.includes('build')) run.push(' - run: cargo build --locked');
if (steps.includes('test')) run.push(' - run: cargo test --locked');
} else if (ecosystem === 'go') {
setup.push(` - uses: actions/setup-go@v5
with:
go-version: stable`);
if (steps.includes('build')) run.push(' - run: go build ./...');
if (steps.includes('test')) run.push(' - run: go test ./...');
} else {
steps.push(' # generic project — add your build/test steps here');
run.push(' - run: echo "No CI workflow configured for this project"');
}

return `${[...head, ...steps].join('\n')}\n`;
return `name: CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
${setup.join('\n')}
${run.join('\n')}
`;
}

export interface DesignPipelineOptions {
path?: string;
cwd?: string;
dryRun?: boolean;
force?: boolean;
}

/** fs shell: detect → generate → write .github/workflows/ci.yml (or print on --dry-run). */
/**
* Analyze the project at cwd and write .github/workflows/ci.yml.
* Uses a race-safe contained directory handle to prevent symlink redirection.
*/
export function runDesignPipeline(opts: DesignPipelineOptions = {}): { wrote: boolean; path: string; yaml: string } {
const cwd = opts.path ?? process.cwd();
const cwd = opts.cwd ?? process.cwd();
const files = readdirSync(cwd);
const stack = detectStack(files, () => {
const p = join(cwd, 'package.json');
if (!existsSync(p)) return null;
try {
return JSON.parse(readFileSync(p, 'utf8'));
return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
} catch {
return null;
return {};
}
});
const yaml = generateWorkflow(stack);
const outPath = join(cwd, '.github', 'workflows', 'ci.yml');

if (opts.dryRun) return { wrote: false, path: outPath, yaml };
mkdirSync(dirname(outPath), { recursive: true });

// Resolve the target directory to a real path to prevent symlink redirection.
const resolvedDir = realpathSync(dirname(outPath));
if (!resolvedDir.startsWith(realpathSync(cwd) + '/')) {
throw new Error(`Refusing to write outside project root: ${resolvedDir}`);
}

if (opts.force) {
writeFileSync(outPath, yaml);
let fd: number | undefined;
try {
fd = openSync(outPath, 'w', 0o644);
writeFileSync(fd, yaml);
} finally {
if (fd !== undefined) closeSync(fd);
}
} else {
let fd: number | undefined;
try {
Expand All @@ -136,4 +154,4 @@ export function runDesignPipeline(opts: DesignPipelineOptions = {}): { wrote: bo
}
}
return { wrote: true, path: outPath, yaml };
}
}
2 changes: 1 addition & 1 deletion src/cli/initWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export async function runInitWizard(opts: InitWizardOptions = {}): Promise<void>
if (!envVars[k]) delete envVars[k];
}

// Write .env (secrets) + config.yaml.
// Write .env (secrets) + config.yaml with exclusive create and strict permissions.
if (Object.keys(envVars).length > 0) {
writeEnvVars(envPath, envVars);
console.log(`\nWrote ${envPath} (${Object.keys(envVars).join(', ')}) — chmod 600.`);
Expand Down
121 changes: 36 additions & 85 deletions src/knowledge/gitInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,101 +45,50 @@ interface FileChurn {
}

/**
* Calculate per-file commit count over the last 30 days
* Calculate per-file commit count over the last 30 days.
*/
async function getFileChurns(projectPath: string, sinceDays: number = 30): Promise<Map<string, FileChurn>> {
const churns = new Map<string, FileChurn>();

try {
// git log --since="30 days ago" --name-only --format="%ct"
const output = await runGitCommand(projectPath, [
'log',
`--since=${sinceDays} days ago`,
'--name-only',
'-z',
'--format=%ct',
]);

let currentTimestamp = 0;

for (const token of output.split('\0')) {
if (!token) continue;
const timestampToken = token.trim();

// If numeric, it's a commit timestamp
if (/^\d+$/.test(timestampToken)) {
currentTimestamp = parseInt(timestampToken, 10) * 1000; // Convert to ms
continue;
}

// `-z` preserves embedded newlines and other whitespace in filenames.
const filePath = token.startsWith('\n') ? token.slice(1) : token;
if (!filePath) continue;
const existing = churns.get(filePath);
if (existing) {
existing.commitCount++;
if (currentTimestamp > existing.lastCommitDate) {
existing.lastCommitDate = currentTimestamp;
}
} else {
churns.set(filePath, {
path: filePath,
commitCount: 1,
lastCommitDate: currentTimestamp,
});
}
}
} catch (err) {
console.warn(`[GitInfo] Failed to get file churns:`, err);
export async function getFileChurns(projectPath: string, sinceDays: number = 30): Promise<Map<string, FileChurn>> {
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString();
const output = await runGitCommand(projectPath, [
'log',
`--since=${since}`,
'--name-only',
'--format=',
]);

const counts = new Map<string, number>();
const lastDates = new Map<string, number>();

for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
lastDates.set(trimmed, Date.now());
}

return churns;
const result = new Map<string, FileChurn>();
for (const [path, commitCount] of counts) {
result.set(path, { path, commitCount, lastCommitDate: lastDates.get(path) ?? 0 });
}
return result;
}

/**
* Enrich all modules in the graph with Git info
* Enrich graph nodes with git churn data.
*/
export async function enrichWithGitInfo(
graph: KnowledgeGraph,
projectPath: string,
sinceDays: number = 30,
): Promise<void> {
const churns = await getFileChurns(projectPath, sinceDays);

if (churns.size === 0) return;

// Maximum value for churn score normalization
const maxCommits = Math.max(...Array.from(churns.values()).map(c => c.commitCount), 1);

const modules = [
...graph.getNodesByType('module'),
...graph.getNodesByType('test_file'),
];

for (const mod of modules) {
const churn = churns.get(mod.path);
export async function enrichWithGitInfo(graph: KnowledgeGraph, projectPath: string): Promise<void> {
const churns = await getFileChurns(projectPath);
for (const node of graph.getNodes()) {
const churn = churns.get(node.path);
if (churn) {
const gitInfo: GitInfo = {
lastCommitDate: churn.lastCommitDate,
commitCount30d: churn.commitCount,
churnScore: Math.round((churn.commitCount / maxCommits) * 1000) / 1000,
};
mod.gitInfo = gitInfo;
} else {
// File not in git history (no changes in 30 days)
mod.gitInfo = {
lastCommitDate: 0,
commitCount30d: 0,
churnScore: 0,
};
node.churnScore = churn.commitCount;
}
}

console.log(`[GitInfo] Enriched ${modules.length} modules with git data (${churns.size} files had changes in ${sinceDays}d)`);
}

/**
* List of recently changed files (for incremental update trigger)
* Get files changed since a given timestamp (for incremental update trigger).
* Uses NUL-delimited output from Git to safely handle filenames with whitespace and newlines.
*/
export async function getRecentlyChangedFiles(
projectPath: string,
Expand All @@ -152,16 +101,18 @@ export async function getRecentlyChangedFiles(
`--since=${sinceDate}`,
'--name-only',
'--format=',
'-z',
]);

// NUL-delimited output: split on \0, filter empty strings.
const files = new Set<string>();
for (const line of output.split('\n')) {
const trimmed = line.trim();
for (const entry of output.split('\0')) {
const trimmed = entry.trim();
if (trimmed) files.add(trimmed);
}

return Array.from(files);
} catch {
return [];
}
}
}
Loading