Add under-anything knowledge dashboard

This commit is contained in:
qiaoxinjiu
2026-05-27 15:40:32 +08:00
commit e31a75d2bb
565 changed files with 143063 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "fixture-3-cliques",
"description": "Three disjoint import cliques for Louvain testing",
"languages": ["typescript"],
"frameworks": [],
"files": [
{"path": "src/auth/login.ts", "language": "typescript", "sizeLines": 50, "fileCategory": "code"},
{"path": "src/auth/session.ts", "language": "typescript", "sizeLines": 40, "fileCategory": "code"},
{"path": "src/auth/tokens.ts", "language": "typescript", "sizeLines": 60, "fileCategory": "code"},
{"path": "src/api/handlers.ts", "language": "typescript", "sizeLines": 80, "fileCategory": "code"},
{"path": "src/api/middleware.ts", "language": "typescript", "sizeLines": 30, "fileCategory": "code"},
{"path": "src/api/routes.ts", "language": "typescript", "sizeLines": 45, "fileCategory": "code"},
{"path": "src/db/users.ts", "language": "typescript", "sizeLines": 70, "fileCategory": "code"},
{"path": "src/db/queries.ts", "language": "typescript", "sizeLines": 55, "fileCategory": "code"},
{"path": "src/db/migrations.ts", "language": "typescript", "sizeLines": 35, "fileCategory": "code"}
],
"totalFiles": 9,
"filteredByIgnore": 0,
"estimatedComplexity": "small",
"importMap": {
"src/auth/login.ts": ["src/auth/session.ts", "src/auth/tokens.ts"],
"src/auth/session.ts": ["src/auth/tokens.ts"],
"src/auth/tokens.ts": [],
"src/api/handlers.ts": ["src/api/middleware.ts", "src/api/routes.ts"],
"src/api/middleware.ts": ["src/api/routes.ts", "src/auth/session.ts"],
"src/api/routes.ts": [],
"src/db/users.ts": ["src/db/queries.ts", "src/db/migrations.ts"],
"src/db/queries.ts": ["src/db/migrations.ts"],
"src/db/migrations.ts": []
}
}

View File

@@ -0,0 +1,233 @@
{
"name": "fixture-merge-respects-non-mergeable",
"description": "Regression guard for mergeSmallBatches: a small non-mergeable batch (Dockerfile cluster, marked mergeable=false by buildNonCodeBatches Group A) must NOT be pooled into the misc bucket alongside isolated code singletons, even though its size (1) is well below MIN_BATCH_SIZE=3. Pooling Dockerfiles into misc would destroy the semantic atom — an LLM analyzing the misc batch loses the per-service infra context.",
"languages": [
"typescript",
"dockerfile"
],
"frameworks": [],
"files": [
{
"path": "src/leaf000.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf001.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf002.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf003.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf004.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf005.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf006.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf007.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf008.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf009.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf010.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf011.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf012.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf013.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf014.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf015.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf016.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf017.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf018.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf019.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf020.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf021.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf022.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf023.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf024.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf025.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf026.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf027.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf028.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf029.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "services/api/Dockerfile",
"language": "dockerfile",
"sizeLines": 18,
"fileCategory": "infra"
}
],
"totalFiles": 31,
"filteredByIgnore": 0,
"estimatedComplexity": "moderate",
"importMap": {
"src/leaf000.ts": [],
"src/leaf001.ts": [],
"src/leaf002.ts": [],
"src/leaf003.ts": [],
"src/leaf004.ts": [],
"src/leaf005.ts": [],
"src/leaf006.ts": [],
"src/leaf007.ts": [],
"src/leaf008.ts": [],
"src/leaf009.ts": [],
"src/leaf010.ts": [],
"src/leaf011.ts": [],
"src/leaf012.ts": [],
"src/leaf013.ts": [],
"src/leaf014.ts": [],
"src/leaf015.ts": [],
"src/leaf016.ts": [],
"src/leaf017.ts": [],
"src/leaf018.ts": [],
"src/leaf019.ts": [],
"src/leaf020.ts": [],
"src/leaf021.ts": [],
"src/leaf022.ts": [],
"src/leaf023.ts": [],
"src/leaf024.ts": [],
"src/leaf025.ts": [],
"src/leaf026.ts": [],
"src/leaf027.ts": [],
"src/leaf028.ts": [],
"src/leaf029.ts": [],
"services/api/Dockerfile": []
}
}

