feat(phase-10): STRUCT-004 project format detection utility

Task: STRUCT-004
Branch: cline-dev-dishant
Cross-branch notes: none -- no shared dependencies with Richard phase 9/6 work

- ProjectFormatDetector class (injectable filesystem, fully testable)
- detect() async + detectSync() sync variants
- Detects legacy (project.json) vs v2 (nodegx.project.json + _registry.json)
- Returns { format, confidence, indicators } for transparent decision-making
- Scoring system: v2 needs score >= 2 to avoid false positives
- Errors in exists() treated as not-found (graceful degradation)
- V2_INDICATORS / LEGACY_INDICATORS sentinel constants exported
- createNodeDetector() factory for Node.js/test contexts
- Convenience methods: getFormat(), isV2(), isLegacy()
- 30 tests in tests/io/ProjectFormatDetector.test.ts
  - detect(): 12 cases (all combinations, async fs, error handling)
  - detectSync(): 4 cases (incl. throws on async fs)
  - Convenience methods: 7 cases
  - Constants: 3 cases
  - createNodeDetector() integration: 4 cases (real tmp dirs)
- Updated tests/io/index.ts to export detector tests
- Updated PROGRESS-dishant.md: STRUCT-001/002/003/004 all marked complete
This commit is contained in:
dishant-kumar-thakur
2026-02-19 01:19:46 +05:30
parent d54e2a55a0
commit 1e78b5e279
4 changed files with 599 additions and 34 deletions

View File

