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:
2026-06-09 17:45:19 +02:00
commit fd958671ca
20 changed files with 4667 additions and 0 deletions
+13
View File
@@ -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
View File
@@ -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();
+23
View File
@@ -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;
}
+56
View File
@@ -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;
}
+40
View File
@@ -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');
}
+5
View File
@@ -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
View File
@@ -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);
}
+48
View File
@@ -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'
};
}
+25
View File
@@ -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])]
};
}
+48
View File
@@ -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}`);
}
}
+90
View File
@@ -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;
}