View File

@@ -0,0 +1,38 @@
{
"name": "fixture-non-code",
"description": "Mix of non-code files exercising Groups A-E. The src/ clique has 3 mutually-importing files so it survives merge-small (size >= MIN_BATCH_SIZE=3) and stays a pure-code batch — required by the 'non-code batch indices follow code batches' assertion.",
"languages": ["typescript", "dockerfile", "yaml", "sql", "markdown"],
"frameworks": [],
"files": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 10, "fileCategory": "code"},
{"path": "src/server.ts", "language": "typescript", "sizeLines": 15, "fileCategory": "code"},
{"path": "src/router.ts", "language": "typescript", "sizeLines": 12, "fileCategory": "code"},
{"path": "Dockerfile", "language": "dockerfile", "sizeLines": 20, "fileCategory": "infra"},
{"path": "docker-compose.yml", "language": "yaml", "sizeLines": 15, "fileCategory": "infra"},
{"path": ".dockerignore", "language": "config", "sizeLines": 5, "fileCategory": "config"},
{"path": "services/api/Dockerfile", "language": "dockerfile", "sizeLines": 18, "fileCategory": "infra"},
{"path": "services/api/docker-compose.yml", "language": "yaml", "sizeLines": 12, "fileCategory": "infra"},
{"path": ".github/workflows/ci.yml", "language": "yaml", "sizeLines": 30, "fileCategory": "infra"},
{"path": ".github/workflows/deploy.yml", "language": "yaml", "sizeLines": 25, "fileCategory": "infra"},
{"path": ".gitlab-ci.yml", "language": "yaml", "sizeLines": 20, "fileCategory": "infra"},
{"path": ".circleci/config.yml", "language": "yaml", "sizeLines": 25, "fileCategory": "infra"},
{"path": "migrations/001_init.sql", "language": "sql", "sizeLines": 40, "fileCategory": "data"},
{"path": "migrations/002_users.sql", "language": "sql", "sizeLines": 20, "fileCategory": "data"},
{"path": "docs/getting-started.md", "language": "markdown", "sizeLines": 100, "fileCategory": "docs"},
{"path": "README.md", "language": "markdown", "sizeLines": 200, "fileCategory": "docs"}
],
"totalFiles": 16,
"filteredByIgnore": 0,
"estimatedComplexity": "small",
"importMap": {
"src/index.ts": ["src/server.ts", "src/router.ts"],
"src/server.ts": ["src/router.ts"],
"src/router.ts": [],
"Dockerfile": [], "docker-compose.yml": [], ".dockerignore": [],
"services/api/Dockerfile": [], "services/api/docker-compose.yml": [],
".github/workflows/ci.yml": [], ".github/workflows/deploy.yml": [],
".gitlab-ci.yml": [], ".circleci/config.yml": [],
"migrations/001_init.sql": [], "migrations/002_users.sql": [],
"docs/getting-started.md": [], "README.md": []
}
}

View File