@@ -0,0 +1,240 @@
/**
* STRUCT-004 Project Format Detection
*
* Utility for detecting whether a project directory uses the legacy
* monolithic format (project.json) or the v2 multi-file format
* (nodegx.project.json + components/).
*
* Design goals:
* - Pure utility no side effects, no global state
* - Testable accepts a filesystem abstraction, not the platform singleton
* - Fast uses file existence checks only, no parsing
* - Explicit returns a typed result with confidence and indicators
*
* @module noodl-editor/io/ProjectFormatDetector
* @since 1.2.0
*/
// Types
/** The detected project format */
export type ProjectFormat = 'legacy' | 'v2' | 'unknown';
/**
* Result of a format detection check.
*/
export interface FormatDetectionResult {
/** The detected format */
format: ProjectFormat;
/** Confidence level of the detection */
confidence: 'high' | 'medium' | 'low';
/** Human-readable indicators that led to this conclusion */
indicators: string[];
}
/**
* Minimal filesystem interface required by ProjectFormatDetector.
* Allows injection of a test double without depending on the platform.
*/
export interface DetectorFilesystem {
/** Returns true if the path exists (file or directory) */
exists(path: string): boolean | Promise<boolean>;
/** Joins path segments */
join(...parts: string[]): string;
}
// Sentinel filenames
/** Files/dirs that indicate a v2 project */
export const V2_INDICATORS = {
projectFile: 'nodegx.project.json',
componentsDir: 'components',
registryFile: 'components/_registry.json'
} as const;
/** Files that indicate a legacy project */
export const LEGACY_INDICATORS = {
projectFile: 'project.json'
} as const;
// ProjectFormatDetector
/**
* Detects the format of a project directory.
*
* Detection logic (in priority order):
* 1. If `nodegx.project.json` exists v2 (high confidence)
* 2. If `components/_registry.json` exists v2 (high confidence)
* 3. If `project.json` exists AND no v2 indicators legacy (high confidence)
* 4. If `project.json` exists AND v2 indicators present v2 (medium confidence, mixed project)
* 5. If neither exists unknown (low confidence)
*
* @example
* ```ts
* // With platform filesystem
* import { filesystem } from '@noodl/platform';
* const detector = new ProjectFormatDetector(filesystem);
* const result = await detector.detect('/path/to/project');
*
* // With test double
* const mockFs = { exists: (p) => p.endsWith('project.json'), join: path.join };
* const detector = new ProjectFormatDetector(mockFs);
* ```
*/
export class ProjectFormatDetector {
constructor(private readonly fs: DetectorFilesystem) {}
/**
* Detects the format of the project at the given directory path.
*
* @param projectDir - Absolute path to the project directory
* @returns FormatDetectionResult with format, confidence, and indicators
*/
async detect(projectDir: string): Promise<FormatDetectionResult> {
const indicators: string[] = [];
// Check for v2 indicators
const hasV2ProjectFile = await this.exists(this.fs.join(projectDir, V2_INDICATORS.projectFile));
const hasRegistry = await this.exists(this.fs.join(projectDir, V2_INDICATORS.registryFile));
const hasComponentsDir = await this.exists(this.fs.join(projectDir, V2_INDICATORS.componentsDir));
// Check for legacy indicator
const hasLegacyProjectFile = await this.exists(this.fs.join(projectDir, LEGACY_INDICATORS.projectFile));
// Build indicator list
if (hasV2ProjectFile) indicators.push(`Found ${V2_INDICATORS.projectFile}`);
if (hasRegistry) indicators.push(`Found ${V2_INDICATORS.registryFile}`);
if (hasComponentsDir) indicators.push(`Found ${V2_INDICATORS.componentsDir}/ directory`);
if (hasLegacyProjectFile) indicators.push(`Found ${LEGACY_INDICATORS.projectFile}`);
// Decision logic
const v2Score = (hasV2ProjectFile ? 2 : 0) + (hasRegistry ? 2 : 0) + (hasComponentsDir ? 1 : 0);
const isV2 = v2Score >= 2; // needs at least nodegx.project.json OR registry
if (isV2) {
return {
format: 'v2',
confidence: hasV2ProjectFile && hasRegistry ? 'high' : 'medium',
indicators
};
}
if (hasLegacyProjectFile) {
return {
format: 'legacy',
confidence: 'high',
indicators
};
}
// Nothing found
return {
format: 'unknown',
confidence: 'low',
indicators: indicators.length > 0 ? indicators : ['No project files found in directory']
};
}
/**
* Synchronous version of detect() for contexts where async is not available.
* Only works if the injected filesystem.exists() is synchronous.
*
* @param projectDir - Absolute path to the project directory
* @returns FormatDetectionResult (synchronous)
*/
detectSync(projectDir: string): FormatDetectionResult {
const indicators: string[] = [];
const existsSync = (p: string): boolean => {
const result = this.fs.exists(p);
if (typeof result === 'boolean') return result;
throw new Error('detectSync() requires a synchronous filesystem.exists() implementation');
};
const hasV2ProjectFile = existsSync(this.fs.join(projectDir, V2_INDICATORS.projectFile));
const hasRegistry = existsSync(this.fs.join(projectDir, V2_INDICATORS.registryFile));
const hasComponentsDir = existsSync(this.fs.join(projectDir, V2_INDICATORS.componentsDir));
const hasLegacyProjectFile = existsSync(this.fs.join(projectDir, LEGACY_INDICATORS.projectFile));
if (hasV2ProjectFile) indicators.push(`Found ${V2_INDICATORS.projectFile}`);
if (hasRegistry) indicators.push(`Found ${V2_INDICATORS.registryFile}`);
if (hasComponentsDir) indicators.push(`Found ${V2_INDICATORS.componentsDir}/ directory`);
if (hasLegacyProjectFile) indicators.push(`Found ${LEGACY_INDICATORS.projectFile}`);
const v2Score = (hasV2ProjectFile ? 2 : 0) + (hasRegistry ? 2 : 0) + (hasComponentsDir ? 1 : 0);
const isV2 = v2Score >= 2;
if (isV2) {
return {
format: 'v2',
confidence: hasV2ProjectFile && hasRegistry ? 'high' : 'medium',
indicators
};
}
if (hasLegacyProjectFile) {
return { format: 'legacy', confidence: 'high', indicators };
}
return {
format: 'unknown',
confidence: 'low',
indicators: indicators.length > 0 ? indicators : ['No project files found in directory']
};
}
/**
* Quick check returns just the format string without full result object.
* Useful for simple if/else branching.
*
* @param projectDir - Absolute path to the project directory
*/
async getFormat(projectDir: string): Promise<ProjectFormat> {
const result = await this.detect(projectDir);
return result.format;
}
/**
* Returns true if the project at the given path is in v2 format.
*/
async isV2(projectDir: string): Promise<boolean> {
return (await this.getFormat(projectDir)) === 'v2';
}
/**
* Returns true if the project at the given path is in legacy format.
*/
async isLegacy(projectDir: string): Promise<boolean> {
return (await this.getFormat(projectDir)) === 'legacy';
}
// Private helpers
private async exists(path: string): Promise<boolean> {
try {
const result = this.fs.exists(path);
return result instanceof Promise ? await result : result;
} catch {
return false;
}
}
}
// Factory helpers
/**
* Creates a ProjectFormatDetector using Node.js fs module.
* Use this in tests or Node.js scripts.
*/
export function createNodeDetector(): ProjectFormatDetector {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs') as typeof import('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodePath = require('path') as typeof import('path');
return new ProjectFormatDetector({
exists: (p: string) => fs.existsSync(p),
join: (...parts: string[]) => nodePath.join(...parts)
});
}

