mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
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:
@@ -1,4 +1,4 @@
|
|||||||
# Phase 10A AI-Powered Development: Dishant Progress
|
# Phase 10A — AI-Powered Development: Dishant Progress
|
||||||
|
|
||||||
**Developer:** Dishant
|
**Developer:** Dishant
|
||||||
**Branch:** `cline-dev-dishant`
|
**Branch:** `cline-dev-dishant`
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
| STRUCT-001 | JSON Schema Definition | COMPLETE | 8 schemas + validator + tests (33/33 pass) |
|
| STRUCT-001 | JSON Schema Definition | COMPLETE | 8 schemas + validator + tests (33/33 pass) |
|
||||||
| STRUCT-002 | Export Engine Core | COMPLETE | ProjectExporter + 50 unit tests |
|
| STRUCT-002 | Export Engine Core | COMPLETE | ProjectExporter + 50 unit tests |
|
||||||
| STRUCT-003 | Import Engine Core | COMPLETE | ProjectImporter + 55 unit tests incl. round-trip validation |
|
| STRUCT-003 | Import Engine Core | COMPLETE | ProjectImporter + 55 unit tests incl. round-trip validation |
|
||||||
| STRUCT-004 | Editor Format Detection | TODO | Unblocked by STRUCT-003 |
|
| STRUCT-004 | Editor Format Detection | COMPLETE | ProjectFormatDetector + 30 unit tests incl. integration |
|
||||||
| STRUCT-005 | Lazy Loading | TODO | Unblocked by STRUCT-004 |
|
| STRUCT-005 | Lazy Loading | TODO | Unblocked by STRUCT-004 |
|
||||||
| STRUCT-006 | Save Logic | TODO | Unblocked by STRUCT-005 |
|
| STRUCT-006 | Save Logic | TODO | Unblocked by STRUCT-005 |
|
||||||
| STRUCT-007 | Migration Wizard UI | TODO | Unblocked by STRUCT-006 |
|
| STRUCT-007 | Migration Wizard UI | TODO | Unblocked by STRUCT-006 |
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STRUCT-001 JSON Schema Definition COMPLETE
|
## STRUCT-001 — JSON Schema Definition COMPLETE
|
||||||
|
|
||||||
**Completed:** 2026-02-18
|
**Completed:** 2026-02-18
|
||||||
|
|
||||||
@@ -41,20 +41,20 @@ All files under `packages/noodl-editor/src/editor/src/schemas/`:
|
|||||||
| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants |
|
| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants |
|
||||||
| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions |
|
| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions |
|
||||||
|
|
||||||
- `validator.ts` SchemaValidator singleton, per-schema convenience methods, Ajv v8 + ajv-formats
|
- `validator.ts` — SchemaValidator singleton, per-schema convenience methods, Ajv v8 + ajv-formats
|
||||||
- `index.ts` re-exports all schemas, validator, TypeScript interfaces
|
- `index.ts` — re-exports all schemas, validator, TypeScript interfaces
|
||||||
- `tests/schemas/schema-validator.test.ts` 33 test cases, all passing
|
- `tests/schemas/schema-validator.test.ts` — 33 test cases, all passing
|
||||||
|
|
||||||
### Key decisions
|
### Key decisions
|
||||||
|
|
||||||
- `additionalProperties: true` on nodes/connections open-ended by design
|
- `additionalProperties: true` on nodes/connections — open-ended by design
|
||||||
- Port type is `oneOf [string, object]` Noodl uses both formats
|
- Port type is `oneOf [string, object]` — Noodl uses both formats
|
||||||
- `strict: false` on Ajv schemas use `description` in `definitions`
|
- `strict: false` on Ajv — schemas use `description` in `definitions`
|
||||||
- `require()` for `ajv-formats` avoids TS type conflict
|
- `require()` for `ajv-formats` — avoids TS type conflict
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STRUCT-002 Export Engine Core COMPLETE
|
## STRUCT-002 — Export Engine Core COMPLETE
|
||||||
|
|
||||||
**Completed:** 2026-02-19
|
**Completed:** 2026-02-19
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ All files under `packages/noodl-editor/src/editor/src/schemas/`:
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
`ProjectExporter` is pure no filesystem access. Takes `LegacyProject`, returns `ExportResult { files[], stats }`.
|
`ProjectExporter` is pure — no filesystem access. Takes `LegacyProject`, returns `ExportResult { files[], stats }`.
|
||||||
|
|
||||||
Output layout:
|
Output layout:
|
||||||
```
|
```
|
||||||
@@ -85,17 +85,17 @@ components/
|
|||||||
|
|
||||||
### Key decisions
|
### Key decisions
|
||||||
|
|
||||||
1. Pure/filesystem-agnostic caller writes files
|
1. Pure/filesystem-agnostic — caller writes files
|
||||||
2. Node tree flattening recursive tree to flat array with parent/children IDs
|
2. Node tree flattening — recursive tree to flat array with parent/children IDs
|
||||||
3. Styles/routes files conditional only produced when content exists
|
3. Styles/routes files conditional — only produced when content exists
|
||||||
4. Legacy `stateParamaters` typo normalised to `stateParameters`
|
4. Legacy `stateParamaters` typo normalised to `stateParameters`
|
||||||
5. Metadata split styles/routes extracted, rest stays in project file
|
5. Metadata split — styles/routes extracted, rest stays in project file
|
||||||
6. Original legacy path preserved in `component.json` for round-trip fidelity
|
6. Original legacy path preserved in `component.json` for round-trip fidelity
|
||||||
7. Empty `parameters: {}` omitted from output
|
7. Empty `parameters: {}` omitted from output
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STRUCT-003 Import Engine Core COMPLETE
|
## STRUCT-003 — Import Engine Core COMPLETE
|
||||||
|
|
||||||
**Completed:** 2026-02-19
|
**Completed:** 2026-02-19
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ components/
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
`ProjectImporter` is pure no filesystem access. Takes `ImportInput` (all file contents), returns `ImportResult { project, warnings }`.
|
`ProjectImporter` is pure — no filesystem access. Takes `ImportInput` (all file contents), returns `ImportResult { project, warnings }`.
|
||||||
|
|
||||||
```
|
```
|
||||||
ImportInput {
|
ImportInput {
|
||||||
@@ -129,30 +129,79 @@ ImportInput {
|
|||||||
|
|
||||||
### Key decisions
|
### Key decisions
|
||||||
|
|
||||||
1. Pure/filesystem-agnostic symmetric with ProjectExporter
|
1. Pure/filesystem-agnostic — symmetric with ProjectExporter
|
||||||
2. `unflattenNodes` two-pass: build map, then wire children in order
|
2. `unflattenNodes` — two-pass: build map, then wire children in order
|
||||||
3. `toLegacyName` prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction
|
3. `toLegacyName` — prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction
|
||||||
4. `stateParameters` `stateParamaters` reversal restores legacy typo
|
4. `stateParameters` → `stateParamaters` reversal — restores legacy typo
|
||||||
5. Metadata merge routes/styles merged back into `project.metadata`
|
5. Metadata merge — routes/styles merged back into `project.metadata`
|
||||||
6. Non-fatal warnings component failures collected, not thrown
|
6. Non-fatal warnings — component failures collected, not thrown
|
||||||
7. Round-trip validated 20 round-trip tests covering all data types
|
7. Round-trip validated — 20 round-trip tests covering all data types
|
||||||
|
|
||||||
### Test coverage (55 tests)
|
### Test coverage (55 tests)
|
||||||
|
|
||||||
- `unflattenNodes` 11 cases (empty, single, parent-child, deep nesting, order, field restoration)
|
- `unflattenNodes` — 11 cases (empty, single, parent-child, deep nesting, order, field restoration)
|
||||||
- `toLegacyName` 4 cases (path field, fallback, root, cloud)
|
- `toLegacyName` — 4 cases (path field, fallback, root, cloud)
|
||||||
- `ProjectImporter.import()` 20 cases (basic, metadata, variants, components, nodes)
|
- `ProjectImporter.import()` — 20 cases (basic, metadata, variants, components, nodes)
|
||||||
- Round-trip (export import) 20 cases (all data types, edge cases)
|
- Round-trip (export → import) — 20 cases (all data types, edge cases)
|
||||||
|
|
||||||
### Cross-branch note for Richard
|
---
|
||||||
|
|
||||||
No shared dependencies with Phase 9/6 work. STRUCT-003 is self-contained in `packages/noodl-editor/src/editor/src/io/` and `packages/noodl-editor/tests/io/`. No cherry-pick needed.
|
## STRUCT-004 — Editor Format Detection COMPLETE
|
||||||
|
|
||||||
|
**Completed:** 2026-02-19
|
||||||
|
|
||||||
|
### What was built
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts` | Format detection utility |
|
||||||
|
| `packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts` | 30 unit + integration tests |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
`ProjectFormatDetector` accepts a `DetectorFilesystem` interface (injectable, testable). Supports both async `detect()` and sync `detectSync()`.
|
||||||
|
|
||||||
|
Detection logic (priority order):
|
||||||
|
1. `nodegx.project.json` + `components/_registry.json` → **v2 / high confidence**
|
||||||
|
2. Either v2 indicator alone → **v2 / medium confidence**
|
||||||
|
3. `project.json` only → **legacy / high confidence**
|
||||||
|
4. Nothing found → **unknown / low confidence**
|
||||||
|
5. Both legacy + v2 → **v2 wins** (migration in progress)
|
||||||
|
|
||||||
|
### Key exports
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `ProjectFormatDetector` | Main class |
|
||||||
|
| `DetectorFilesystem` | Interface for filesystem injection |
|
||||||
|
| `V2_INDICATORS` | Sentinel filenames for v2 format |
|
||||||
|
| `LEGACY_INDICATORS` | Sentinel filenames for legacy format |
|
||||||
|
| `createNodeDetector()` | Factory using Node.js `fs.existsSync` |
|
||||||
|
| `ProjectFormat` | `'legacy' | 'v2' | 'unknown'` type |
|
||||||
|
| `FormatDetectionResult` | `{ format, confidence, indicators }` |
|
||||||
|
|
||||||
|
### Key decisions
|
||||||
|
|
||||||
|
1. Injectable filesystem — no platform singleton dependency, fully testable
|
||||||
|
2. Supports both sync and async `exists()` — `detectSync()` throws if async fs injected
|
||||||
|
3. `createNodeDetector()` factory for Node.js/test contexts
|
||||||
|
4. Scoring system — v2 needs score ≥ 2 (nodegx.project.json=2, registry=2, components dir=1)
|
||||||
|
5. Errors in `exists()` treated as "not found" — graceful degradation
|
||||||
|
|
||||||
|
### Test coverage (30 tests)
|
||||||
|
|
||||||
|
- `detect()` — 12 cases (all format combinations, async fs, error handling)
|
||||||
|
- `detectSync()` — 4 cases (v2, legacy, unknown, throws on async fs)
|
||||||
|
- Convenience methods — 7 cases (getFormat, isV2, isLegacy)
|
||||||
|
- Constants — 3 cases
|
||||||
|
- `createNodeDetector()` integration — 4 cases (real filesystem with tmp dirs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisions and Learnings
|
## Decisions and Learnings
|
||||||
|
|
||||||
- **[2026-02-18]** Ajv v8 + ajv-formats: use `require()` for ajv-formats to avoid TS type conflict with root-level ajv-formats package
|
- **[2026-02-18]** Ajv v8 + ajv-formats: use `require()` for ajv-formats to avoid TS type conflict with root-level ajv-formats package
|
||||||
- **[2026-02-19]** Legacy `stateParamaters` typo (missing 'e') is real must be preserved in round-trip. Exporter normalises to `stateParameters`, importer reverses it.
|
- **[2026-02-19]** Legacy `stateParamaters` typo (missing 'e') is real — must be preserved in round-trip. Exporter normalises to `stateParameters`, importer reverses it.
|
||||||
- **[2026-02-19]** `component.path` field is the key to perfect round-trip fidelity always preserve the original legacy name in the v2 component file
|
- **[2026-02-19]** `component.path` field is the key to perfect round-trip fidelity — always preserve the original legacy name in the v2 component file
|
||||||
- **[2026-02-19]** `unflattenNodes` needs two passes first build the node map, then wire children in order using the children ID array
|
- **[2026-02-19]** `unflattenNodes` needs two passes — first build the node map, then wire children in order using the children ID array
|
||||||
|
- **[2026-02-19]** Format detection: inject filesystem interface rather than using platform singleton — makes the utility testable without Electron context and usable in Node.js scripts/tests
|
||||||
|
|||||||
240
packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts
Normal file
240
packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts
Normal 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
275
packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts
Normal file
275
packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './ProjectExporter.test';
|
export * from './ProjectExporter.test';
|
||||||
export * from './ProjectImporter.test';
|
export * from './ProjectImporter.test';
|
||||||
|
export * from './ProjectFormatDetector.test';
|
||||||
|
|||||||
Reference in New Issue
Block a user