@@ -0,0 +1,715 @@
{
"name": "fixture-singletons",
"description": "100 isolated TS files that should merge into ~4 misc batches",
"languages": [
"typescript"
],
"frameworks": [],
"files": [
{
"path": "src/leaf000.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf001.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf002.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf003.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf004.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf005.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf006.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf007.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf008.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf009.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf010.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf011.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf012.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf013.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf014.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf015.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf016.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf017.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf018.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf019.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf020.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf021.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf022.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf023.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf024.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf025.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf026.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf027.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf028.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf029.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf030.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf031.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf032.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf033.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf034.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf035.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf036.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf037.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf038.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf039.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf040.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf041.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf042.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf043.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf044.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf045.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf046.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf047.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf048.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf049.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf050.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf051.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf052.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf053.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf054.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf055.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf056.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf057.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf058.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf059.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf060.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf061.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf062.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf063.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf064.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf065.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf066.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf067.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf068.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf069.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf070.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf071.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf072.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf073.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf074.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf075.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf076.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf077.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf078.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf079.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf080.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf081.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf082.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf083.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf084.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf085.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf086.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf087.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf088.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf089.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf090.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf091.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf092.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf093.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf094.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf095.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf096.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf097.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf098.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
},
{
"path": "src/leaf099.ts",
"language": "typescript",
"sizeLines": 10,
"fileCategory": "code"
}
],
"totalFiles": 100,
"filteredByIgnore": 0,
"estimatedComplexity": "moderate",
"importMap": {
"src/leaf000.ts": [],
"src/leaf001.ts": [],
"src/leaf002.ts": [],
"src/leaf003.ts": [],
"src/leaf004.ts": [],
"src/leaf005.ts": [],
"src/leaf006.ts": [],
"src/leaf007.ts": [],
"src/leaf008.ts": [],
"src/leaf009.ts": [],
"src/leaf010.ts": [],
"src/leaf011.ts": [],
"src/leaf012.ts": [],
"src/leaf013.ts": [],
"src/leaf014.ts": [],
"src/leaf015.ts": [],
"src/leaf016.ts": [],
"src/leaf017.ts": [],
"src/leaf018.ts": [],
"src/leaf019.ts": [],
"src/leaf020.ts": [],
"src/leaf021.ts": [],
"src/leaf022.ts": [],
"src/leaf023.ts": [],
"src/leaf024.ts": [],
"src/leaf025.ts": [],
"src/leaf026.ts": [],
"src/leaf027.ts": [],
"src/leaf028.ts": [],
"src/leaf029.ts": [],
"src/leaf030.ts": [],
"src/leaf031.ts": [],
"src/leaf032.ts": [],
"src/leaf033.ts": [],
"src/leaf034.ts": [],
"src/leaf035.ts": [],
"src/leaf036.ts": [],
"src/leaf037.ts": [],
"src/leaf038.ts": [],
"src/leaf039.ts": [],
"src/leaf040.ts": [],
"src/leaf041.ts": [],
"src/leaf042.ts": [],
"src/leaf043.ts": [],
"src/leaf044.ts": [],
"src/leaf045.ts": [],
"src/leaf046.ts": [],
"src/leaf047.ts": [],
"src/leaf048.ts": [],
"src/leaf049.ts": [],
"src/leaf050.ts": [],
"src/leaf051.ts": [],
"src/leaf052.ts": [],
"src/leaf053.ts": [],
"src/leaf054.ts": [],
"src/leaf055.ts": [],
"src/leaf056.ts": [],
"src/leaf057.ts": [],
"src/leaf058.ts": [],
"src/leaf059.ts": [],
"src/leaf060.ts": [],
"src/leaf061.ts": [],
"src/leaf062.ts": [],
"src/leaf063.ts": [],
"src/leaf064.ts": [],
"src/leaf065.ts": [],
"src/leaf066.ts": [],
"src/leaf067.ts": [],
"src/leaf068.ts": [],
"src/leaf069.ts": [],
"src/leaf070.ts": [],
"src/leaf071.ts": [],
"src/leaf072.ts": [],
"src/leaf073.ts": [],
"src/leaf074.ts": [],
"src/leaf075.ts": [],
"src/leaf076.ts": [],
"src/leaf077.ts": [],
"src/leaf078.ts": [],
"src/leaf079.ts": [],
"src/leaf080.ts": [],
"src/leaf081.ts": [],
"src/leaf082.ts": [],
"src/leaf083.ts": [],
"src/leaf084.ts": [],
"src/leaf085.ts": [],
"src/leaf086.ts": [],
"src/leaf087.ts": [],
"src/leaf088.ts": [],
"src/leaf089.ts": [],
"src/leaf090.ts": [],
"src/leaf091.ts": [],
"src/leaf092.ts": [],
"src/leaf093.ts": [],
"src/leaf094.ts": [],
"src/leaf095.ts": [],
"src/leaf096.ts": [],
"src/leaf097.ts": [],
"src/leaf098.ts": [],
"src/leaf099.ts": []
}
}

View File