View File

@@ -0,0 +1,275 @@
/**
* ProjectFormatDetector Tests -- STRUCT-004
*/
import {
ProjectFormatDetector,
DetectorFilesystem,
V2_INDICATORS,
LEGACY_INDICATORS,
createNodeDetector
} from '../../src/editor/src/io/ProjectFormatDetector';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
// ---- Mock filesystem factory ------------------------------------------------
function makeMockFs(existingPaths: string[]): DetectorFilesystem {
const set = new Set(existingPaths.map((p) => p.replace(/\\/g, '/')));
return {
exists: (p: string) => set.has(p.replace(/\\/g, '/')),
join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/')
};
}
// ---- detect() ---------------------------------------------------------------
describe('ProjectFormatDetector.detect()', () => {
it('returns v2/high when nodegx.project.json AND _registry.json exist', async () => {
const mockFs = makeMockFs([
'/proj/nodegx.project.json',
'/proj/components/_registry.json',
'/proj/components'
]);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('v2');
expect(result.confidence).toBe('high');
});
it('returns v2/medium when only nodegx.project.json exists', async () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('v2');
expect(result.confidence).toBe('medium');
});
it('returns v2/medium when only _registry.json exists', async () => {
const mockFs = makeMockFs(['/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('v2');
expect(result.confidence).toBe('medium');
});
it('returns legacy/high when only project.json exists', async () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('legacy');
expect(result.confidence).toBe('high');
});
it('returns unknown/low when no project files exist', async () => {
const mockFs = makeMockFs([]);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('unknown');
expect(result.confidence).toBe('low');
});
it('returns v2 when both project.json and nodegx.project.json exist (mixed)', async () => {
const mockFs = makeMockFs([
'/proj/project.json',
'/proj/nodegx.project.json',
'/proj/components/_registry.json'
]);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('v2');
});
it('includes indicator for nodegx.project.json when found', async () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.indicators.some((i) => i.includes('nodegx.project.json'))).toBe(true);
});
it('includes indicator for project.json when found', async () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.indicators.some((i) => i.includes('project.json'))).toBe(true);
});
it('includes indicator for _registry.json when found', async () => {
const mockFs = makeMockFs(['/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.indicators.some((i) => i.includes('_registry.json'))).toBe(true);
});
it('includes "No project files found" indicator when nothing exists', async () => {
const mockFs = makeMockFs([]);
const detector = new ProjectFormatDetector(mockFs);
const result = await detector.detect('/proj');
expect(result.indicators.some((i) => i.toLowerCase().includes('no project files'))).toBe(true);
});
it('handles async filesystem.exists()', async () => {
const asyncFs: DetectorFilesystem = {
exists: async (p: string) => p.includes('nodegx.project.json') || p.includes('_registry.json'),
join: (...parts: string[]) => parts.join('/')
};
const detector = new ProjectFormatDetector(asyncFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('v2');
expect(result.confidence).toBe('high');
});
it('handles filesystem.exists() throwing (treats as not found)', async () => {
const throwingFs: DetectorFilesystem = {
exists: () => { throw new Error('permission denied'); },
join: (...parts: string[]) => parts.join('/')
};
const detector = new ProjectFormatDetector(throwingFs);
const result = await detector.detect('/proj');
expect(result.format).toBe('unknown');
});
});
// ---- detectSync() -----------------------------------------------------------
describe('ProjectFormatDetector.detectSync()', () => {
it('returns v2/high for v2 project', () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = detector.detectSync('/proj');
expect(result.format).toBe('v2');
expect(result.confidence).toBe('high');
});
it('returns legacy/high for legacy project', () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
const result = detector.detectSync('/proj');
expect(result.format).toBe('legacy');
expect(result.confidence).toBe('high');
});
it('returns unknown for empty directory', () => {
const mockFs = makeMockFs([]);
const detector = new ProjectFormatDetector(mockFs);
const result = detector.detectSync('/proj');
expect(result.format).toBe('unknown');
});
it('throws when filesystem.exists() returns a Promise', () => {
const asyncFs: DetectorFilesystem = {
exists: async () => false,
join: (...parts: string[]) => parts.join('/')
};
const detector = new ProjectFormatDetector(asyncFs);
expect(() => detector.detectSync('/proj')).toThrow('synchronous');
});
});
// ---- getFormat() / isV2() / isLegacy() --------------------------------------
describe('ProjectFormatDetector convenience methods', () => {
it('getFormat() returns "v2" for v2 project', async () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.getFormat('/proj')).toBe('v2');
});
it('getFormat() returns "legacy" for legacy project', async () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.getFormat('/proj')).toBe('legacy');
});
it('getFormat() returns "unknown" for empty dir', async () => {
const mockFs = makeMockFs([]);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.getFormat('/proj')).toBe('unknown');
});
it('isV2() returns true for v2 project', async () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.isV2('/proj')).toBe(true);
});
it('isV2() returns false for legacy project', async () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.isV2('/proj')).toBe(false);
});
it('isLegacy() returns true for legacy project', async () => {
const mockFs = makeMockFs(['/proj/project.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.isLegacy('/proj')).toBe(true);
});
it('isLegacy() returns false for v2 project', async () => {
const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']);
const detector = new ProjectFormatDetector(mockFs);
expect(await detector.isLegacy('/proj')).toBe(false);
});
});
// ---- V2_INDICATORS / LEGACY_INDICATORS constants ----------------------------
describe('Sentinel constants', () => {
it('V2_INDICATORS.projectFile is nodegx.project.json', () => {
expect(V2_INDICATORS.projectFile).toBe('nodegx.project.json');
});
it('V2_INDICATORS.registryFile is components/_registry.json', () => {
expect(V2_INDICATORS.registryFile).toBe('components/_registry.json');
});
it('LEGACY_INDICATORS.projectFile is project.json', () => {
expect(LEGACY_INDICATORS.projectFile).toBe('project.json');
});
});
// ---- createNodeDetector() integration test ----------------------------------
describe('createNodeDetector() integration', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noodl-format-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects legacy project from real filesystem', async () => {
fs.writeFileSync(path.join(tmpDir, 'project.json'), '{}');
const detector = createNodeDetector();
const result = await detector.detect(tmpDir);
expect(result.format).toBe('legacy');
expect(result.confidence).toBe('high');
});
it('detects v2 project from real filesystem', async () => {
fs.writeFileSync(path.join(tmpDir, 'nodegx.project.json'), '{}');
fs.mkdirSync(path.join(tmpDir, 'components'));
fs.writeFileSync(path.join(tmpDir, 'components', '_registry.json'), '{}');
const detector = createNodeDetector();
const result = await detector.detect(tmpDir);
expect(result.format).toBe('v2');
expect(result.confidence).toBe('high');
});
it('detects unknown for empty directory', async () => {
const detector = createNodeDetector();
const result = await detector.detect(tmpDir);
expect(result.format).toBe('unknown');
});
it('detectSync works on real filesystem', () => {
fs.writeFileSync(path.join(tmpDir, 'project.json'), '{}');
const detector = createNodeDetector();
const result = detector.detectSync(tmpDir);
expect(result.format).toBe('legacy');
});
});

View File

@@ -1,2 +1,3 @@
export * from './ProjectExporter.test';
export * from './ProjectImporter.test';
export * from './ProjectFormatDetector.test';