diff --git a/commands/egc-grok.toml b/commands/egc-grok.toml new file mode 100644 index 0000000..36ba94e --- /dev/null +++ b/commands/egc-grok.toml @@ -0,0 +1,83 @@ +description = "Grok the entire repo: architecture map, circular deps, dead-file candidates" +prompt = ''' +# /egc-grok + +Run a deterministic, full-repo audit using Gemini's 1M context window. +Produces: + +- File inventory and language breakdown +- JS/TS module dependency graph (relative imports) +- Circular dependency detection (Tarjan SCC) +- Dead-file candidates (no incoming imports, not entry points) +- Synthesized architectural narrative + Top 3 actions + +## Usage + +`/egc-grok [scope] [--format text|json]` + +- `scope` (optional): path to analyze (defaults to repo root) +- `--format`: `text` (default) or `json` + +## Deterministic Engine + +Always run: + +```bash +node scripts/grok.js [scope] --format +``` + +This script is the single source of truth for the inventory, graph, +cycles, and dead-file analysis. Do not re-derive these dimensions in +prose and do not invent file paths, counts, or cycles that the script +did not emit. + +## Synthesis Contract + +After running the script, produce a synthesis section ABOVE the raw +report: + +1. **Architecture map** — 2-3 sentence narrative of the repo's + structure, anchored in the script's directory and language stats. +2. **Hotspots** — concrete findings keyed to script output: + - Each circular dependency (cite the files exactly) + - Up to 10 dead-file candidates (cite paths exactly) +3. **Top 3 actions** — prioritized, each with: (a) what to do, + (b) which files, (c) why this beats the alternatives. + +## Rules + +- Use script output verbatim for all file paths and counts. +- If `--format json` was requested, return the script JSON unchanged + with no synthesis layer. +- If text was requested, prepend the synthesis above the raw report. +- Do not score or assign severity beyond what the script emits. +- Do not propose fixes that touch files outside the dead-file or + cycle lists unless the user asks. + +## Example Result (text) + +```text +## Architecture map +Node.js CLI bundle. 481 files (most JS + markdown), JS code lives under +scripts/ and hooks/. Tests are co-located in tests/lib/. + +## Hotspots +- Cycle: scripts/lib/a.js -> scripts/lib/b.js -> scripts/lib/a.js +- Dead candidates (3): scripts/old-helper.js, ... + +## Top 3 actions +1. Break the lib/a <-> lib/b cycle by ... (scripts/lib/a.js, b.js). +2. Delete or wire up scripts/old-helper.js ... +3. ... + +# Repo Grok: everything-gemini-code +... (raw script output) ... +``` + +## Arguments + +$ARGUMENTS: +- `scope` (optional path) +- `--format text|json` (optional output format) + +''' diff --git a/scripts/grok.js b/scripts/grok.js new file mode 100644 index 0000000..12f27ca --- /dev/null +++ b/scripts/grok.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const engine = require('./lib/grok-engine'); + +const REPO_ROOT = path.join(__dirname, '..'); + +function valueAfterEquals(arg) { + return arg.slice(arg.indexOf('=') + 1); +} + +function requireValue(flag, value) { + if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + scope: REPO_ROOT, + format: 'text', + help: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + continue; + } + + if (arg === '--format') { + parsed.format = requireValue('--format', args[index + 1]).toLowerCase(); + index += 1; + continue; + } + + if (arg === '--scope') { + parsed.scope = path.resolve(requireValue('--scope', args[index + 1])); + index += 1; + continue; + } + + if (arg.startsWith('--format=')) { + parsed.format = valueAfterEquals(arg).toLowerCase(); + continue; + } + + if (arg.startsWith('--scope=')) { + parsed.scope = path.resolve(valueAfterEquals(arg)); + continue; + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown argument: ${arg}`); + } + + parsed.scope = path.resolve(arg); + } + + if (!['text', 'json'].includes(parsed.format)) { + throw new Error(`Invalid format: ${parsed.format}. Use text or json.`); + } + + return parsed; +} + +function ensureDirectory(scopePath) { + let stat; + try { + stat = fs.statSync(scopePath); + } catch (err) { + throw new Error(`Scope path not found: ${scopePath} (${err.code || err.message})`); + } + if (!stat.isDirectory()) { + throw new Error(`Scope path is not a directory: ${scopePath}`); + } +} + +function printHelp() { + const lines = [ + 'Usage: node scripts/grok.js [scope] [--format text|json]', + '', + 'Run a deterministic, full-repo audit and emit an architecture map,', + 'circular dependency report, and dead-file candidates.', + '', + 'Arguments:', + ' scope Directory to analyze (default: repo root)', + ' --scope Same, as a flag', + ' --format text (default) or json', + ' --help, -h Show this help', + ]; + console.log(lines.join('\n')); +} + +function main() { + let parsed; + try { + parsed = parseArgs(process.argv); + } catch (err) { + console.error(err.message); + process.exit(1); + } + + if (parsed.help) { + printHelp(); + return; + } + + try { + ensureDirectory(parsed.scope); + const report = engine.buildReport(parsed.scope); + const output = parsed.format === 'json' ? engine.formatJson(report) : engine.formatText(report); + console.log(output); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { parseArgs, ensureDirectory }; diff --git a/scripts/lib/grok-engine.js b/scripts/lib/grok-engine.js new file mode 100644 index 0000000..f5494fe --- /dev/null +++ b/scripts/lib/grok-engine.js @@ -0,0 +1,447 @@ +/** + * Grok engine: deterministic full-repo audit. + * + * Pure(ish) functions used by scripts/grok.js and tested in + * tests/lib/grok-engine.test.js. Builds an inventory, a JS/TS import + * graph, detects cycles via Tarjan's SCC, and lists dead-file + * candidates. The slash command uses these outputs verbatim and only + * adds synthesis on top. + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_IGNORE = Object.freeze([ + 'node_modules', + '.git', + 'dist', + 'build', + 'coverage', + '.next', + '.turbo', + '.cache', + 'out', +]); + +const LANGUAGE_BY_EXT = Object.freeze({ + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.kt': 'kotlin', + '.swift': 'swift', + '.md': 'markdown', + '.toml': 'toml', + '.json': 'json', + '.yml': 'yaml', + '.yaml': 'yaml', + '.sh': 'shell', +}); + +const JS_LIKE_EXT = Object.freeze(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']); + +function walkRepo(rootDir, options = {}) { + const ignore = new Set(options.ignore || DEFAULT_IGNORE); + const errors = options.errors || []; + const results = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch (err) { + errors.push({ + path: path.relative(rootDir, current) || '.', + op: 'readdir', + message: err.message, + }); + continue; + } + + for (const entry of entries) { + if (ignore.has(entry.name)) continue; + // Skip hidden dirs/files except .github (CI configs). + if (entry.name.startsWith('.') && entry.name !== '.github') continue; + + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + results.push(path.relative(rootDir, fullPath)); + } + } + } + + return results.sort(); +} + +function classifyLanguage(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return LANGUAGE_BY_EXT[ext] || 'other'; +} + +function countLines(absolutePath) { + try { + const content = fs.readFileSync(absolutePath, 'utf8'); + if (content.length === 0) return 0; + return content.split('\n').length; + } catch (_err) { + return 0; + } +} + +function summarizeFiles(files, rootDir) { + const summary = { + totalFiles: files.length, + totalCodeLines: 0, + byLanguage: {}, + }; + + for (const rel of files) { + const lang = classifyLanguage(rel); + if (!summary.byLanguage[lang]) { + summary.byLanguage[lang] = { files: 0, lines: 0 }; + } + summary.byLanguage[lang].files += 1; + + if (lang === 'javascript' || lang === 'typescript') { + const lines = countLines(path.join(rootDir, rel)); + summary.byLanguage[lang].lines += lines; + summary.totalCodeLines += lines; + } + } + + return summary; +} + +// Strips line/block comments. Does NOT strip string literals — an import-like +// pattern inside a string (e.g. `"const a = require('x')"`) will produce a +// false edge. Acceptable for v1 because grok-engine only consumes the result +// to build a graph and reports it as a "candidate" for synthesis. +function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +const REQUIRE_RE = /require\(\s*['"]([^'"]+)['"]\s*\)/g; +const IMPORT_FROM_RE = /(?:^|[\s;])import\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/g; +const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g; +const EXPORT_FROM_RE = /(?:^|[\s;])export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g; + +function extractImports(content) { + const cleaned = stripComments(content); + const imports = new Set(); + const patterns = [REQUIRE_RE, IMPORT_FROM_RE, DYNAMIC_IMPORT_RE, EXPORT_FROM_RE]; + + for (const pattern of patterns) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(cleaned)) !== null) { + imports.add(match[1]); + } + } + + return Array.from(imports).sort(); +} + +function isRelativeImport(specifier) { + return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/'); +} + +function resolveRelativeImport(fromRel, specifier, fileSet) { + if (!isRelativeImport(specifier)) return null; + + const fromDir = path.dirname(fromRel); + const targetBase = path.normalize(path.join(fromDir, specifier)); + const candidates = [targetBase]; + + for (const ext of JS_LIKE_EXT) { + candidates.push(targetBase + ext); + candidates.push(path.join(targetBase, `index${ext}`)); + } + + for (const candidate of candidates) { + if (fileSet.has(candidate)) return candidate; + } + return null; +} + +function buildGraph(files, rootDir, errors = []) { + const jsFiles = files.filter((f) => JS_LIKE_EXT.includes(path.extname(f))); + const fileSet = new Set(jsFiles); + const edges = new Map(); + const reverse = new Map(); + const unresolved = []; + + for (const rel of jsFiles) { + edges.set(rel, new Set()); + reverse.set(rel, new Set()); + } + + for (const rel of jsFiles) { + let content; + try { + content = fs.readFileSync(path.join(rootDir, rel), 'utf8'); + } catch (err) { + errors.push({ path: rel, op: 'readFile', message: err.message }); + continue; + } + + const specs = extractImports(content); + for (const spec of specs) { + if (!isRelativeImport(spec)) continue; + const resolved = resolveRelativeImport(rel, spec, fileSet); + if (resolved) { + edges.get(rel).add(resolved); + reverse.get(resolved).add(rel); + } else { + unresolved.push({ from: rel, specifier: spec }); + } + } + } + + return { nodes: jsFiles, edges, reverse, unresolved }; +} + +function detectCycles(graph) { + const { nodes, edges } = graph; + const indices = new Map(); + const lowlinks = new Map(); + const onStack = new Set(); + const stack = []; + const sccs = []; + let nextIndex = 0; + + function strongconnect(v) { + indices.set(v, nextIndex); + lowlinks.set(v, nextIndex); + nextIndex += 1; + stack.push(v); + onStack.add(v); + + const adj = edges.get(v) || new Set(); + for (const w of adj) { + if (!indices.has(w)) { + strongconnect(w); + lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w))); + } else if (onStack.has(w)) { + lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w))); + } + } + + if (lowlinks.get(v) === indices.get(v)) { + const component = []; + let w; + do { + w = stack.pop(); + onStack.delete(w); + component.push(w); + } while (w !== v); + sccs.push(component); + } + } + + for (const v of nodes) { + if (!indices.has(v)) strongconnect(v); + } + + return sccs.filter((scc) => { + if (scc.length > 1) return true; + const v = scc[0]; + const adj = edges.get(v); + return adj && adj.has(v); + }); +} + +const DEFAULT_ENTRY_PATTERNS = Object.freeze([ + // CLI scripts at any depth under scripts/ + /^scripts\/.+\.(?:js|mjs|cjs)$/, + // Hooks at any depth under hooks/ + /^hooks\/.+\.(?:js|mjs|cjs)$/, + // Skill-bundled commands, hooks, and renderable assets + /^skills\/[^/]+\/(?:commands|hooks)\/.+\.(?:js|mjs|cjs)$/, + /^skills\/.+\/(?:assets|templates|rules\/assets)\/.+$/, + // Test files + /^tests\/.+\.test\.js$/, + /^tests\/run-all\.js$/, + // Root config files (any *.config.js at the top level) + /^[^/]+\.config\.(?:js|mjs|cjs)$/, +]); + +// `package.json` paths can carry leading `./` or `/`, but walkRepo emits +// repo-relative paths without that prefix. Normalize so both shapes match. +function normalizePackagePath(value) { + const stripped = value.replace(/^\.\//, '').replace(/^\//, ''); + return path.posix.normalize(stripped); +} + +function loadEntryPatterns(rootDir, errors = []) { + const patterns = [...DEFAULT_ENTRY_PATTERNS]; + + try { + const pkgRaw = fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'); + const pkg = JSON.parse(pkgRaw); + if (typeof pkg.main === 'string') { + patterns.push(normalizePackagePath(pkg.main)); + } + if (pkg.bin) { + const binValues = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin); + for (const value of binValues) { + if (typeof value === 'string') patterns.push(normalizePackagePath(value)); + } + } + } catch (err) { + if (err && err.code !== 'ENOENT') { + errors.push({ path: 'package.json', op: 'parse', message: err.message }); + } + } + + return patterns; +} + +function isEntryFile(rel, entryPatterns) { + for (const pattern of entryPatterns) { + if (pattern instanceof RegExp) { + if (pattern.test(rel)) return true; + } else if (typeof pattern === 'string' && rel === pattern) { + return true; + } + } + return false; +} + +function detectDeadFiles(graph, entryPatterns) { + const { nodes, reverse } = graph; + const dead = []; + + for (const node of nodes) { + if (isEntryFile(node, entryPatterns)) continue; + const incoming = reverse.get(node) || new Set(); + if (incoming.size === 0) dead.push(node); + } + + return dead.sort(); +} + +function countEdges(graph) { + let total = 0; + for (const adj of graph.edges.values()) total += adj.size; + return total; +} + +function buildReport(rootDir, options = {}) { + const errors = []; + const files = walkRepo(rootDir, { ignore: options.ignore, errors }); + const summary = summarizeFiles(files, rootDir); + const graph = buildGraph(files, rootDir, errors); + const cycles = detectCycles(graph); + const entryPatterns = loadEntryPatterns(rootDir, errors); + const deadFiles = detectDeadFiles(graph, entryPatterns); + + return { + rootDir: path.basename(rootDir), + summary, + dependencyGraph: { + nodeCount: graph.nodes.length, + edgeCount: countEdges(graph), + unresolvedCount: graph.unresolved.length, + }, + cycles: cycles.map((files) => ({ size: files.length, files })), + deadFiles, + errors, + }; +} + +function formatText(report) { + const lines = []; + lines.push(`# Repo Grok: ${report.rootDir}`); + lines.push(''); + + lines.push('## Inventory'); + lines.push(`- Total files: ${report.summary.totalFiles}`); + lines.push(`- JS/TS lines: ${report.summary.totalCodeLines}`); + lines.push(''); + + lines.push('### By language'); + const langs = Object.entries(report.summary.byLanguage).sort((a, b) => b[1].files - a[1].files); + for (const [lang, stats] of langs) { + const linePart = stats.lines ? `, ${stats.lines} lines` : ''; + lines.push(`- ${lang}: ${stats.files} files${linePart}`); + } + lines.push(''); + + lines.push('## Dependency graph'); + lines.push(`- Nodes (JS/TS files): ${report.dependencyGraph.nodeCount}`); + lines.push(`- Edges (relative imports): ${report.dependencyGraph.edgeCount}`); + lines.push(`- Unresolved relative imports: ${report.dependencyGraph.unresolvedCount}`); + lines.push(''); + + lines.push(`## Circular dependencies (${report.cycles.length})`); + if (report.cycles.length === 0) { + lines.push('- None detected'); + } else { + for (const cycle of report.cycles) { + const chain = cycle.files.join(' -> '); + lines.push(`- (size ${cycle.size}) ${chain} -> ${cycle.files[0]}`); + } + } + lines.push(''); + + lines.push(`## Dead-file candidates (${report.deadFiles.length})`); + if (report.deadFiles.length === 0) { + lines.push('- None detected'); + } else { + for (const f of report.deadFiles) { + lines.push(`- ${f}`); + } + } + lines.push(''); + + const errors = report.errors || []; + if (errors.length > 0) { + lines.push(`## Read errors (${errors.length})`); + lines.push('Some files or directories could not be read; results may be incomplete.'); + for (const err of errors) { + lines.push(`- [${err.op}] ${err.path}: ${err.message}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +function formatJson(report) { + return JSON.stringify(report, null, 2); +} + +module.exports = { + DEFAULT_IGNORE, + JS_LIKE_EXT, + walkRepo, + classifyLanguage, + countLines, + summarizeFiles, + stripComments, + extractImports, + isRelativeImport, + resolveRelativeImport, + buildGraph, + detectCycles, + loadEntryPatterns, + normalizePackagePath, + isEntryFile, + detectDeadFiles, + countEdges, + buildReport, + formatText, + formatJson, +}; diff --git a/tests/lib/grok-cli.test.js b/tests/lib/grok-cli.test.js new file mode 100644 index 0000000..1eebd43 --- /dev/null +++ b/tests/lib/grok-cli.test.js @@ -0,0 +1,91 @@ +/** + * Tests for scripts/grok.js CLI argument parsing and validation. + * + * Run with: node tests/lib/grok-cli.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const cli = require('../../scripts/grok'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed += 1; + } +} + +console.log('\n=== Testing scripts/grok.js (CLI) ===\n'); + +test('parseArgs accepts --format text', () => { + const parsed = cli.parseArgs(['node', 'grok.js', '--format', 'text']); + assert.strictEqual(parsed.format, 'text'); +}); + +test('parseArgs accepts --format=json', () => { + const parsed = cli.parseArgs(['node', 'grok.js', '--format=json']); + assert.strictEqual(parsed.format, 'json'); +}); + +test('parseArgs rejects --format without a value', () => { + assert.throws(() => cli.parseArgs(['node', 'grok.js', '--format']), /Missing value/); +}); + +test('parseArgs rejects --scope followed by another flag', () => { + assert.throws( + () => cli.parseArgs(['node', 'grok.js', '--scope', '--format', 'text']), + /Missing value/, + ); +}); + +test('parseArgs rejects unknown flags', () => { + assert.throws(() => cli.parseArgs(['node', 'grok.js', '--bogus']), /Unknown argument/); +}); + +test('parseArgs rejects invalid format value', () => { + assert.throws(() => cli.parseArgs(['node', 'grok.js', '--format', 'xml']), /Invalid format/); +}); + +test('parseArgs preserves = in --scope= values', () => { + // A directory name containing `=` (rare but valid). split('=')[1] would + // truncate; slice from the first `=` should preserve the full value. + const parsed = cli.parseArgs(['node', 'grok.js', '--scope=/tmp/odd=name']); + assert.ok(parsed.scope.endsWith('odd=name'), `scope: ${parsed.scope}`); +}); + +test('ensureDirectory throws on non-existent path', () => { + const missing = path.join(os.tmpdir(), `grok-missing-${Date.now()}`); + assert.throws(() => cli.ensureDirectory(missing), /not found/i); +}); + +test('ensureDirectory throws when path is a file', () => { + const tmpFile = path.join(os.tmpdir(), `grok-file-${Date.now()}.txt`); + fs.writeFileSync(tmpFile, 'x'); + try { + assert.throws(() => cli.ensureDirectory(tmpFile), /not a directory/i); + } finally { + fs.rmSync(tmpFile, { force: true }); + } +}); + +test('ensureDirectory accepts an existing directory', () => { + cli.ensureDirectory(os.tmpdir()); +}); + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/lib/grok-engine.test.js b/tests/lib/grok-engine.test.js new file mode 100644 index 0000000..45eb2e5 --- /dev/null +++ b/tests/lib/grok-engine.test.js @@ -0,0 +1,377 @@ +/** + * Tests for scripts/lib/grok-engine.js + * + * Run with: node tests/lib/grok-engine.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const engine = require('../../scripts/lib/grok-engine'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed += 1; + } +} + +function makeTempRepo(structure) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grok-test-')); + for (const [rel, content] of Object.entries(structure)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return root; +} + +function rmTempRepo(root) { + fs.rmSync(root, { recursive: true, force: true }); +} + +console.log('\n=== Testing grok-engine.js ===\n'); + +test('extractImports finds require()', () => { + const imports = engine.extractImports("const x = require('./a');"); + assert.deepStrictEqual(imports, ['./a']); +}); + +test('extractImports finds named and default import-from', () => { + const imports = engine.extractImports("import x from './a';\nimport { y } from './b';"); + assert.deepStrictEqual(imports, ['./a', './b']); +}); + +test('extractImports finds bare side-effect imports', () => { + const imports = engine.extractImports("import './side-effect';"); + assert.deepStrictEqual(imports, ['./side-effect']); +}); + +test('extractImports finds dynamic import()', () => { + const imports = engine.extractImports("const m = await import('./lazy');"); + assert.deepStrictEqual(imports, ['./lazy']); +}); + +test('extractImports finds export-from re-exports', () => { + const imports = engine.extractImports("export { x } from './re';"); + assert.deepStrictEqual(imports, ['./re']); +}); + +test('extractImports ignores commented-out imports', () => { + const src = ` + // const a = require('./skip-line'); + /* import b from './skip-block'; */ + const c = require('./keep'); + `; + const imports = engine.extractImports(src); + assert.deepStrictEqual(imports, ['./keep']); +}); + +test('extractImports does not strip URLs containing //', () => { + const src = "const url = 'https://example.com'; require('./real');"; + const imports = engine.extractImports(src); + assert.deepStrictEqual(imports, ['./real']); +}); + +test('isRelativeImport classifies relative vs package specifiers', () => { + assert.strictEqual(engine.isRelativeImport('./a'), true); + assert.strictEqual(engine.isRelativeImport('../a'), true); + assert.strictEqual(engine.isRelativeImport('/abs/path'), true); + assert.strictEqual(engine.isRelativeImport('lodash'), false); + assert.strictEqual(engine.isRelativeImport('@scope/pkg'), false); +}); + +test('walkRepo skips node_modules and hidden dirs', () => { + const root = makeTempRepo({ + 'a.js': '', + 'src/b.js': '', + 'node_modules/lib/index.js': '', + '.git/HEAD': '', + '.cache/x': '', + }); + try { + const files = engine.walkRepo(root); + assert.ok(files.includes('a.js')); + assert.ok(files.includes(path.join('src', 'b.js'))); + assert.ok(!files.some((f) => f.includes('node_modules'))); + assert.ok(!files.some((f) => f.startsWith('.git'))); + assert.ok(!files.some((f) => f.startsWith('.cache'))); + } finally { + rmTempRepo(root); + } +}); + +test('walkRepo keeps .github directory', () => { + const root = makeTempRepo({ + '.github/workflows/ci.yml': 'name: ci', + }); + try { + const files = engine.walkRepo(root); + assert.ok(files.some((f) => f.includes('.github'))); + } finally { + rmTempRepo(root); + } +}); + +test('summarizeFiles groups by language and counts JS/TS lines', () => { + const root = makeTempRepo({ + 'a.js': 'line1\nline2\n', + 'b.ts': 'line1\n', + 'README.md': '# hi', + 'package.json': '{"name":"t"}', + }); + try { + const files = engine.walkRepo(root); + const sum = engine.summarizeFiles(files, root); + assert.strictEqual(sum.byLanguage.javascript.files, 1); + assert.strictEqual(sum.byLanguage.typescript.files, 1); + assert.strictEqual(sum.byLanguage.markdown.files, 1); + assert.strictEqual(sum.byLanguage.json.files, 1); + assert.ok(sum.totalCodeLines > 0); + } finally { + rmTempRepo(root); + } +}); + +test('buildGraph + detectCycles finds simple 2-cycle', () => { + const root = makeTempRepo({ + 'a.js': "require('./b');", + 'b.js': "require('./a');", + 'package.json': '{"name":"t"}', + }); + try { + const files = engine.walkRepo(root); + const graph = engine.buildGraph(files, root); + const cycles = engine.detectCycles(graph); + assert.strictEqual(cycles.length, 1); + assert.strictEqual(cycles[0].length, 2); + } finally { + rmTempRepo(root); + } +}); + +test('detectCycles finds self-loop', () => { + const root = makeTempRepo({ + 'a.js': "require('./a');", + 'package.json': '{"name":"t"}', + }); + try { + const files = engine.walkRepo(root); + const graph = engine.buildGraph(files, root); + const cycles = engine.detectCycles(graph); + assert.strictEqual(cycles.length, 1); + assert.strictEqual(cycles[0].length, 1); + } finally { + rmTempRepo(root); + } +}); + +test('detectCycles returns none for a DAG', () => { + const root = makeTempRepo({ + 'a.js': "require('./b'); require('./c');", + 'b.js': "require('./c');", + 'c.js': 'module.exports = {};', + 'package.json': '{"name":"t"}', + }); + try { + const files = engine.walkRepo(root); + const graph = engine.buildGraph(files, root); + const cycles = engine.detectCycles(graph); + assert.strictEqual(cycles.length, 0); + } finally { + rmTempRepo(root); + } +}); + +test('resolveRelativeImport resolves index files', () => { + const fileSet = new Set(['lib/foo/index.js', 'a.js']); + const resolved = engine.resolveRelativeImport('a.js', './lib/foo', fileSet); + assert.strictEqual(resolved, path.join('lib', 'foo', 'index.js')); +}); + +test('detectDeadFiles flags orphans and respects entry patterns', () => { + const root = makeTempRepo({ + 'scripts/main.js': "require('../lib/used');", + 'lib/used.js': 'module.exports = {};', + 'lib/orphan.js': 'module.exports = {};', + 'package.json': '{"name":"t"}', + }); + try { + const files = engine.walkRepo(root); + const graph = engine.buildGraph(files, root); + const patterns = engine.loadEntryPatterns(root); + const dead = engine.detectDeadFiles(graph, patterns); + assert.ok(dead.includes(path.join('lib', 'orphan.js')), `expected orphan in dead, got ${JSON.stringify(dead)}`); + assert.ok(!dead.some((f) => f.endsWith('main.js')), 'scripts/*.js should be entry'); + assert.ok(!dead.some((f) => f.endsWith('used.js')), 'used.js has incoming edge'); + } finally { + rmTempRepo(root); + } +}); + +test('loadEntryPatterns includes package.json bin entries', () => { + const root = makeTempRepo({ + 'package.json': JSON.stringify({ name: 't', bin: { mycli: 'cli/index.js' } }), + }); + try { + const patterns = engine.loadEntryPatterns(root); + assert.ok(patterns.some((p) => p === 'cli/index.js')); + } finally { + rmTempRepo(root); + } +}); + +test('loadEntryPatterns normalizes ./ and / prefixes from main/bin', () => { + const root = makeTempRepo({ + 'package.json': JSON.stringify({ + name: 't', + main: './index.js', + bin: { a: '/cli/a.js', b: './cli/b.js' }, + }), + 'index.js': '', + 'cli/a.js': '', + 'cli/b.js': '', + }); + try { + const patterns = engine.loadEntryPatterns(root); + const stringPatterns = patterns.filter((p) => typeof p === 'string'); + assert.ok(stringPatterns.includes('index.js'), `main not normalized: ${JSON.stringify(stringPatterns)}`); + assert.ok(stringPatterns.includes('cli/a.js'), `bin /cli/a.js not normalized: ${JSON.stringify(stringPatterns)}`); + assert.ok(stringPatterns.includes('cli/b.js'), `bin ./cli/b.js not normalized: ${JSON.stringify(stringPatterns)}`); + } finally { + rmTempRepo(root); + } +}); + +test('detectDeadFiles does not flag pkg.main even with ./ prefix', () => { + const root = makeTempRepo({ + 'package.json': JSON.stringify({ name: 't', main: './index.js' }), + 'index.js': "require('./helper');", + 'helper.js': 'module.exports = {};', + }); + try { + const files = engine.walkRepo(root); + const graph = engine.buildGraph(files, root); + const patterns = engine.loadEntryPatterns(root); + const dead = engine.detectDeadFiles(graph, patterns); + assert.ok(!dead.includes('index.js'), `index.js should be an entry, got dead: ${JSON.stringify(dead)}`); + } finally { + rmTempRepo(root); + } +}); + +test('normalizePackagePath strips leading ./ and /', () => { + assert.strictEqual(engine.normalizePackagePath('./a/b.js'), 'a/b.js'); + assert.strictEqual(engine.normalizePackagePath('/a/b.js'), 'a/b.js'); + assert.strictEqual(engine.normalizePackagePath('a/b.js'), 'a/b.js'); +}); + +test('buildReport surfaces fs read errors in errors[] without throwing', () => { + const root = makeTempRepo({ + 'a.js': "require('./b');", + 'b.js': 'module.exports = {};', + 'package.json': '{"name":"t"}', + }); + try { + // Inject a path that walkRepo will discover but not be able to read. + // Simulating: chmod is awkward cross-platform — instead, point at + // a non-existent subdir via the public API's error collection in + // walkRepo by deleting after listing. Easiest verification: just + // assert the field shape on a healthy run is an empty array. + const report = engine.buildReport(root); + assert.ok(Array.isArray(report.errors)); + assert.strictEqual(report.errors.length, 0); + } finally { + rmTempRepo(root); + } +}); + +test('walkRepo records readdir errors via errors collector', () => { + const root = makeTempRepo({ 'a.js': '' }); + const errors = []; + try { + // First walk normally; then re-walk after rm to surface errors. + const files = engine.walkRepo(root, { errors }); + assert.ok(files.includes('a.js')); + assert.strictEqual(errors.length, 0); + + // Ask walkRepo to walk a non-existent directory. + const errors2 = []; + const missing = engine.walkRepo(path.join(root, 'does-not-exist'), { errors: errors2 }); + assert.strictEqual(missing.length, 0); + assert.strictEqual(errors2.length, 1); + assert.strictEqual(errors2[0].op, 'readdir'); + } finally { + rmTempRepo(root); + } +}); + +test('buildReport returns expected shape', () => { + const root = makeTempRepo({ + 'a.js': "require('./b');", + 'b.js': 'module.exports = {};', + 'package.json': '{"name":"t"}', + }); + try { + const report = engine.buildReport(root); + assert.ok(report.summary); + assert.ok(report.dependencyGraph); + assert.strictEqual(typeof report.dependencyGraph.nodeCount, 'number'); + assert.ok(Array.isArray(report.cycles)); + assert.ok(Array.isArray(report.deadFiles)); + assert.strictEqual(typeof report.rootDir, 'string'); + } finally { + rmTempRepo(root); + } +}); + +test('formatText renders all sections', () => { + const root = makeTempRepo({ + 'a.js': "require('./b');", + 'b.js': "require('./a');", + 'package.json': '{"name":"t"}', + }); + try { + const report = engine.buildReport(root); + const text = engine.formatText(report); + assert.ok(text.includes('# Repo Grok')); + assert.ok(text.includes('## Inventory')); + assert.ok(text.includes('## Dependency graph')); + assert.ok(text.includes('## Circular dependencies')); + assert.ok(text.includes('## Dead-file candidates')); + } finally { + rmTempRepo(root); + } +}); + +test('formatJson is valid JSON', () => { + const root = makeTempRepo({ + 'a.js': '', + 'package.json': '{"name":"t"}', + }); + try { + const report = engine.buildReport(root); + const json = engine.formatJson(report); + const parsed = JSON.parse(json); + assert.strictEqual(parsed.rootDir, path.basename(root)); + } finally { + rmTempRepo(root); + } +}); + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/run-all.js b/tests/run-all.js index b2916ad..121da6e 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -16,6 +16,8 @@ const testFiles = [ 'lib/session-manager.test.js', 'lib/session-aliases.test.js', 'lib/gemini-tools.test.js', + 'lib/grok-engine.test.js', + 'lib/grok-cli.test.js', 'hooks/hooks.test.js', 'hooks/block-no-verify.test.js', 'ci/validate-workflow-security.test.js', diff --git a/workflows/grok.md b/workflows/grok.md new file mode 100644 index 0000000..2ea077a --- /dev/null +++ b/workflows/grok.md @@ -0,0 +1,57 @@ +--- +description: Full-repo audit using Gemini's 1M context — architecture, circular deps, dead files. +--- + +# Grok + +Run a deterministic, full-repo audit using Gemini's 1M context window. +Produces: + +- File inventory and language breakdown +- JS/TS module dependency graph (relative imports) +- Circular dependency detection (Tarjan SCC) +- Dead-file candidates (no incoming imports, not entry points) +- Synthesized architectural narrative + Top 3 actions + +## Usage + +`/egc-grok [scope] [--format text|json]` + +- `scope` (optional): path to analyze (defaults to repo root) +- `--format`: `text` (default) or `json` + +## Deterministic Engine + +Always run: + +```bash +node scripts/grok.js [scope] --format +``` + +This script is the single source of truth for the inventory, graph, +cycles, and dead-file analysis. Do not re-derive these dimensions in +prose and do not invent file paths, counts, or cycles that the script +did not emit. + +## Synthesis Contract + +After running the script, produce a synthesis section ABOVE the raw +report: + +1. **Architecture map** — 2-3 sentence narrative of the repo's + structure, anchored in the script's directory and language stats. +2. **Hotspots** — concrete findings keyed to script output: + - Each circular dependency (cite the files exactly) + - Up to 10 dead-file candidates (cite paths exactly) +3. **Top 3 actions** — prioritized, each with: (a) what to do, + (b) which files, (c) why this beats the alternatives. + +## Rules + +- Use script output verbatim for all file paths and counts. +- If `--format json` was requested, return the script JSON unchanged + with no synthesis layer. +- If text was requested, prepend the synthesis above the raw report. +- Do not score or assign severity beyond what the script emits. +- Do not propose fixes that touch files outside the dead-file or + cycle lists unless the user asks.