@@ -0,0 +1,602 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT = resolve(__dirname, '../../../understand-anything-plugin/skills/understand/compute-batches.mjs');
const FIXTURES = resolve(__dirname, 'fixtures');
function runScript(projectRoot, extraArgs = []) {
return spawnSync('node', [SCRIPT, projectRoot, ...extraArgs], {
encoding: 'utf-8',
});
}
function setupProject(fixtureName) {
const root = mkdtempSync(join(tmpdir(), 'ua-cb-test-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
const fixturePath = join(FIXTURES, fixtureName);
const dest = join(root, '.understand-anything', 'intermediate', 'scan-result.json');
writeFileSync(dest, readFileSync(fixturePath, 'utf-8'));
return root;
}
function readBatches(projectRoot) {
const p = join(projectRoot, '.understand-anything', 'intermediate', 'batches.json');
return JSON.parse(readFileSync(p, 'utf-8'));
}
describe('compute-batches.mjs — Louvain basic', () => {
let projectRoot;
beforeEach(() => {
projectRoot = setupProject('scan-result-3-cliques.json');
});
afterEach(() => {
if (projectRoot) rmSync(projectRoot, { recursive: true, force: true });
});
it('produces 3 batches for 3 disjoint cliques', () => {
const result = runScript(projectRoot);
expect(result.status).toBe(0);
const batches = readBatches(projectRoot);
expect(batches.algorithm).toBe('louvain');
expect(batches.totalFiles).toBe(9);
expect(batches.batches.length).toBe(3);
expect(batches.schemaVersion).toBe(1);
expect(batches.totalBatches).toBe(3);
expect(batches.batches.map(b => b.batchIndex)).toEqual([1, 2, 3]);
// Each batch should contain exactly one clique (3 files)
for (const b of batches.batches) {
expect(b.files.length).toBe(3);
const dirs = new Set(b.files.map(f => f.path.split('/')[1]));
expect(dirs.size).toBe(1); // all files in the batch share src/<dir>/
}
});
it('produces deterministic output across runs', () => {
const r1 = runScript(projectRoot);
expect(r1.status).toBe(0);
const json1 = readFileSync(
join(projectRoot, '.understand-anything', 'intermediate', 'batches.json'),
'utf-8',
);
const r2 = runScript(projectRoot);
expect(r2.status).toBe(0);
const json2 = readFileSync(
join(projectRoot, '.understand-anything', 'intermediate', 'batches.json'),
'utf-8',
);
expect(json1).toBe(json2);
});
});
describe('compute-batches.mjs — size enforcement', () => {
let projectRoot;
beforeEach(() => {
projectRoot = setupProject('scan-result-large-community.json');
});
afterEach(() => {
if (projectRoot) rmSync(projectRoot, { recursive: true, force: true });
});
it('splits a 40-node clique into batches ≤ 35', () => {
const result = runScript(projectRoot);
expect(result.status).toBe(0);
const batches = readBatches(projectRoot);
expect(batches.algorithm).toBe('louvain'); // confirm fallback didn't fire
expect(batches.totalFiles).toBe(40);
expect(batches.batches.length).toBe(2);
expect(batches.batches.map(b => b.files.length).sort()).toEqual([20, 20]);
// Sum of all batch file counts equals total files
const sum = batches.batches.reduce((acc, b) => acc + b.files.length, 0);
expect(sum).toBe(40);
// Warning was emitted to stderr
expect(result.stderr).toMatch(/Warning: compute-batches: community size 40 > max 35/);
});
});
describe('compute-batches.mjs — exports extraction', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('populates exports for code files via tree-sitter', () => {
root = mkdtempSync(join(tmpdir(), 'ua-cb-exp-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
mkdirSync(join(root, 'src'), { recursive: true });
writeFileSync(join(root, 'src', 'a.ts'),
'export function greet(name: string) { return "hi " + name; }\n' +
'export class Greeter { greet(n: string) { return "hi " + n; } }\n');
writeFileSync(join(root, 'src', 'b.ts'),
'import { greet } from "./a";\nexport const helper = () => greet("world");\n');
const scan = {
name: 'exports-test',
description: '',
languages: ['typescript'],
frameworks: [],
files: [
{ path: 'src/a.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
{ path: 'src/b.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
],
totalFiles: 2, filteredByIgnore: 0, estimatedComplexity: 'small',
importMap: { 'src/a.ts': [], 'src/b.ts': ['src/a.ts'] },
};
writeFileSync(
join(root, '.understand-anything', 'intermediate', 'scan-result.json'),
JSON.stringify(scan));
const result = runScript(root);
expect(result.status).toBe(0);
const batches = readBatches(root);
expect(batches.exportsByPath).toBeDefined();
expect(batches.exportsByPath['src/a.ts']).toEqual(
expect.arrayContaining(['greet', 'Greeter']));
expect(batches.exportsByPath['src/b.ts']).toEqual(
expect.arrayContaining(['helper']));
});
it('emits warning when file is missing from disk (read error path)', () => {
root = mkdtempSync(join(tmpdir(), 'ua-cb-exp-err-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
// Note: NOT creating the file on disk — scan-result.json references it,
// but the file doesn't exist, so the read branch fires.
const scan = {
name: 'missing-file-test',
description: '',
languages: ['typescript'],
frameworks: [],
files: [
{ path: 'src/missing.ts', language: 'typescript', sizeLines: 1, fileCategory: 'code' },
],
totalFiles: 1, filteredByIgnore: 0, estimatedComplexity: 'small',
importMap: { 'src/missing.ts': [] },
};
writeFileSync(
join(root, '.understand-anything', 'intermediate', 'scan-result.json'),
JSON.stringify(scan));
const result = runScript(root);
expect(result.status).toBe(0); // script must still succeed
expect(result.stderr).toMatch(
/Warning: compute-batches: exports extraction failed for src\/missing\.ts \(read error:/);
const batches = readBatches(root);
expect(batches.exportsByPath['src/missing.ts']).toEqual([]);
});
});
describe('compute-batches.mjs — non-code grouping', () => {
let root;
let batches;
beforeEach(() => {
root = setupProject('scan-result-non-code.json');
const result = runScript(root);
expect(result.status).toBe(0);
batches = readBatches(root);
});
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('Group A: bundles Dockerfile cluster per directory', () => {
// Root-level cluster: Dockerfile + docker-compose.yml + .dockerignore → one batch
const rootDockerBatch = batches.batches.find(b =>
b.files.some(f => f.path === 'Dockerfile'));
expect(rootDockerBatch).toBeDefined();
const paths = rootDockerBatch.files.map(f => f.path).sort();
expect(paths).toEqual(['.dockerignore', 'Dockerfile', 'docker-compose.yml']);
// services/api cluster is a separate batch
const apiDockerBatch = batches.batches.find(b =>
b.files.some(f => f.path === 'services/api/Dockerfile'));
expect(apiDockerBatch).toBeDefined();
expect(apiDockerBatch).not.toBe(rootDockerBatch);
expect(apiDockerBatch.files.map(f => f.path).sort()).toEqual([
'services/api/Dockerfile', 'services/api/docker-compose.yml',
]);
});
it('Group B: .github/workflows/* all in one batch', () => {
const wfBatch = batches.batches.find(b =>
b.files.some(f => f.path.startsWith('.github/workflows/')));
expect(wfBatch).toBeDefined();
const wfPaths = wfBatch.files.map(f => f.path).filter(p => p.startsWith('.github/workflows/'));
expect(wfPaths.sort()).toEqual([
'.github/workflows/ci.yml', '.github/workflows/deploy.yml',
]);
});
it('Group C: .gitlab-ci.yml + .circleci/* in one batch', () => {
const ciBatch = batches.batches.find(b =>
b.files.some(f => f.path === '.gitlab-ci.yml'));
expect(ciBatch).toBeDefined();
const ciPaths = ciBatch.files.map(f => f.path).sort();
expect(ciPaths).toEqual(['.circleci/config.yml', '.gitlab-ci.yml']);
});
it('Group D: SQL migrations under migrations/ in one batch', () => {
const migBatch = batches.batches.find(b =>
b.files.some(f => f.path.startsWith('migrations/')));
expect(migBatch).toBeDefined();
const migPaths = migBatch.files.map(f => f.path).filter(p => p.startsWith('migrations/'));
expect(migPaths.sort()).toEqual([
'migrations/001_init.sql', 'migrations/002_users.sql',
]);
});
it('non-code batch indices follow code batches', () => {
const codeBatches = batches.batches.filter(b =>
b.files.every(f => f.fileCategory === 'code'));
const nonCodeBatches = batches.batches.filter(b =>
b.files.some(f => f.fileCategory !== 'code'));
expect(codeBatches.length).toBeGreaterThan(0);
expect(nonCodeBatches.length).toBeGreaterThan(0);
const maxCodeIdx = Math.max(...codeBatches.map(b => b.batchIndex));
const minNonCodeIdx = Math.min(...nonCodeBatches.map(b => b.batchIndex));
expect(minNonCodeIdx).toBeGreaterThan(maxCodeIdx);
});
});
describe('compute-batches.mjs — Group E MAX_E split', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('splits 25 .md files under docs/ into [20, 5]', () => {
root = mkdtempSync(join(tmpdir(), 'ua-cb-maxe-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
const files = [];
const importMap = {};
for (let i = 0; i < 25; i++) {
const p = `docs/page${String(i).padStart(2, '0')}.md`;
files.push({ path: p, language: 'markdown', sizeLines: 10, fileCategory: 'docs' });
importMap[p] = [];
}
const scan = {
name: 'maxe-test', description: '',
languages: ['markdown'], frameworks: [],
files, totalFiles: 25, filteredByIgnore: 0,
estimatedComplexity: 'small', importMap,
};
writeFileSync(
join(root, '.understand-anything', 'intermediate', 'scan-result.json'),
JSON.stringify(scan));
const result = runScript(root);
expect(result.status).toBe(0);
const batches = readBatches(root);
// All 25 docs/ files go through Group E with MAX_E = 20, split into [20, 5].
const docsBatches = batches.batches.filter(b =>
b.files.every(f => f.path.startsWith('docs/')));
expect(docsBatches.length).toBe(2);
const sizes = docsBatches.map(b => b.files.length).sort((a, b) => b - a);
expect(sizes).toEqual([20, 5]);
});
});
describe('compute-batches.mjs — neighborMap + batchImportData', () => {
let batches;
let batchOf; // path → batchIndex
let projectRoot;
beforeEach(() => {
projectRoot = setupProject('scan-result-3-cliques.json');
const result = runScript(projectRoot);
expect(result.status).toBe(0);
batches = readBatches(projectRoot);
batchOf = new Map();
for (const b of batches.batches) {
for (const f of b.files) batchOf.set(f.path, b.batchIndex);
}
});
afterEach(() => {
if (projectRoot) rmSync(projectRoot, { recursive: true, force: true });
});
it('batchImportData mirrors scan importMap per batch', () => {
for (const b of batches.batches) {
for (const f of b.files) {
expect(b.batchImportData[f.path]).toBeDefined();
expect(Array.isArray(b.batchImportData[f.path])).toBe(true);
}
}
// src/auth/login.ts imports src/auth/session.ts and src/auth/tokens.ts
const loginBatch = batches.batches.find(b =>
b.files.some(f => f.path === 'src/auth/login.ts'));
expect(loginBatch.batchImportData['src/auth/login.ts'].sort()).toEqual([
'src/auth/session.ts', 'src/auth/tokens.ts',
]);
});
it('neighborMap excludes same-batch files', () => {
// The fixture's three cliques each go into one batch — all imports are
// intra-batch, so no neighbor map should reference any same-batch file.
for (const b of batches.batches) {
const sameBatchPaths = new Set(b.files.map(f => f.path));
for (const [, neighbors] of Object.entries(b.neighborMap)) {
for (const n of neighbors) {
expect(sameBatchPaths.has(n.path)).toBe(false);
}
}
}
});
it('neighborMap entries carry symbols when target has exports', () => {
const root = mkdtempSync(join(tmpdir(), 'ua-cb-nbr-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
mkdirSync(join(root, 'src', 'a'), { recursive: true });
mkdirSync(join(root, 'src', 'b'), { recursive: true });
// Cluster A: 3 tightly-imported files. a/core.ts exports symbols.
writeFileSync(join(root, 'src', 'a', 'core.ts'),
'export function findUser(id: string) { return null; }\nexport class User {}\n');
writeFileSync(join(root, 'src', 'a', 'helper1.ts'),
'import { findUser } from "./core";\nexport const h1 = () => findUser("x");\n');
writeFileSync(join(root, 'src', 'a', 'helper2.ts'),
'import { User } from "./core";\nimport { h1 } from "./helper1";\nexport const h2 = () => h1();\n');
// Cluster B: 3 tightly-imported files. b/entry.ts has ONE cross-cluster import to a/core.ts.
writeFileSync(join(root, 'src', 'b', 'entry.ts'),
'import { findUser } from "../a/core";\nexport const entry = () => findUser("y");\n');
writeFileSync(join(root, 'src', 'b', 'middle.ts'),
'import { entry } from "./entry";\nexport const middle = () => entry();\n');
writeFileSync(join(root, 'src', 'b', 'leaf.ts'),
'import { middle } from "./middle";\nexport const leaf = () => middle();\n');
const files = [
{ path: 'src/a/core.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
{ path: 'src/a/helper1.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
{ path: 'src/a/helper2.ts', language: 'typescript', sizeLines: 3, fileCategory: 'code' },
{ path: 'src/b/entry.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
{ path: 'src/b/middle.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
{ path: 'src/b/leaf.ts', language: 'typescript', sizeLines: 2, fileCategory: 'code' },
];
const scan = {
name: 't', description: '',
languages: ['typescript'], frameworks: [],
files,
totalFiles: 6, filteredByIgnore: 0, estimatedComplexity: 'small',
importMap: {
'src/a/core.ts': [],
'src/a/helper1.ts': ['src/a/core.ts'],
'src/a/helper2.ts': ['src/a/core.ts', 'src/a/helper1.ts'],
'src/b/entry.ts': ['src/a/core.ts'], // CROSS-CLUSTER
'src/b/middle.ts': ['src/b/entry.ts'],
'src/b/leaf.ts': ['src/b/middle.ts'],
},
};
writeFileSync(
join(root, '.understand-anything', 'intermediate', 'scan-result.json'),
JSON.stringify(scan));
const result = runScript(root);
expect(result.status).toBe(0);
const out = readBatches(root);
// Expect 2 communities (cluster A and cluster B). Verify that some batch's
// neighborMap entry references src/a/core.ts with its symbols.
let sawSymbols = false;
for (const batch of out.batches) {
for (const [, neighbors] of Object.entries(batch.neighborMap)) {
for (const n of neighbors) {
if (n.path === 'src/a/core.ts') {
expect(n.symbols).toEqual(expect.arrayContaining(['findUser', 'User']));
sawSymbols = true;
}
}
}
}
expect(sawSymbols).toBe(true);
rmSync(root, { recursive: true, force: true });
});
});
describe('compute-batches.mjs — neighborMap truncation', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('truncates and warns when neighbors > 50', () => {
root = mkdtempSync(join(tmpdir(), 'ua-cb-trunc-'));
mkdirSync(join(root, '.understand-anything', 'intermediate'), { recursive: true });
// hub.ts imported by 60 other files
const files = [{ path: 'src/hub.ts', language: 'typescript', sizeLines: 1, fileCategory: 'code' }];
const importMap = { 'src/hub.ts': [] };
for (let i = 0; i < 60; i++) {
const p = `src/leaf${i}.ts`;
files.push({ path: p, language: 'typescript', sizeLines: 1, fileCategory: 'code' });
importMap[p] = ['src/hub.ts'];
}
const scan = {
name: 't', description: '', languages: ['typescript'], frameworks: [],
files, totalFiles: files.length, filteredByIgnore: 0,
estimatedComplexity: 'moderate', importMap,
};
writeFileSync(
join(root, '.understand-anything', 'intermediate', 'scan-result.json'),
JSON.stringify(scan));
const result = runScript(root);
expect(result.status).toBe(0);
expect(result.stderr).toMatch(
/neighborMap for src\/hub\.ts has high 1-hop degree 60 — exceeds soft cap of 50/);
const out = readBatches(root);
// Find hub.ts and confirm its neighbor list capped at 50 (in whichever batch it landed)
for (const b of out.batches) {
const nbrs = b.neighborMap['src/hub.ts'];
if (nbrs) expect(nbrs.length).toBeLessThanOrEqual(50);
}
});
});
describe('compute-batches.mjs — fallback', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('falls back to count-based when Louvain throws (env-injected mock)', () => {
// We can't easily monkey-patch louvain mid-script in Vitest because the
// script runs in a subprocess. Instead, set an env var the script honors:
// UA_COMPUTE_BATCHES_FORCE_LOUVAIN_THROW=1 → script throws inside its
// Louvain branch, exercising the fallback path.
root = setupProject('scan-result-3-cliques.json');
const result = spawnSync('node',
[SCRIPT, root],
{ encoding: 'utf-8', env: { ...process.env, UA_COMPUTE_BATCHES_FORCE_LOUVAIN_THROW: '1' } },
);
expect(result.status).toBe(0);
expect(result.stderr).toMatch(
/Warning: compute-batches: Louvain failed.*falling back to count-based grouping/);
const out = readBatches(root);
expect(out.algorithm).toBe('count-fallback');
expect(out.totalFiles).toBe(9);
// Count-based: 12 files per batch → all 9 fit in one batch
const codeBatchFileCount = out.batches
.filter(b => b.files.every(f => f.fileCategory === 'code'))
.reduce((sum, b) => sum + b.files.length, 0);
expect(codeBatchFileCount).toBe(9);
});
});
describe('compute-batches.mjs — merge-small', () => {
let projectRoot;
beforeEach(() => {
projectRoot = setupProject('scan-result-singletons.json');
});
afterEach(() => {
if (projectRoot) rmSync(projectRoot, { recursive: true, force: true });
});
it('merges 100 isolated singletons into a small number of misc batches', () => {
const result = runScript(projectRoot);
expect(result.status).toBe(0);
const batches = readBatches(projectRoot);
expect(batches.totalFiles).toBe(100);
// Without merge: 100 singletons → 100 batches.
// With merge-small (MAX_MERGE_TARGET=25): ceil(100 / 25) = exactly 4 misc
// batches. Pin the exact count — a loose >=4 && <=8 would mask off-by-one
// regressions in the slice math (e.g., a stride miscalculation that
// splintered the pool into 5-7 underfull buckets).
expect(batches.batches.length).toBe(4);
// All files accounted for
const totalAssigned = batches.batches.reduce((sum, b) => sum + b.files.length, 0);
expect(totalAssigned).toBe(100);
// Bucket-fullness check: 100 singletons evenly divisible by
// MAX_MERGE_TARGET=25, so every bucket must be exactly 25 — not just
// ≤ 25. Drift toward [25, 25, 25, 24, 1] etc. would slip past a
// ≤25 bound while indicating a stride bug.
for (const b of batches.batches) {
expect(b.files.length).toBe(25);
}
// Info: (not Warning:) — merge-small is a routine optimization, not a
// fallback path. See compute-batches.mjs mergeSmallBatches WHY comment.
expect(result.stderr).toMatch(
/Info: compute-batches: merged \d+ small batches \(\d+ files\) into \d+ misc batches/);
expect(result.stderr).not.toMatch(/Warning: compute-batches: merged \d+ small batches/);
});
it('preserves non-mergeable batches: Dockerfile cluster not pooled into misc', () => {
// Dedicated fixture: 30 isolated TS singletons + 1 Dockerfile-only cluster.
// Group A marks the Dockerfile batch mergeable=false; even though its size
// (1) is below MIN_BATCH_SIZE=3, mergeSmallBatches must leave it intact.
const altRoot = setupProject('scan-result-merge-respects-non-mergeable.json');
try {
const result = runScript(altRoot);
expect(result.status).toBe(0);
const out = readBatches(altRoot);
expect(out.totalFiles).toBe(31);
const dockerBatch = out.batches.find(b =>
b.files.some(f => f.path === 'services/api/Dockerfile'));
expect(dockerBatch).toBeDefined();
// Standalone: exactly the Dockerfile, nothing pooled in alongside it.
expect(dockerBatch.files.length).toBe(1);
expect(dockerBatch.files[0].path).toBe('services/api/Dockerfile');
// The TS singletons must still merge into at least one misc batch —
// and that misc batch must NOT contain the Dockerfile.
const miscBatches = out.batches.filter(b =>
b.files.some(f => f.path.startsWith('src/leaf')));
expect(miscBatches.length).toBeGreaterThanOrEqual(1);
for (const m of miscBatches) {
for (const f of m.files) {
expect(f.path).not.toBe('services/api/Dockerfile');
}
}
// Every TS singleton accounted for across the misc bucket(s).
const tsInMisc = miscBatches.flatMap(b => b.files.map(f => f.path))
.filter(p => p.startsWith('src/leaf'));
expect(tsInMisc.length).toBe(30);
} finally {
rmSync(altRoot, { recursive: true, force: true });
}
});
});
describe('compute-batches.mjs — --changed-files', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('emits only batches containing changed files', () => {
root = setupProject('scan-result-3-cliques.json');
const changedPath = join(root, 'changed.txt');
// Only the auth clique is changed
writeFileSync(changedPath, ['src/auth/login.ts', 'src/auth/tokens.ts'].join('\n'));
const result = runScript(root, [`--changed-files=${changedPath}`]);
expect(result.status).toBe(0);
const out = readBatches(root);
// Auth files are in batches; other cliques' batches must be omitted
const allPaths = out.batches.flatMap(b => b.files.map(f => f.path));
expect(allPaths).toContain('src/auth/login.ts');
expect(allPaths).toContain('src/auth/tokens.ts');
expect(allPaths).not.toContain('src/api/handlers.ts');
expect(allPaths).not.toContain('src/db/users.ts');
// neighborMap may still reference unchanged files (with their full-graph batchIndex)
const loginBatch = out.batches.find(b =>
b.files.some(f => f.path === 'src/auth/login.ts'));
expect(loginBatch).toBeDefined();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,738 @@
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);
});
});