739 lines
27 KiB
JavaScript
739 lines
27 KiB
JavaScript
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': '<?php echo 1;\n',
|
|
'c.c': 'int main() { return 0; }\n',
|
|
'd.h': 'void f();\n',
|
|
'e.cpp': 'int main() {}\n',
|
|
'f.hpp': 'class F {};\n',
|
|
});
|
|
const r = runScript(projectRoot);
|
|
expect(r.status).toBe(0);
|
|
expect(byPath(r.output, 'a.rb').language).toBe('ruby');
|
|
expect(byPath(r.output, 'b.php').language).toBe('php');
|
|
expect(byPath(r.output, 'c.c').language).toBe('c');
|
|
expect(byPath(r.output, 'd.h').language).toBe('c');
|
|
expect(byPath(r.output, 'e.cpp').language).toBe('cpp');
|
|
expect(byPath(r.output, 'f.hpp').language).toBe('cpp');
|
|
});
|
|
|
|
it('maps web markup (HTML, CSS) to their language ids', () => {
|
|
projectRoot = setupTree({
|
|
'a.html': '<!doctype html><html></html>\n',
|
|
'b.htm': '<html></html>\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': '<x/>\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': '<x/>\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': '<!doctype html>\n',
|
|
'public/page.htm': '<html></html>\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);
|
|
});
|
|
});
|