Initial commit: pi-project-map skill scaffolding
- Design doc with full spec (dense markdown format, hybrid AST+LLM pipeline, consumption model, stale data mitigation) - Implementation plan with 6 milestones and rollout strategy - TypeScript package structure with all source stubs - CLI entry point, formatter, discover, init, patch, validate, extract, merge - Pi SKILL.md with tool definitions and format documentation
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
interface ASTFileData {
|
||||
exports: string[];
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
export async function extractFileAST(filePath: string): Promise<ASTFileData | null> {
|
||||
// TODO: integrate tree-sitter
|
||||
// Detect language from extension
|
||||
// Parse and extract symbols
|
||||
return null;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { initProject } from './init.js';
|
||||
import { patchFile } from './patch.js';
|
||||
import { validateMaps } from './validate.js';
|
||||
import { reinitPath } from './init.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case 'init':
|
||||
await initProject(args[1] || '.');
|
||||
break;
|
||||
case 'patch':
|
||||
await patchFile(args[1]);
|
||||
break;
|
||||
case 'validate': {
|
||||
const result = await validateMaps(args[1] || '.');
|
||||
process.exit(result.clean ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
case 'reinit':
|
||||
await reinitPath(args[1] || '.');
|
||||
break;
|
||||
default:
|
||||
console.log(`Usage: project-map <init|patch|validate|reinit> [path]`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface SkillConfig {
|
||||
ignorePatterns: string[];
|
||||
smallPackageThreshold: number;
|
||||
llmModel: string;
|
||||
contextBudget: number;
|
||||
autoInjectPrompt: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: SkillConfig = {
|
||||
ignorePatterns: [
|
||||
'node_modules', '.git', 'dist', 'build', 'coverage',
|
||||
'.next', '.venv', '__pycache__', '.DS_Store', '*.log'
|
||||
],
|
||||
smallPackageThreshold: 10,
|
||||
llmModel: 'gpt-4o-mini',
|
||||
contextBudget: 4000,
|
||||
autoInjectPrompt: true
|
||||
};
|
||||
|
||||
export function loadConfig(): SkillConfig {
|
||||
// TODO: load from .pi-project-map.json or similar
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import ignore from 'ignore';
|
||||
|
||||
const DEFAULT_IGNORE = [
|
||||
'node_modules', '.git', 'dist', 'build', 'coverage',
|
||||
'.next', '.venv', '__pycache__', '.DS_Store', '*.log'
|
||||
];
|
||||
|
||||
export interface DirectoryEntry {
|
||||
dirPath: string;
|
||||
relativePath: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export function discoverProject(rootPath: string): DirectoryEntry[] {
|
||||
const ig = ignore().add(DEFAULT_IGNORE);
|
||||
const gitignorePath = join(rootPath, '.gitignore');
|
||||
try {
|
||||
const gitignoreContent = require('fs').readFileSync(gitignorePath, 'utf8');
|
||||
ig.add(gitignoreContent);
|
||||
} catch { /* no .gitignore */ }
|
||||
|
||||
const entries: DirectoryEntry[] = [];
|
||||
|
||||
function walk(dir: string) {
|
||||
const relDir = relative(rootPath, dir) || '.';
|
||||
if (ig.ignores(relDir)) return;
|
||||
|
||||
const items = readdirSync(dir);
|
||||
const files: string[] = [];
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const relPath = join(relDir, item);
|
||||
if (ig.ignores(relPath)) continue;
|
||||
|
||||
const fullPath = join(dir, item);
|
||||
const st = statSync(fullPath);
|
||||
if (st.isDirectory()) {
|
||||
subdirs.push(fullPath);
|
||||
} else {
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ dirPath: dir, relativePath: relDir, files });
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
walk(subdir);
|
||||
}
|
||||
}
|
||||
|
||||
walk(rootPath);
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface PackageMapData {
|
||||
path: string;
|
||||
role: string;
|
||||
files: FileEntry[];
|
||||
arch: string;
|
||||
dirty?: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
purpose: string;
|
||||
exports: string[];
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
export function renderPackageMap(data: PackageMapData): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${data.path}`);
|
||||
lines.push(`## role`);
|
||||
lines.push(data.role);
|
||||
lines.push(`## files`);
|
||||
for (const file of data.files) {
|
||||
const exp = file.exports.length > 0 ? `exp: ${file.exports.join(', ')}` : '';
|
||||
const dep = file.deps.length > 0 ? `dep: ${file.deps.join(', ')}` : '';
|
||||
const parts = [`- ${file.name} | ${file.purpose}`];
|
||||
if (exp) parts.push(exp);
|
||||
if (dep) parts.push(dep);
|
||||
lines.push(parts.join(' | '));
|
||||
}
|
||||
lines.push(`## arch`);
|
||||
lines.push(data.arch);
|
||||
lines.push(`## dirty`);
|
||||
lines.push(data.dirty || '-');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
export function parsePackageMap(_markdown: string): PackageMapData {
|
||||
// TODO: implement robust parser
|
||||
throw new Error('parsePackageMap not yet implemented');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Main entry point for pi-project-map skill
|
||||
export { initProject } from './init.js';
|
||||
export { patchFile } from './patch.js';
|
||||
export { validateMaps } from './validate.js';
|
||||
export { renderPackageMap, parsePackageMap } from './format.js';
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { discoverProject } from './discover.js';
|
||||
import { renderPackageMap, type PackageMapData } from './format.js';
|
||||
import { extractFileLLM } from './llm-extract.js';
|
||||
import { extractFileAST } from './ast-extract.js';
|
||||
import { mergeFileData } from './merge.js';
|
||||
import { extractPackageLLM } from './llm-extract.js';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function initProject(rootPath: string): Promise<void> {
|
||||
const entries = discoverProject(rootPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fileData = [];
|
||||
for (const file of entry.files) {
|
||||
const filePath = join(entry.dirPath, file);
|
||||
const llmData = await extractFileLLM(filePath);
|
||||
const astData = await extractFileAST(filePath);
|
||||
fileData.push(mergeFileData(file, llmData, astData));
|
||||
}
|
||||
|
||||
const packageData = await extractPackageLLM(entry.relativePath, fileData);
|
||||
|
||||
const mapData: PackageMapData = {
|
||||
path: entry.relativePath,
|
||||
role: packageData.role,
|
||||
files: fileData,
|
||||
arch: packageData.arch,
|
||||
dirty: '-'
|
||||
};
|
||||
|
||||
const outPath = join(entry.dirPath, '.pi-map.md');
|
||||
writeFileSync(outPath, renderPackageMap(mapData));
|
||||
}
|
||||
|
||||
console.log(`Generated ${entries.length} .pi-map.md files`);
|
||||
}
|
||||
|
||||
export async function reinitPath(path: string): Promise<void> {
|
||||
// TODO: clear dirty markers and force regeneration
|
||||
await initProject(path);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
interface LLMFileData {
|
||||
purpose: string;
|
||||
exports: string[];
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface LLMPackageData {
|
||||
role: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// Simple in-memory cache
|
||||
const cache = new Map<string, LLMFileData>();
|
||||
|
||||
export async function extractFileLLM(filePath: string): Promise<LLMFileData> {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const hash = createHash('sha256').update(content).digest('hex');
|
||||
|
||||
if (cache.has(hash)) {
|
||||
return cache.get(hash)!;
|
||||
}
|
||||
|
||||
// TODO: integrate with Pi's LLM tool or a generic client
|
||||
// For now, return placeholder data
|
||||
const result: LLMFileData = {
|
||||
purpose: 'TODO: analyze with LLM',
|
||||
exports: [],
|
||||
deps: []
|
||||
};
|
||||
|
||||
cache.set(hash, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function extractPackageLLM(
|
||||
relativePath: string,
|
||||
fileData: { name: string; purpose: string }[]
|
||||
): Promise<LLMPackageData> {
|
||||
// TODO: integrate with Pi's LLM tool
|
||||
// For now, return placeholder data
|
||||
return {
|
||||
role: `TODO: analyze package ${relativePath}`,
|
||||
arch: 'TODO: architectural analysis'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FileEntry } from './format.js';
|
||||
|
||||
interface LLMFileData {
|
||||
purpose: string;
|
||||
exports: string[];
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ASTFileData {
|
||||
exports: string[];
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
export function mergeFileData(
|
||||
fileName: string,
|
||||
llm: LLMFileData,
|
||||
ast: ASTFileData | null
|
||||
): FileEntry {
|
||||
return {
|
||||
name: fileName,
|
||||
purpose: llm.purpose,
|
||||
exports: ast?.exports ?? llm.exports,
|
||||
deps: [...new Set([...(ast?.deps ?? []), ...llm.deps])]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { parsePackageMap, renderPackageMap } from './format.js';
|
||||
import { extractFileLLM } from './llm-extract.js';
|
||||
import { extractFileAST } from './ast-extract.js';
|
||||
import { mergeFileData } from './merge.js';
|
||||
import { readdirSync } from 'fs';
|
||||
|
||||
const SMALL_PACKAGE_THRESHOLD = 10;
|
||||
|
||||
export async function patchFile(filePath: string): Promise<void> {
|
||||
const dirPath = dirname(filePath);
|
||||
const mapPath = join(dirPath, '.pi-map.md');
|
||||
|
||||
if (!existsSync(mapPath)) {
|
||||
// No map exists yet — would need to generate from scratch
|
||||
console.warn(`No .pi-map.md found in ${dirPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = readdirSync(dirPath).filter((f: string) => !f.startsWith('.') && !f.endsWith('.md'));
|
||||
const isSmallPackage = allFiles.length < SMALL_PACKAGE_THRESHOLD;
|
||||
|
||||
if (isSmallPackage) {
|
||||
// Full rewrite for small packages
|
||||
// TODO: import and reuse init logic for a single directory
|
||||
console.log(`Full rewrite of ${mapPath} (small package: ${allFiles.length} files)`);
|
||||
} else {
|
||||
// Section-level patch
|
||||
const existing = parsePackageMap(readFileSync(mapPath, 'utf8'));
|
||||
const llmData = await extractFileLLM(filePath);
|
||||
const astData = await extractFileAST(filePath);
|
||||
const fileName = filePath.split('/').pop()!;
|
||||
const updatedFile = mergeFileData(fileName, llmData, astData);
|
||||
|
||||
// Replace the matching file entry
|
||||
const idx = existing.files.findIndex(f => f.name === updatedFile.name);
|
||||
if (idx >= 0) {
|
||||
existing.files[idx] = updatedFile;
|
||||
} else {
|
||||
existing.files.push(updatedFile);
|
||||
}
|
||||
|
||||
existing.dirty = `${new Date().toISOString()}: ${fileName} patched (section-level)`;
|
||||
writeFileSync(mapPath, renderPackageMap(existing));
|
||||
console.log(`Patched ${mapPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { discoverProject } from './discover.js';
|
||||
import { parsePackageMap } from './format.js';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { extractFileAST } from './ast-extract.js';
|
||||
|
||||
export interface ValidationResult {
|
||||
clean: boolean;
|
||||
discrepancies: Discrepancy[];
|
||||
}
|
||||
|
||||
export interface Discrepancy {
|
||||
type: 'missing' | 'orphaned' | 'stale-signature' | 'dirty';
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function validateMaps(rootPath: string): Promise<ValidationResult> {
|
||||
const discrepancies: Discrepancy[] = [];
|
||||
const entries = discoverProject(rootPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const mapPath = join(entry.dirPath, '.pi-map.md');
|
||||
if (!existsSync(mapPath)) {
|
||||
discrepancies.push({ type: 'missing', path: entry.relativePath, message: 'No .pi-map.md found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapData = parsePackageMap(readFileSync(mapPath, 'utf8'));
|
||||
|
||||
// Check for dirty markers
|
||||
if (mapData.dirty && mapData.dirty !== '-') {
|
||||
discrepancies.push({ type: 'dirty', path: mapPath, message: `Dirty: ${mapData.dirty}` });
|
||||
}
|
||||
|
||||
// Check for orphaned entries
|
||||
for (const fileEntry of mapData.files) {
|
||||
const filePath = join(entry.dirPath, fileEntry.name);
|
||||
if (!existsSync(filePath)) {
|
||||
discrepancies.push({ type: 'orphaned', path: filePath, message: `File listed but deleted: ${fileEntry.name}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new files not in map
|
||||
for (const file of entry.files) {
|
||||
if (!mapData.files.find(f => f.name === file)) {
|
||||
discrepancies.push({ type: 'missing', path: join(entry.dirPath, file), message: `File not in .pi-map.md: ${file}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Check signatures for code files
|
||||
for (const fileEntry of mapData.files) {
|
||||
const filePath = join(entry.dirPath, fileEntry.name);
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
const astData = await extractFileAST(filePath);
|
||||
if (astData) {
|
||||
const listedExports = new Set(fileEntry.exports);
|
||||
const actualExports = new Set(astData.exports);
|
||||
|
||||
for (const exp of listedExports) {
|
||||
if (!actualExports.has(exp)) {
|
||||
discrepancies.push({ type: 'stale-signature', path: filePath, message: `Missing export: ${exp}` });
|
||||
}
|
||||
}
|
||||
for (const exp of actualExports) {
|
||||
if (!listedExports.has(exp)) {
|
||||
discrepancies.push({ type: 'stale-signature', path: filePath, message: `New export: ${exp}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: ValidationResult = {
|
||||
clean: discrepancies.length === 0,
|
||||
discrepancies
|
||||
};
|
||||
|
||||
if (result.clean) {
|
||||
console.log('All .pi-map.md files are clean.');
|
||||
} else {
|
||||
console.log(`Found ${discrepancies.length} discrepancies:`);
|
||||
for (const d of discrepancies) {
|
||||
console.log(` [${d.type}] ${d.path}: ${d.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user