import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, existsSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = resolve( __dirname, '../../../understand-anything-plugin/skills/understand/scan-project.mjs', ); /** * Build a project tree from a `{ relPath: contents }` object. Creates parent * directories as needed. Initializes a real git repo so the script's preferred * `git ls-files` enumeration path is exercised — tests that need the walker * fallback can set `gitInit=false`. */ function setupTree(files, { gitInit = true } = {}) { const root = mkdtempSync(join(tmpdir(), 'ua-scan-test-')); for (const [relPath, contents] of Object.entries(files)) { const abs = join(root, relPath); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, contents, 'utf-8'); } if (gitInit) { // `git ls-files -co --exclude-standard` returns BOTH cached and others // (modulo gitignore), so an `add` is unnecessary for our tests — the // bare repo init is enough for ls-files to enumerate. const init = spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf-8' }); if (init.status !== 0) { // CI without git: continue without it; the walker fallback will fire. } } return root; } /** * Tracks every temp output dir created by runScript() so the global * cleanup can sweep them between tests. The output file must live * OUTSIDE projectRoot because the project's default ignore patterns * do NOT exclude `.understand-anything/` (the dir is reserved for * persistent state, not transient scratch). If we wrote inside * projectRoot, the second call in the determinism test would * enumerate the first call's output file and produce drift. */ const _runScriptOutputDirs = []; /** * Run scan-project.mjs against `projectRoot`. Returns * { status, stdout, stderr, output } where `output` is the parsed JSON * written by the script (or null on failure). */ function runScript(projectRoot) { const outputDir = mkdtempSync(join(tmpdir(), 'ua-scan-out-')); _runScriptOutputDirs.push(outputDir); const outputPath = join(outputDir, 'scan-output.json'); const result = spawnSync('node', [SCRIPT, projectRoot, outputPath], { encoding: 'utf-8', }); let output = null; try { output = JSON.parse(readFileSync(outputPath, 'utf-8')); } catch { /* output missing on hard failure */ } return { status: result.status, stdout: result.stdout, stderr: result.stderr, output }; } /** * Look up the `files[]` entry for a given path. Returns undefined if not * present — callers should `expect(byPath('x')).toBeDefined()` first. */ function byPath(output, path) { return output.files.find(f => f.path === path); } // Sweep every output dir created during a test back to disk-empty between // tests. The top-level afterEach fires after each `it()` regardless of which // describe block it lives in, so a single hook covers the whole file. afterEach(() => { while (_runScriptOutputDirs.length) { const d = _runScriptOutputDirs.pop(); rmSync(d, { recursive: true, force: true }); } }); describe('scan-project.mjs — language detection', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('maps TypeScript/JavaScript extensions to typescript/javascript', () => { projectRoot = setupTree({ 'a.ts': 'export const a = 1;\n', 'b.tsx': 'export const B = () => null;\n', 'c.js': 'module.exports = {};\n', 'd.jsx': 'export default () => null;\n', 'e.mjs': 'export const e = 1;\n', 'f.cjs': 'module.exports = 1;\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'a.ts').language).toBe('typescript'); expect(byPath(r.output, 'b.tsx').language).toBe('typescript'); expect(byPath(r.output, 'c.js').language).toBe('javascript'); expect(byPath(r.output, 'd.jsx').language).toBe('javascript'); expect(byPath(r.output, 'e.mjs').language).toBe('javascript'); expect(byPath(r.output, 'f.cjs').language).toBe('javascript'); }); it('maps Python, Go, Rust, Java, Kotlin, C# to their language ids', () => { projectRoot = setupTree({ 'a.py': 'x = 1\n', 'b.go': 'package main\n', 'c.rs': 'fn main() {}\n', 'd.java': 'class D {}\n', 'e.kt': 'fun main() {}\n', 'f.cs': 'class F {}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'a.py').language).toBe('python'); expect(byPath(r.output, 'b.go').language).toBe('go'); expect(byPath(r.output, 'c.rs').language).toBe('rust'); expect(byPath(r.output, 'd.java').language).toBe('java'); expect(byPath(r.output, 'e.kt').language).toBe('kotlin'); expect(byPath(r.output, 'f.cs').language).toBe('csharp'); }); it('maps Ruby, PHP, C, C++ to their language ids', () => { projectRoot = setupTree({ 'a.rb': 'puts 1\n', 'b.php': ' { projectRoot = setupTree({ 'a.html': '\n', 'b.htm': '\n', 'c.css': '.a { }\n', 'd.scss': '$x: 1;\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'a.html').language).toBe('html'); expect(byPath(r.output, 'b.htm').language).toBe('html'); expect(byPath(r.output, 'c.css').language).toBe('css'); expect(byPath(r.output, 'd.scss').language).toBe('css'); }); it('maps configuration formats (YAML, JSON, JSONC, TOML, XML, Markdown) to their language ids', () => { projectRoot = setupTree({ 'a.yaml': 'x: 1\n', 'b.yml': 'x: 1\n', 'c.json': '{}\n', 'd.jsonc': '{ /* c */ }\n', 'e.toml': 'x = 1\n', 'f.xml': '\n', 'g.md': '# h\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'a.yaml').language).toBe('yaml'); expect(byPath(r.output, 'b.yml').language).toBe('yaml'); expect(byPath(r.output, 'c.json').language).toBe('json'); expect(byPath(r.output, 'd.jsonc').language).toBe('jsonc'); expect(byPath(r.output, 'e.toml').language).toBe('toml'); expect(byPath(r.output, 'f.xml').language).toBe('xml'); expect(byPath(r.output, 'g.md').language).toBe('markdown'); }); it('maps shell + batch + Dockerfile (no extension) to their language ids', () => { projectRoot = setupTree({ 'a.sh': 'echo 1\n', 'b.bat': '@echo off\n', Dockerfile: 'FROM node:22\n', 'Dockerfile.dev': 'FROM node:22\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'a.sh').language).toBe('shell'); expect(byPath(r.output, 'b.bat').language).toBe('batch'); expect(byPath(r.output, 'Dockerfile').language).toBe('dockerfile'); expect(byPath(r.output, 'Dockerfile.dev').language).toBe('dockerfile'); }); it('falls back to "unknown" for files with no extension and no filename match', () => { projectRoot = setupTree({ WEIRD_FILE: 'mystery contents\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'WEIRD_FILE').language).toBe('unknown'); }); it('falls back to bare extension (without dot) for unknown extensions', () => { projectRoot = setupTree({ 'data.weirdext': 'some data\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'data.weirdext').language).toBe('weirdext'); }); }); describe('scan-project.mjs — category assignment (project-scanner.md Step 4)', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('assigns code to TypeScript, JavaScript, Python, Go, Rust source files', () => { projectRoot = setupTree({ 'src/a.ts': 'export const a = 1;\n', 'src/b.py': 'def b(): pass\n', 'src/c.go': 'package main\n', 'src/d.rs': 'fn main() {}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'src/a.ts').fileCategory).toBe('code'); expect(byPath(r.output, 'src/b.py').fileCategory).toBe('code'); expect(byPath(r.output, 'src/c.go').fileCategory).toBe('code'); expect(byPath(r.output, 'src/d.rs').fileCategory).toBe('code'); }); it('assigns config to JSON/YAML/TOML/INI/XML', () => { projectRoot = setupTree({ 'package.json': '{}\n', 'tsconfig.json': '{}\n', 'pyproject.toml': '[project]\nname = "p"\n', 'config.yaml': 'x: 1\n', 'app.ini': '[s]\nk=v\n', 'data.xml': '\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'package.json').fileCategory).toBe('config'); expect(byPath(r.output, 'tsconfig.json').fileCategory).toBe('config'); expect(byPath(r.output, 'pyproject.toml').fileCategory).toBe('config'); expect(byPath(r.output, 'config.yaml').fileCategory).toBe('config'); expect(byPath(r.output, 'app.ini').fileCategory).toBe('config'); expect(byPath(r.output, 'data.xml').fileCategory).toBe('config'); }); it('assigns docs to .md / .rst / .txt (but NOT to LICENSE)', () => { projectRoot = setupTree({ 'README.md': '# x\n', 'docs/guide.rst': 'Guide\n=====\n', 'NOTES.txt': 'notes\n', LICENSE: 'Apache-2.0\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'README.md').fileCategory).toBe('docs'); expect(byPath(r.output, 'docs/guide.rst').fileCategory).toBe('docs'); expect(byPath(r.output, 'NOTES.txt').fileCategory).toBe('docs'); // LICENSE exception: must NOT be docs. The default ignore filter // normally drops LICENSE entirely, so we re-include it via // `!LICENSE` so the category test can fire. writeFileSync(join(projectRoot, '.understandignore'), '!LICENSE\n'); const r2 = runScript(projectRoot); const license = byPath(r2.output, 'LICENSE'); expect(license).toBeDefined(); expect(license.fileCategory).not.toBe('docs'); }); it('assigns infra to Dockerfile, docker-compose, .gitlab-ci.yml, .tf, .github/workflows/, Makefile, Jenkinsfile, k8s paths', () => { projectRoot = setupTree({ Dockerfile: 'FROM node:22\n', 'docker-compose.yml': 'services: {}\n', '.gitlab-ci.yml': 'stages: []\n', 'infra/main.tf': 'resource "x" "y" {}\n', '.github/workflows/ci.yml': 'name: ci\n', Makefile: 'all:\n\t@echo hi\n', Jenkinsfile: 'pipeline { }\n', 'k8s/deploy.yaml': 'kind: Deployment\n', 'kubernetes/svc.yaml': 'kind: Service\n', 'foo.k8s.yaml': 'kind: ConfigMap\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'Dockerfile').fileCategory).toBe('infra'); expect(byPath(r.output, 'docker-compose.yml').fileCategory).toBe('infra'); expect(byPath(r.output, '.gitlab-ci.yml').fileCategory).toBe('infra'); expect(byPath(r.output, 'infra/main.tf').fileCategory).toBe('infra'); expect(byPath(r.output, '.github/workflows/ci.yml').fileCategory).toBe('infra'); expect(byPath(r.output, 'Makefile').fileCategory).toBe('infra'); expect(byPath(r.output, 'Jenkinsfile').fileCategory).toBe('infra'); expect(byPath(r.output, 'k8s/deploy.yaml').fileCategory).toBe('infra'); expect(byPath(r.output, 'kubernetes/svc.yaml').fileCategory).toBe('infra'); expect(byPath(r.output, 'foo.k8s.yaml').fileCategory).toBe('infra'); }); it('assigns data to SQL, GraphQL, Proto, Prisma, CSV', () => { projectRoot = setupTree({ 'db/schema.sql': 'CREATE TABLE x (id INT);\n', 'api/schema.graphql': 'type X { id: ID! }\n', 'api/types.proto': 'syntax = "proto3";\n', 'prisma/schema.prisma': 'model X { id Int @id }\n', 'data/seed.csv': 'a,b\n1,2\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'db/schema.sql').fileCategory).toBe('data'); expect(byPath(r.output, 'api/schema.graphql').fileCategory).toBe('data'); expect(byPath(r.output, 'api/types.proto').fileCategory).toBe('data'); expect(byPath(r.output, 'prisma/schema.prisma').fileCategory).toBe('data'); expect(byPath(r.output, 'data/seed.csv').fileCategory).toBe('data'); }); it('assigns script to shell + batch files (.sh, .bash, .ps1, .bat)', () => { projectRoot = setupTree({ 'scripts/build.sh': '#!/bin/bash\necho 1\n', 'scripts/run.bash': '#!/bin/bash\necho run\n', 'scripts/build.ps1': 'Write-Output 1\n', 'scripts/setup.bat': '@echo off\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'scripts/build.sh').fileCategory).toBe('script'); expect(byPath(r.output, 'scripts/run.bash').fileCategory).toBe('script'); expect(byPath(r.output, 'scripts/build.ps1').fileCategory).toBe('script'); expect(byPath(r.output, 'scripts/setup.bat').fileCategory).toBe('script'); }); it('assigns markup to HTML + CSS variants', () => { projectRoot = setupTree({ 'public/index.html': '\n', 'public/page.htm': '\n', 'styles/app.css': 'body { }\n', 'styles/app.scss': '$x: 1;\n', 'styles/app.less': '@x: 1;\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'public/index.html').fileCategory).toBe('markup'); expect(byPath(r.output, 'public/page.htm').fileCategory).toBe('markup'); expect(byPath(r.output, 'styles/app.css').fileCategory).toBe('markup'); expect(byPath(r.output, 'styles/app.scss').fileCategory).toBe('markup'); expect(byPath(r.output, 'styles/app.less').fileCategory).toBe('markup'); }); it('priority: docker-compose.yml maps to infra, not config', () => { // The .yml extension would normally route to `config`, but the // docker-compose.* filename rule fires first per Step 4 priority. projectRoot = setupTree({ 'docker-compose.yml': 'services: {}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'docker-compose.yml').fileCategory).toBe('infra'); expect(byPath(r.output, 'docker-compose.yml').language).toBe('yaml'); }); // Regression: path.extname returns '' for `.env` and the second segment // for `.env.local` — neither hits CATEGORY_BY_EXT['.env']. Dotfile-style // configs were falling through to `code` / `unknown`. Caught by Codex // review on PR #204. it('dotfile configs (.env, .env.local, .env.production) map to config + env language', () => { projectRoot = setupTree({ '.env': 'API_KEY=abc\n', '.env.local': 'LOCAL=1\n', '.env.production': 'PROD=1\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); for (const p of ['.env', '.env.local', '.env.production']) { expect(byPath(r.output, p).fileCategory).toBe('config'); // LANGUAGE_BY_EXT['.env'] -> 'config' (the language id itself; not // a typo — the language for env files is the 'config' bucket). expect(byPath(r.output, p).language).toBe('config'); } }); }); describe('scan-project.mjs — .understandignore handling', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('respects .understandignore patterns and increments filteredByIgnore', () => { // `**/*.log` is NOT in the hardcoded defaults at the recursive level // — wait, `*.log` is. Use a custom pattern to exercise user-driven drops. projectRoot = setupTree({ '.understandignore': 'fixtures/\n', 'src/index.ts': 'export const x = 1;\n', 'fixtures/snap1.json': '{ "a": 1 }\n', 'fixtures/snap2.json': '{ "b": 2 }\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); // fixtures/ files dropped expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined(); expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined(); // Counted as user-driven expect(r.output.filteredByIgnore).toBe(2); }); it('supports `!pattern` negation to re-include defaults-excluded files', () => { // `*.log` is in the hardcoded defaults; the user re-includes a // specific file with `!keep.log`. After the override, keep.log MUST // appear in the output. It is NOT counted in filteredByIgnore (it // was re-included, not additionally filtered). projectRoot = setupTree({ '.understandignore': '!keep.log\n', 'src/index.ts': 'export const x = 1;\n', 'keep.log': 'important diagnostic\n', 'drop.log': 'noise\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(byPath(r.output, 'keep.log')).toBeDefined(); // drop.log still excluded by defaults (no negation for it) expect(byPath(r.output, 'drop.log')).toBeUndefined(); // The defaults dropped drop.log — that's a baseline default drop, // NOT a user-driven drop. filteredByIgnore should be 0. expect(r.output.filteredByIgnore).toBe(0); }); }); describe('scan-project.mjs — special-file recognition', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('Dockerfile (no extension) is language=dockerfile, category=infra', () => { projectRoot = setupTree({ Dockerfile: 'FROM alpine:3\nCMD ["sh"]\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); const entry = byPath(r.output, 'Dockerfile'); expect(entry).toBeDefined(); expect(entry.language).toBe('dockerfile'); expect(entry.fileCategory).toBe('infra'); }); }); describe('scan-project.mjs — determinism', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('produces byte-identical output across runs for the same input tree', () => { projectRoot = setupTree({ 'README.md': '# project\n', 'src/a.ts': 'export const a = 1;\n', 'src/b.ts': 'export const b = 2;\n', 'src/lib/c.ts': 'export const c = 3;\n', 'package.json': '{}\n', 'tsconfig.json': '{}\n', }); const r1 = runScript(projectRoot); const r2 = runScript(projectRoot); expect(r1.status).toBe(0); expect(r2.status).toBe(0); expect(JSON.stringify(r1.output)).toBe(JSON.stringify(r2.output)); }); }); describe('scan-project.mjs — empty repo', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('handles a project with zero files without crashing', () => { projectRoot = setupTree({}, { gitInit: true }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.scriptCompleted).toBe(true); expect(r.output.totalFiles).toBe(0); expect(r.output.files).toEqual([]); expect(r.output.filteredByIgnore).toBe(0); expect(r.output.estimatedComplexity).toBe('small'); }); }); describe('scan-project.mjs — per-file failure resilience', () => { let projectRoot; afterEach(() => { if (projectRoot) { // Restore permissions on any chmod'd file before delete, so cleanup // succeeds even when a test left a 000-permission file behind. try { const f = join(projectRoot, 'src/unreadable.ts'); if (existsSync(f)) chmodSync(f, 0o644); } catch { /* best-effort */ } rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('emits a Warning: and skips a file with unreadable permissions; other files survive', () => { if (process.platform === 'win32') { // chmod permission bits don't apply on Windows the same way; skip. return; } if (process.getuid && process.getuid() === 0) { // Running as root bypasses permission checks; the test cannot exercise // its failure mode. Skip rather than emit a false pass. return; } projectRoot = setupTree({ 'src/good.ts': 'export const good = 1;\n', 'src/unreadable.ts': 'export const bad = 2;\n', }); // Strip read permission on the synthetic file. chmodSync(join(projectRoot, 'src/unreadable.ts'), 0o000); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.scriptCompleted).toBe(true); // The good file is in the output. expect(byPath(r.output, 'src/good.ts')).toBeDefined(); // The unreadable file is dropped. expect(byPath(r.output, 'src/unreadable.ts')).toBeUndefined(); // A visible warning was emitted with the documented prefix. expect(r.stderr).toMatch( /Warning: scan-project: src\/unreadable\.ts — line count failed/, ); expect(r.stderr).toMatch(/file skipped from output/); // Final summary line still fires. expect(r.stderr).toMatch( /scan-project: filesScanned=1 filteredByIgnore=0 complexity=small/, ); }); }); describe('scan-project.mjs — estimatedComplexity thresholds', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); /** * Build a tree with exactly N .ts files at the top level. Used to * lock in the complexity-tier boundary points from project-scanner.md * Step 7: small (≤30), moderate (31-150), large (151-500), very-large * (>500). */ function setupNFiles(n) { const tree = {}; for (let i = 0; i < n; i++) { // Pad indices so localeCompare gives the natural order for any N. tree[`f${String(i).padStart(4, '0')}.ts`] = 'export const x = 1;\n'; } return setupTree(tree); } it('30 files -> small (upper boundary of small)', () => { projectRoot = setupNFiles(30); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.totalFiles).toBe(30); expect(r.output.estimatedComplexity).toBe('small'); }); it('31 files -> moderate (lower boundary of moderate)', () => { projectRoot = setupNFiles(31); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.totalFiles).toBe(31); expect(r.output.estimatedComplexity).toBe('moderate'); }); it('150 files -> moderate (upper boundary of moderate)', () => { projectRoot = setupNFiles(150); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.totalFiles).toBe(150); expect(r.output.estimatedComplexity).toBe('moderate'); }); it('151 files -> large (lower boundary of large)', () => { projectRoot = setupNFiles(151); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.totalFiles).toBe(151); expect(r.output.estimatedComplexity).toBe('large'); }); it('501 files -> very-large (lower boundary of very-large)', () => { projectRoot = setupNFiles(501); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output.totalFiles).toBe(501); expect(r.output.estimatedComplexity).toBe('very-large'); }); }); describe('scan-project.mjs — CLI entry guard + invocation', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('invokes successfully via subprocess and produces a parseable output file', () => { projectRoot = setupTree({ 'README.md': '# proj\n', 'src/index.ts': 'export const x = 1;\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); expect(r.output).not.toBeNull(); expect(r.output.scriptCompleted).toBe(true); // Stats summary line fires on stderr. expect(r.stderr).toMatch( /scan-project: filesScanned=2 filteredByIgnore=0 complexity=small/, ); // Two files captured. expect(r.output.totalFiles).toBe(2); }); it('fails fast with usage message when projectRoot is missing', () => { const result = spawnSync('node', [SCRIPT], { encoding: 'utf-8' }); expect(result.status).toBe(1); expect(result.stderr).toMatch(/Usage: node scan-project\.mjs/); }); }); describe('scan-project.mjs — output schema invariants', () => { let projectRoot; afterEach(() => { if (projectRoot) { rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; } }); it('emits the documented top-level fields with correct shapes', () => { projectRoot = setupTree({ 'src/a.ts': 'export const a = 1;\n', 'README.md': '# x\n', 'package.json': '{}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); const out = r.output; expect(out.scriptCompleted).toBe(true); expect(Array.isArray(out.files)).toBe(true); expect(typeof out.totalFiles).toBe('number'); expect(out.totalFiles).toBe(out.files.length); expect(typeof out.filteredByIgnore).toBe('number'); expect(['small', 'moderate', 'large', 'very-large']).toContain( out.estimatedComplexity, ); expect(out.stats).toBeDefined(); expect(out.stats.filesScanned).toBe(out.files.length); expect(typeof out.stats.byCategory).toBe('object'); expect(typeof out.stats.byLanguage).toBe('object'); // Per-file shape for (const f of out.files) { expect(typeof f.path).toBe('string'); expect(typeof f.language).toBe('string'); expect(typeof f.sizeLines).toBe('number'); expect([ 'code', 'config', 'docs', 'infra', 'data', 'script', 'markup', ]).toContain(f.fileCategory); } }); it('files[] is sorted by path.localeCompare', () => { projectRoot = setupTree({ 'zzz.ts': '\n', 'aaa.ts': '\n', 'mmm.ts': '\n', 'subdir/file.ts': '\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); const paths = r.output.files.map(f => f.path); const sortedPaths = [...paths].sort((a, b) => a.localeCompare(b)); expect(paths).toEqual(sortedPaths); }); });