feat(skills): resolve .ua data directory in helper scripts

Every bundled script now honors the shared rule (.understand-anything/
wins when it already exists, .ua/ otherwise): scripts importing
@understand-anything/core use the exported resolveUaDir; standalone
figma/dev scripts inline a two-line helper; Python merge/parse scripts
inline resolve_ua_dir mirroring core. extract-domain-context skips both
directory names when walking, and eslint ignores both. Tests cover
fresh-project .ua, legacy .understand-anything, and legacy-wins-when-
both for each script family.
This commit is contained in:
Lum1104
2026-07-10 17:23:39 +08:00
parent 2100b77b6a
commit 6d811c68ab
17 changed files with 325 additions and 46 deletions
+1
View File
@@ -11,6 +11,7 @@ export default tseslint.config(
'**/public/**',
'**/coverage/**',
'**/.understand-anything/**',
'**/.ua/**',
'**/.claude-plugin/**',
'**/.cursor-plugin/**',
'**/.copilot-plugin/**',
+10 -4
View File
@@ -11,11 +11,17 @@
* dashboard robustness pipeline (Tier 1-3: null fields, wrong cases,
* missing fields, aliases, dangling refs, unrecognizable types).
*
* Default: 3000 nodes. Writes to .understand-anything/knowledge-graph.json
* Default: 3000 nodes. Writes to the project's data dir —
* .ua/knowledge-graph.json (or legacy .understand-anything/knowledge-graph.json
* when that directory already exists).
*/
import { writeFileSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
import { resolve, join } from "node:path";
// Mirror core's resolveUaDir: the legacy `.understand-anything/` dir wins for
// both reads and writes when it already exists; otherwise use `.ua/`.
const uaDir = (root) => { const legacy = join(root, ".understand-anything"); return existsSync(legacy) ? legacy : join(root, ".ua"); };
const args = process.argv.slice(2);
const MESSY = args.includes("--messy");
@@ -278,7 +284,7 @@ const graph = {
tour: MESSY && Math.random() < 0.5 ? null : tour,
};
const outDir = resolve(process.cwd(), ".understand-anything");
const outDir = resolve(uaDir(process.cwd()));
mkdirSync(outDir, { recursive: true });
const outPath = resolve(outDir, "knowledge-graph.json");
writeFileSync(outPath, JSON.stringify(graph, null, 2));
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
@@ -25,6 +25,19 @@ function setupProject(fixtureName) {
return root;
}
// Variant of setupProject that seeds the fixture into an arbitrary data
// directory name (`.ua` for fresh projects, `.understand-anything` for legacy).
function setupProjectInDir(fixtureName, dirName) {
const root = mkdtempSync(join(tmpdir(), 'ua-cb-dir-test-'));
mkdirSync(join(root, dirName, 'intermediate'), { recursive: true });
const fixturePath = join(FIXTURES, fixtureName);
writeFileSync(
join(root, dirName, 'intermediate', 'scan-result.json'),
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'));
@@ -703,3 +716,50 @@ describe('compute-batches.mjs — --changed-files', () => {
expect(neighbors.find(n => n.path === 'src/b/middle.ts').batchIndex).toBe(batch.batchIndex);
});
});
describe('compute-batches.mjs — data-dir resolution (.ua vs legacy)', () => {
let root;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it('fresh project reads scan-result from .ua/ and writes batches.json there', () => {
root = setupProjectInDir('scan-result-3-cliques.json', '.ua');
const result = runScript(root);
expect(result.status).toBe(0);
// Output landed in .ua/, and the legacy dir was never created.
expect(existsSync(join(root, '.ua', 'intermediate', 'batches.json'))).toBe(true);
expect(existsSync(join(root, '.understand-anything'))).toBe(false);
const batches = JSON.parse(
readFileSync(join(root, '.ua', 'intermediate', 'batches.json'), 'utf-8'),
);
expect(batches.totalFiles).toBe(9);
expect(batches.batches.length).toBe(3);
});
it('legacy project keeps using .understand-anything/ (no migration)', () => {
// Legacy-compat regression: an existing .understand-anything/ dir wins for
// both read and write even though .ua/ is the new default.
root = setupProjectInDir('scan-result-3-cliques.json', '.understand-anything');
const result = runScript(root);
expect(result.status).toBe(0);
expect(existsSync(join(root, '.understand-anything', 'intermediate', 'batches.json'))).toBe(true);
expect(existsSync(join(root, '.ua'))).toBe(false);
});
it('legacy dir wins when both .understand-anything/ and .ua/ exist', () => {
root = setupProjectInDir('scan-result-3-cliques.json', '.understand-anything');
// A stray empty .ua/ must not divert reads/writes away from the legacy dir.
mkdirSync(join(root, '.ua', 'intermediate'), { recursive: true });
const result = runScript(root);
expect(result.status).toBe(0);
expect(existsSync(join(root, '.understand-anything', 'intermediate', 'batches.json'))).toBe(true);
expect(existsSync(join(root, '.ua', 'intermediate', 'batches.json'))).toBe(false);
});
});
@@ -1292,5 +1292,62 @@ class TestEmptyBatchGuard(unittest.TestCase):
self.assertNotIn("contributed 0 nodes and 0 edges", stderr)
class TestUaDirResolution(unittest.TestCase):
"""The merge script reads/writes under the resolved data dir: `.ua/` for
fresh projects, legacy `.understand-anything/` when that dir already exists
(no migration). Exercised end-to-end via subprocess.
"""
def setUp(self) -> None:
import tempfile
self.tmp = Path(tempfile.mkdtemp(prefix="ua-mbg-uadir-"))
def tearDown(self) -> None:
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
def _write_batch(self, dir_name: str, name: str, nodes: list) -> Path:
import json as _j
inter = self.tmp / dir_name / "intermediate"
inter.mkdir(parents=True, exist_ok=True)
(inter / name).write_text(_j.dumps({"nodes": nodes, "edges": []}), encoding="utf-8")
return inter
def _run(self) -> int:
import subprocess
return subprocess.run(
[sys.executable, str(_MODULE_PATH), str(self.tmp)],
capture_output=True, text=True,
).returncode
def test_fresh_project_uses_dot_ua(self) -> None:
self._write_batch(".ua", "batch-1.json", [_file_node("src/a.ts")])
rc = self._run()
self.assertEqual(rc, 0)
self.assertTrue((self.tmp / ".ua" / "intermediate" / "assembled-graph.json").is_file())
# Legacy dir must not be created for a fresh project.
self.assertFalse((self.tmp / ".understand-anything").exists())
def test_legacy_project_keeps_understand_anything(self) -> None:
self._write_batch(".understand-anything", "batch-1.json", [_file_node("src/a.ts")])
rc = self._run()
self.assertEqual(rc, 0)
self.assertTrue(
(self.tmp / ".understand-anything" / "intermediate" / "assembled-graph.json").is_file()
)
self.assertFalse((self.tmp / ".ua").exists())
def test_legacy_dir_wins_when_both_present(self) -> None:
self._write_batch(".understand-anything", "batch-1.json", [_file_node("src/a.ts")])
# A stray empty .ua/ must not divert the merge away from the legacy dir.
(self.tmp / ".ua" / "intermediate").mkdir(parents=True, exist_ok=True)
rc = self._run()
self.assertEqual(rc, 0)
self.assertTrue(
(self.tmp / ".understand-anything" / "intermediate" / "assembled-graph.json").is_file()
)
self.assertFalse((self.tmp / ".ua" / "intermediate" / "assembled-graph.json").exists())
if __name__ == "__main__":
unittest.main()
@@ -475,6 +475,50 @@ describe('scan-project.mjs — .understandignore handling', () => {
});
});
describe('scan-project.mjs — data-dir resolution (.ua vs legacy)', () => {
let projectRoot;
afterEach(() => {
if (projectRoot) {
rmSync(projectRoot, { recursive: true, force: true });
projectRoot = null;
}
});
it('honors .ua/.understandignore in a fresh project (no legacy dir)', () => {
// scan-project delegates ignore handling to core's createIgnoreFilter,
// which reads <resolveUaDir>/.understandignore — .ua/ for fresh projects.
projectRoot = setupTree({
'.ua/.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);
expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined();
expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
// Counted as user-driven drops (dual-filter accounting saw the ua ignore).
expect(r.output.filteredByIgnore).toBe(2);
});
it('honors legacy .understand-anything/.understandignore (legacy-compat)', () => {
// Legacy-compat regression: projects with an existing
// .understand-anything/ keep using it for the .understandignore lookup.
projectRoot = setupTree({
'.understand-anything/.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);
expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined();
expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
expect(r.output.filteredByIgnore).toBe(2);
});
});
describe('scan-project.mjs — special-file recognition', () => {
let projectRoot;
@@ -9,7 +9,8 @@ Usage:
python extract-domain-context.py <project-root>
Output:
<project-root>/.understand-anything/intermediate/domain-context.json
<ua-dir>/intermediate/domain-context.json, where <ua-dir> is `.ua/` (or
legacy `.understand-anything/` when that directory already exists).
"""
import json
@@ -19,6 +20,12 @@ import sys
from pathlib import Path
from typing import Any
def resolve_ua_dir(root: Path) -> Path:
"""Mirror core resolveUaDir: legacy .understand-anything/ wins if present."""
legacy = root / ".understand-anything"
return legacy if legacy.is_dir() else root / ".ua"
# ── Configuration ──────────────────────────────────────────────────────────
MAX_FILE_TREE_DEPTH = 6
@@ -52,7 +59,7 @@ SKIP_DIRS = {
"node_modules", ".git", ".svn", ".hg", "__pycache__", ".tox",
"venv", ".venv", "env", ".env", "dist", "build", "out", ".next",
".nuxt", "target", "vendor", ".idea", ".vscode", "coverage",
".understand-anything", ".pytest_cache", ".mypy_cache",
".understand-anything", ".ua", ".pytest_cache", ".mypy_cache",
"Pods", "DerivedData", ".gradle", "bin", "obj",
}
@@ -385,7 +392,7 @@ def main() -> None:
try:
# Ensure output directory exists
output_dir = project_root / ".understand-anything" / "intermediate"
output_dir = resolve_ua_dir(project_root) / "intermediate"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "domain-context.json"
@@ -1,10 +1,14 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { mergeDesignGraph } from "@understand-anything/core/figma";
// Mirror core's resolveUaDir: the legacy `.understand-anything/` dir wins for
// both reads and writes when it already exists; otherwise use `.ua/`.
const uaDir = (root) => { const legacy = join(root, ".understand-anything"); return existsSync(legacy) ? legacy : join(root, ".ua"); };
const [, , projectRoot] = process.argv;
const interDir = join(projectRoot, ".understand-anything", "intermediate");
const interDir = join(uaDir(projectRoot), "intermediate");
const manifest = JSON.parse(readFileSync(join(interDir, "scan-manifest.json"), "utf8"));
const analyses = readdirSync(interDir)
.filter((f) => /^analysis-batch-.*\.json$/.test(f))
@@ -20,7 +24,7 @@ if (!result.success || !result.data) {
process.exit(1);
}
const outDir = join(projectRoot, ".understand-anything");
const outDir = uaDir(projectRoot);
writeFileSync(join(outDir, "knowledge-graph.json"), JSON.stringify(result.data, null, 2));
writeFileSync(join(outDir, "meta.json"), JSON.stringify({
lastAnalyzedAt: new Date().toISOString(),
@@ -3,6 +3,10 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { parseFileKey, FigmaApiSource, parseDocument, extractTokens, applyScreenThumbnails } from "@understand-anything/core/figma";
// Mirror core's resolveUaDir: the legacy `.understand-anything/` dir wins for
// both reads and writes when it already exists; otherwise use `.ua/`.
const uaDir = (root) => { const legacy = join(root, ".understand-anything"); return existsSync(legacy) ? legacy : join(root, ".ua"); };
const [, , projectRoot, urlOrKey] = process.argv;
if (!projectRoot || !urlOrKey) {
console.error("usage: figma-scan.mjs <projectRoot> <figmaUrlOrKey>");
@@ -13,7 +17,7 @@ const fileKey = parseFileKey(urlOrKey);
const source = new FigmaApiSource(fileKey); // reads FIGMA_TOKEN from env; throws a friendly error if missing
const doc = await source.fetchDocument();
const metaPath = join(projectRoot, ".understand-anything", "meta.json");
const metaPath = join(uaDir(projectRoot), "meta.json");
let prevVersion = null;
if (existsSync(metaPath)) {
try {
@@ -64,7 +68,7 @@ const manifest = {
edges,
};
const interDir = join(projectRoot, ".understand-anything", "intermediate");
const interDir = join(uaDir(projectRoot), "intermediate");
mkdirSync(interDir, { recursive: true });
writeFileSync(join(interDir, "scan-manifest.json"), JSON.stringify(manifest, null, 2));
@@ -83,7 +87,7 @@ console.error(
* Best-effort: never throw (thumbnails are optional).
*/
async function refreshThumbnailsInPlace(projectRoot, source) {
const graphPath = join(projectRoot, ".understand-anything", "knowledge-graph.json");
const graphPath = join(uaDir(projectRoot), "knowledge-graph.json");
if (!existsSync(graphPath)) return;
try {
const graph = JSON.parse(readFileSync(graphPath, "utf8"));
@@ -12,7 +12,9 @@ Usage:
python merge-knowledge-graph.py <wiki-directory>
Output:
Writes assembled-graph.json to <wiki-directory>/.understand-anything/intermediate/
Writes assembled-graph.json to <wiki-directory>/<ua-dir>/intermediate/, where
<ua-dir> is `.ua/` (or legacy `.understand-anything/` when that directory
already exists).
"""
import json
@@ -23,6 +25,12 @@ from datetime import datetime, timezone
from pathlib import Path
def resolve_ua_dir(root: Path) -> Path:
"""Mirror core resolveUaDir: legacy .understand-anything/ wins if present."""
legacy = root / ".understand-anything"
return legacy if legacy.is_dir() else root / ".ua"
def _find_markdown_case_insensitive(parent: Path, name: str) -> Path:
"""Resolve a known markdown filename case-insensitively within one directory.
@@ -110,7 +118,7 @@ def normalize_entity_name(name: str) -> str:
# ---------------------------------------------------------------------------
def merge(root: Path) -> dict:
intermediate = root / ".understand-anything" / "intermediate"
intermediate = resolve_ua_dir(root) / "intermediate"
manifest_path = intermediate / "scan-manifest.json"
if not manifest_path.is_file():
@@ -10,7 +10,9 @@ Usage:
python parse-knowledge-base.py <wiki-directory>
Output:
Writes scan-manifest.json to <wiki-directory>/.understand-anything/intermediate/
Writes scan-manifest.json to <wiki-directory>/<ua-dir>/intermediate/, where
<ua-dir> is `.ua/` (or legacy `.understand-anything/` when that directory
already exists).
"""
import json
@@ -19,6 +21,12 @@ import re
import sys
from pathlib import Path
def resolve_ua_dir(root: Path) -> Path:
"""Mirror core resolveUaDir: legacy .understand-anything/ wins if present."""
legacy = root / ".understand-anything"
return legacy if legacy.is_dir() else root / ".ua"
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
@@ -538,7 +546,7 @@ def main():
manifest = parse_wiki(root)
# Write output
out_dir = root / ".understand-anything" / "intermediate"
out_dir = resolve_ua_dir(root) / "intermediate"
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / "scan-manifest.json"
out_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
@@ -4,7 +4,9 @@
*
* Builds the structural-fingerprint baseline used by auto-update's
* incremental change detection. Runs once per /understand full rebuild
* (Phase 7 step 2.5), generating .understand-anything/fingerprints.json.
* (Phase 7 step 2.5), generating fingerprints.json in the project's data dir
* (`.ua/`, or legacy `.understand-anything/` — resolved by core's
* saveFingerprints via resolveUaDir).
*
* Replaces the LLM-written fingerprint script that previously sat in
* SKILL.md as a code example — that example had the wrong signature
@@ -17,7 +19,8 @@
* Input JSON:
* { projectRoot: string, sourceFilePaths: string[], gitCommitHash: string }
*
* Writes: <projectRoot>/.understand-anything/fingerprints.json
* Writes: <projectRoot>/.ua/fingerprints.json (or legacy
* <projectRoot>/.understand-anything/fingerprints.json when that dir exists)
* Exit code: 0 on success (including 0 files analyzed); non-zero on error.
*/
@@ -8,8 +8,11 @@
* Usage:
* node compute-batches.mjs <project-root> [--changed-files=<path>]
*
* Input: <project-root>/.understand-anything/intermediate/scan-result.json
* Output: <project-root>/.understand-anything/intermediate/batches.json
* Input/output live under the project's data dir (`.ua/`, or legacy
* `.understand-anything/` when that directory already exists — resolved by
* core's resolveUaDir):
* Input: <ua-dir>/intermediate/scan-result.json
* Output: <ua-dir>/intermediate/batches.json
*/
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
@@ -36,7 +39,7 @@ try {
} catch {
core = await import(pathToFileURL(resolve(PLUGIN_ROOT, 'packages/core/dist/index.js')).href);
}
const { TreeSitterPlugin, PluginRegistry, builtinLanguageConfigs, registerAllParsers } = core;
const { TreeSitterPlugin, PluginRegistry, builtinLanguageConfigs, registerAllParsers, resolveUaDir } = core;
import Graph from 'graphology';
import louvain from 'graphology-communities-louvain';
@@ -369,7 +372,8 @@ async function main() {
}
}
const scanPath = join(projectRoot, '.understand-anything', 'intermediate', 'scan-result.json');
const uaDir = resolveUaDir(projectRoot);
const scanPath = join(uaDir, 'intermediate', 'scan-result.json');
if (!existsSync(scanPath)) {
process.stderr.write(`Error: scan-result.json not found at ${scanPath}\n`);
process.exit(1);
@@ -567,7 +571,7 @@ async function main() {
batches: finalBatches,
};
const outPath = join(projectRoot, '.understand-anything', 'intermediate', 'batches.json');
const outPath = join(uaDir, 'intermediate', 'batches.json');
writeFileSync(outPath, JSON.stringify(output, null, 2), 'utf-8');
const batchSizes = finalBatches.map(b => b.files.length);
const maxSize = batchSizes.length ? Math.max(...batchSizes) : 0;
@@ -2,17 +2,20 @@
/**
* generate-ignore.mjs
*
* Writes a starter `.understand-anything/.understandignore` for the target
* project by delegating to `generateStarterIgnoreFile` in
* `@understand-anything/core`. Invoked from SKILL.md Phase 0.5; replaces the
* inline `node -e "…"` block that previously duplicated the generator logic.
* Writes a starter `.understandignore` into the project's data directory
* (`.ua/`, or legacy `.understand-anything/` when that directory already
* exists — see core's resolveUaDir) by delegating to
* `generateStarterIgnoreFile` in `@understand-anything/core`. Invoked from
* SKILL.md Phase 0.5; replaces the inline `node -e "…"` block that previously
* duplicated the generator logic.
*
* Usage:
* node generate-ignore.mjs <projectRoot>
*
* Behaviour:
* - Exits 0 with a stderr notice if the target file already exists.
* - Creates `<projectRoot>/.understand-anything/` if missing.
* - Creates the resolved data dir (`.ua/` or legacy
* `.understand-anything/`) if missing.
* - Emits a one-line stderr summary on success.
*
* Mirrors the @understand-anything/core resolution dance used by
@@ -50,10 +53,10 @@ try {
core = await import(pathToFileURL(resolve(pluginRoot, 'packages/core/dist/index.js')).href);
}
const { generateStarterIgnoreFile } = core;
const { generateStarterIgnoreFile, resolveUaDir } = core;
const projectRoot = resolve(process.argv[2] ?? process.cwd());
const outDir = join(projectRoot, '.understand-anything');
const outDir = resolveUaDir(projectRoot);
const outPath = join(outDir, '.understandignore');
if (existsSync(outPath)) {
@@ -11,11 +11,10 @@ then reviews the output for semantic issues the script cannot catch.
Usage:
python merge-batch-graphs.py <project-root>
Input:
<project-root>/.understand-anything/intermediate/batch-*.json
Output:
<project-root>/.understand-anything/intermediate/assembled-graph.json
Input/output live under the project's data dir (`.ua/`, or legacy
`.understand-anything/` when that directory already exists):
Input: <ua-dir>/intermediate/batch-*.json
Output: <ua-dir>/intermediate/assembled-graph.json
"""
import json
@@ -27,6 +26,12 @@ from pathlib import Path
from typing import Any
def resolve_ua_dir(root: Path) -> Path:
"""Mirror core resolveUaDir: legacy .understand-anything/ wins if present."""
legacy = root / ".understand-anything"
return legacy if legacy.is_dir() else root / ".ua"
# ── Configuration ─────────────────────────────────────────────────────────
VALID_NODE_PREFIXES = {
@@ -1026,7 +1031,7 @@ def main() -> None:
sys.exit(1)
project_root = Path(sys.argv[1]).resolve()
intermediate_dir = project_root / ".understand-anything" / "intermediate"
intermediate_dir = resolve_ua_dir(project_root) / "intermediate"
if not intermediate_dir.is_dir():
print(f"Error: {intermediate_dir} does not exist", file=sys.stderr)
@@ -2,8 +2,9 @@
"""
merge-subdomain-graphs.py — Merge subdomain knowledge-graph files into one.
Auto-discovers *knowledge-graph*.json files in .understand-anything/
(excluding knowledge-graph.json itself), loads the existing
Auto-discovers *knowledge-graph*.json files in the project's data dir
(`.ua/`, or legacy `.understand-anything/` when that directory already exists)
excluding knowledge-graph.json itself, loads the existing
knowledge-graph.json as a base if present, and merges everything
into a single knowledge-graph.json.
@@ -15,7 +16,7 @@ knowledge-graph.json is loaded as a base but never as a discovery input
(prevents self-merging on repeated runs).
Output:
<project-root>/.understand-anything/knowledge-graph.json
<ua-dir>/knowledge-graph.json
"""
import json
@@ -24,6 +25,12 @@ from collections import Counter
from pathlib import Path
from typing import Any
def resolve_ua_dir(root: Path) -> Path:
"""Mirror core resolveUaDir: legacy .understand-anything/ wins if present."""
legacy = root / ".understand-anything"
return legacy if legacy.is_dir() else root / ".ua"
# Edge types that carry the domain hierarchy. Dropping one of these changes
# downstream graph traversal (unlike a routine `related` edge), so they are
# warned about loudly and re-tried on later runs via merge-report.json —
@@ -308,7 +315,7 @@ def main() -> None:
sys.exit(1)
project_root = Path(sys.argv[1]).resolve()
ua_dir = project_root / ".understand-anything"
ua_dir = resolve_ua_dir(project_root)
if not ua_dir.is_dir():
print(f"Error: {ua_dir} does not exist", file=sys.stderr)
@@ -16,7 +16,9 @@
*
* What this script owns:
* - File enumeration (git ls-files preferred, recursive walk fallback)
* - `.understandignore` filtering (delegated to core's createIgnoreFilter)
* - `.understandignore` filtering (delegated to core's createIgnoreFilter,
* which reads the resolved data dir — `.ua/`, or legacy
* `.understand-anything/` when that directory already exists)
* - Per-file language detection (extension + filename table)
* - Per-file category assignment (priority-ordered rules from
* project-scanner.md Step 4)
@@ -81,7 +83,7 @@ try {
core = await import(pathToFileURL(resolve(pluginRoot, 'packages/core/dist/index.js')).href);
}
const { createIgnoreFilter } = core;
const { createIgnoreFilter, resolveUaDir } = core;
// ---------------------------------------------------------------------------
// Language detection
@@ -602,11 +604,15 @@ function buildDefaultsOnlyFilter() {
* Determine whether `projectRoot` has any user .understandignore files.
* When neither file exists, the combined and defaults-only filters are
* identical, so we can skip the dual-filter accounting entirely.
*
* Mirrors core's createIgnoreFilter, which reads the resolved data dir —
* `.ua/`, or legacy `.understand-anything/` when that directory already
* exists (see resolveUaDir).
*/
function hasUserIgnoreFile(projectRoot) {
return (
existsSync(join(projectRoot, '.understandignore'))
|| existsSync(join(projectRoot, '.understand-anything', '.understandignore'))
|| existsSync(join(resolveUaDir(projectRoot), '.understandignore'))
);
}
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -202,3 +202,55 @@ describe("merge-batch-graphs.py imports recovery", () => {
expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0);
});
});
describe("merge-batch-graphs.py data-dir resolution (.ua vs legacy)", () => {
// Self-contained: uses its own temp roots rather than the module-global
// .understand-anything projectRoot wired up in the top-level beforeEach.
function runIn(root) {
const result = spawnSync(PYTHON.command, [...PYTHON.args, MERGE_SCRIPT, root], {
encoding: "utf-8",
});
return result;
}
it("fresh project reads/writes under .ua/", () => {
const root = mkdtempSync(join(tmpdir(), "ua-merge-uadir-"));
try {
const inter = join(root, ".ua", "intermediate");
mkdirSync(inter, { recursive: true });
writeFileSync(
join(inter, "batch-0.json"),
JSON.stringify({ nodes: [fileNode("src/a.py")], edges: [] }),
);
const result = runIn(root);
expect(result.status).toBe(0);
// Output landed in .ua/, legacy dir never created.
const out = JSON.parse(
readFileSync(join(inter, "assembled-graph.json"), "utf-8"),
);
expect(out.nodes.map((n) => n.id)).toContain("file:src/a.py");
expect(existsSync(join(root, ".understand-anything"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("legacy .understand-anything/ wins even when .ua/ also exists", () => {
const root = mkdtempSync(join(tmpdir(), "ua-merge-legacy-"));
try {
const legacyInter = join(root, ".understand-anything", "intermediate");
mkdirSync(legacyInter, { recursive: true });
mkdirSync(join(root, ".ua", "intermediate"), { recursive: true });
writeFileSync(
join(legacyInter, "batch-0.json"),
JSON.stringify({ nodes: [fileNode("src/a.py")], edges: [] }),
);
const result = runIn(root);
expect(result.status).toBe(0);
expect(existsSync(join(legacyInter, "assembled-graph.json"))).toBe(true);
expect(existsSync(join(root, ".ua", "intermediate", "assembled-graph.json"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});