@relational-fabric/canon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +329 -0
- package/eslint.js +31 -0
- package/package.json +126 -0
- package/src/axiom.ts +34 -0
- package/src/axioms/id.ts +53 -0
- package/src/axioms/references.ts +98 -0
- package/src/axioms/timestamps.ts +77 -0
- package/src/axioms/type.ts +53 -0
- package/src/axioms/version.ts +53 -0
- package/src/canon.ts +47 -0
- package/src/index.ts +30 -0
- package/src/radar/converter.ts +124 -0
- package/src/radar/index.ts +11 -0
- package/src/radar/validator.ts +310 -0
- package/src/registry.test.ts +75 -0
- package/src/registry.ts +72 -0
- package/src/shell.test.ts +63 -0
- package/src/shell.ts +49 -0
- package/src/testing.ts +43 -0
- package/src/types/axioms.ts +131 -0
- package/src/types/canons.ts +78 -0
- package/src/types/guards.ts +51 -0
- package/src/types/index.ts +10 -0
- package/src/types/js.ts +25 -0
- package/src/types/objects.ts +32 -0
- package/src/types/radar.ts +106 -0
- package/src/utils/guards.test.ts +27 -0
- package/src/utils/guards.ts +29 -0
- package/src/utils/objects.test.ts +141 -0
- package/src/utils/objects.ts +172 -0
- package/tsconfig.base.json +11 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamps axiom implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides universal access to timestamp data with format conversion.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RepresentationAxiom, Satisfies } from '../types/index.js'
|
|
8
|
+
import { inferAxiom } from '../axiom.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register Timestamps axiom in global Axioms interface
|
|
12
|
+
*/
|
|
13
|
+
declare module '@relational-fabric/canon' {
|
|
14
|
+
interface Axioms {
|
|
15
|
+
/**
|
|
16
|
+
* Timestamps concept - might be number, string, Date, etc.
|
|
17
|
+
*/
|
|
18
|
+
Timestamps: RepresentationAxiom<number | string | Date, Date>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a value is a canonical timestamp (Date object)
|
|
24
|
+
*
|
|
25
|
+
* @param value - The value to check
|
|
26
|
+
* @returns True if the value is a Date object
|
|
27
|
+
*/
|
|
28
|
+
export function isCanonicalTimestamp(value: number | string | Date): value is Date {
|
|
29
|
+
return value instanceof Date
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract and convert timestamp data to canonical Date format
|
|
34
|
+
*
|
|
35
|
+
* @param x - The timestamp value to convert
|
|
36
|
+
* @returns The timestamp as a Date object
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const unixTimestamp = 1640995200000
|
|
41
|
+
* const isoTimestamp = '2022-01-01T00:00:00Z'
|
|
42
|
+
* const dateTimestamp = new Date('2022-01-01')
|
|
43
|
+
*
|
|
44
|
+
* console.log(timestampsOf(unixTimestamp)) // Converted to canonical Date
|
|
45
|
+
* console.log(timestampsOf(isoTimestamp)) // Converted to canonical Date
|
|
46
|
+
* console.log(timestampsOf(dateTimestamp)) // Already canonical Date
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function timestampsOf<T extends Satisfies<'Timestamps'>>(x: T): Date {
|
|
50
|
+
const config = inferAxiom('Timestamps', x)
|
|
51
|
+
|
|
52
|
+
if (!config) {
|
|
53
|
+
throw new Error('No matching canon found for Timestamps axiom')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if already canonical
|
|
57
|
+
if (isCanonicalTimestamp(x)) {
|
|
58
|
+
return x
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Convert to canonical format
|
|
62
|
+
if (typeof x === 'number') {
|
|
63
|
+
// Assume Unix timestamp in milliseconds
|
|
64
|
+
return new Date(x)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof x === 'string') {
|
|
68
|
+
// Try to parse as ISO string or other date format
|
|
69
|
+
const date = new Date(x)
|
|
70
|
+
if (Number.isNaN(date.getTime())) {
|
|
71
|
+
throw new TypeError(`Invalid date string: ${x}`)
|
|
72
|
+
}
|
|
73
|
+
return date
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new TypeError(`Expected number, string, or Date, got ${typeof x}`)
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type axiom implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides universal access to entity classification across different data formats.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { KeyNameAxiom, Satisfies } from '../types/index.js'
|
|
8
|
+
import { inferAxiom } from '../axiom.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register Type axiom in global Axioms interface
|
|
12
|
+
*/
|
|
13
|
+
declare module '@relational-fabric/canon' {
|
|
14
|
+
interface Axioms {
|
|
15
|
+
/**
|
|
16
|
+
* Type concept - might be 'type', '@type', '_type', etc.
|
|
17
|
+
*/
|
|
18
|
+
Type: KeyNameAxiom
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract the type value from any entity that satisfies the Type axiom
|
|
24
|
+
*
|
|
25
|
+
* @param x - The entity to extract type from
|
|
26
|
+
* @returns The type value as a string
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const data = { type: 'user', name: 'Test' }
|
|
31
|
+
* const type = typeOf(data) // "user"
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function typeOf<T extends Satisfies<'Type'>>(x: T): string {
|
|
35
|
+
const config = inferAxiom('Type', x)
|
|
36
|
+
|
|
37
|
+
if (!config) {
|
|
38
|
+
throw new Error('No matching canon found for Type axiom')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// For KeyNameAxiom, extract using the key field
|
|
42
|
+
if ('key' in config && typeof config.key === 'string') {
|
|
43
|
+
const value = (x as Record<string, unknown>)[config.key]
|
|
44
|
+
|
|
45
|
+
if (typeof value !== 'string') {
|
|
46
|
+
throw new TypeError(`Expected string type, got ${typeof value}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error('Invalid Type axiom configuration')
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version axiom implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides universal access to version information across different data formats.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { KeyNameAxiom, Satisfies } from '../types/index.js'
|
|
8
|
+
import { inferAxiom } from '../axiom.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register Version axiom in global Axioms interface
|
|
12
|
+
*/
|
|
13
|
+
declare module '@relational-fabric/canon' {
|
|
14
|
+
interface Axioms {
|
|
15
|
+
/**
|
|
16
|
+
* Version concept - might be 'version', '@version', '_version', etc.
|
|
17
|
+
*/
|
|
18
|
+
Version: KeyNameAxiom
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract the version value from any entity that satisfies the Version axiom
|
|
24
|
+
*
|
|
25
|
+
* @param x - The entity to extract version from
|
|
26
|
+
* @returns The version value as a string or number
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const data = { version: 5, name: 'Test' }
|
|
31
|
+
* const version = versionOf(data) // 5
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function versionOf<T extends Satisfies<'Version'>>(x: T): string | number {
|
|
35
|
+
const config = inferAxiom('Version', x)
|
|
36
|
+
|
|
37
|
+
if (!config) {
|
|
38
|
+
throw new Error('No matching canon found for Version axiom')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// For KeyNameAxiom, extract using the key field
|
|
42
|
+
if ('key' in config && typeof config.key === 'string') {
|
|
43
|
+
const value = (x as Record<string, unknown>)[config.key]
|
|
44
|
+
|
|
45
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
46
|
+
throw new TypeError(`Expected string or number version, got ${typeof value}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error('Invalid Version axiom configuration')
|
|
53
|
+
}
|
package/src/canon.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canon API functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CanonConfig } from './types/index.js'
|
|
6
|
+
import { getRegistry } from './shell.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Infer which canon matches a value
|
|
10
|
+
*
|
|
11
|
+
* Finds the canon with the MOST matching axioms for the value.
|
|
12
|
+
* If multiple canons match the same number of axioms, returns the first.
|
|
13
|
+
*
|
|
14
|
+
* @param value - The value to check
|
|
15
|
+
* @returns The best matching canon config or undefined
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const canon = inferCanon({ id: 'test-123' })
|
|
20
|
+
* const jsonLdCanon = inferCanon({ '@id': 'uri' })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function inferCanon(value: unknown): CanonConfig | undefined {
|
|
24
|
+
const registry = getRegistry()
|
|
25
|
+
|
|
26
|
+
let bestCanon: CanonConfig | undefined
|
|
27
|
+
let maxMatches = 0
|
|
28
|
+
|
|
29
|
+
for (const canonConfig of registry) {
|
|
30
|
+
let matches = 0
|
|
31
|
+
|
|
32
|
+
// Count how many axioms match
|
|
33
|
+
for (const axiomConfig of Object.values(canonConfig.axioms)) {
|
|
34
|
+
if (axiomConfig.$basis(value)) {
|
|
35
|
+
matches++
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Keep the canon with the most matches
|
|
40
|
+
if (matches > maxMatches) {
|
|
41
|
+
maxMatches = matches
|
|
42
|
+
bestCanon = canonConfig
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return maxMatches > 0 ? bestCanon : undefined
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Axiom and Canon APIs
|
|
2
|
+
export * from './axiom.js'
|
|
3
|
+
// Specific Axioms
|
|
4
|
+
export * from './axioms/id.js'
|
|
5
|
+
export * from './axioms/references.js'
|
|
6
|
+
export * from './axioms/timestamps.js'
|
|
7
|
+
export * from './axioms/type.js'
|
|
8
|
+
export * from './axioms/version.js'
|
|
9
|
+
|
|
10
|
+
export * from './canon.js'
|
|
11
|
+
|
|
12
|
+
// Radar
|
|
13
|
+
export * from './radar/index.js'
|
|
14
|
+
|
|
15
|
+
// Registry and Shell
|
|
16
|
+
export * from './registry.js'
|
|
17
|
+
export * from './shell.js'
|
|
18
|
+
|
|
19
|
+
// Type testing utilities (compile-time helpers only)
|
|
20
|
+
export * from './testing.js'
|
|
21
|
+
|
|
22
|
+
// Type system (includes defineAxiom, defineCanon)
|
|
23
|
+
export * from './types/index.js'
|
|
24
|
+
|
|
25
|
+
// Utilities
|
|
26
|
+
export * from './utils/guards.js'
|
|
27
|
+
export * from './utils/objects.js'
|
|
28
|
+
|
|
29
|
+
// Re-export utility libraries used internally
|
|
30
|
+
export { defu } from 'defu'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technology Radar data conversion utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CsvRow, QuadrantKey, RadarData, RingKey } from '../types/radar.js'
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
import { parse } from 'yaml'
|
|
10
|
+
|
|
11
|
+
// Quadrant mapping for CSV output
|
|
12
|
+
const QUADRANT_MAP: Record<QuadrantKey, string> = {
|
|
13
|
+
'tools-libraries': 'Tools & Libraries',
|
|
14
|
+
'techniques-patterns': 'Techniques & Patterns',
|
|
15
|
+
'features-capabilities': 'Features & Capabilities',
|
|
16
|
+
'data-structures-formats': 'Data Structures, Formats & Standards',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Ring mapping for CSV output
|
|
20
|
+
const RING_MAP: Record<RingKey, string> = {
|
|
21
|
+
adopt: 'Adopt',
|
|
22
|
+
trial: 'Trial',
|
|
23
|
+
assess: 'Assess',
|
|
24
|
+
hold: 'Hold',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert YAML radar data to CSV format
|
|
29
|
+
*/
|
|
30
|
+
export function convertYamlToCsv(yamlContent: string): string {
|
|
31
|
+
const data = parse(yamlContent) as RadarData
|
|
32
|
+
|
|
33
|
+
// Generate CSV content
|
|
34
|
+
const csvRows: string[] = ['name,ring,quadrant,isNew,description']
|
|
35
|
+
|
|
36
|
+
// Process each quadrant in the entries section
|
|
37
|
+
Object.entries(data.entries).forEach(([quadrantKey, quadrantData]) => {
|
|
38
|
+
const quadrantName = QUADRANT_MAP[quadrantKey as QuadrantKey] || quadrantKey
|
|
39
|
+
|
|
40
|
+
// Process each ring in the quadrant
|
|
41
|
+
Object.entries(quadrantData).forEach(([ringKey, items]) => {
|
|
42
|
+
const ringName = RING_MAP[ringKey as RingKey] || ringKey
|
|
43
|
+
|
|
44
|
+
// Process each item in the ring
|
|
45
|
+
;(items as any[]).forEach((item: any) => {
|
|
46
|
+
const row: CsvRow = {
|
|
47
|
+
name: item.name,
|
|
48
|
+
ring: ringName,
|
|
49
|
+
quadrant: quadrantName,
|
|
50
|
+
isNew: item.isNew,
|
|
51
|
+
description: item.description,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
csvRows.push(formatCsvRow(row))
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return csvRows.join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convert radar data from file paths
|
|
64
|
+
*/
|
|
65
|
+
export function convertYamlFileToCsv(yamlPath: string, csvPath: string): void {
|
|
66
|
+
try {
|
|
67
|
+
// Read and parse YAML file
|
|
68
|
+
const yamlContent = readFileSync(yamlPath, 'utf8')
|
|
69
|
+
const csvContent = convertYamlToCsv(yamlContent)
|
|
70
|
+
|
|
71
|
+
// Write CSV file
|
|
72
|
+
writeFileSync(csvPath, csvContent)
|
|
73
|
+
// Success: Converted YAML to CSV
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(
|
|
76
|
+
'❌ Error converting YAML to CSV:',
|
|
77
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
78
|
+
)
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a CSV row with proper escaping
|
|
85
|
+
*/
|
|
86
|
+
function formatCsvRow(row: CsvRow): string {
|
|
87
|
+
return [
|
|
88
|
+
escapeCsvField(row.name),
|
|
89
|
+
escapeCsvField(row.ring),
|
|
90
|
+
escapeCsvField(row.quadrant),
|
|
91
|
+
row.isNew ? 'true' : 'false',
|
|
92
|
+
escapeCsvField(row.description),
|
|
93
|
+
].join(',')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Escape CSV field values
|
|
98
|
+
*/
|
|
99
|
+
function escapeCsvField(field: string): string {
|
|
100
|
+
if (typeof field !== 'string')
|
|
101
|
+
return String(field)
|
|
102
|
+
|
|
103
|
+
// Escape quotes and wrap in quotes if contains comma, quote, or newline
|
|
104
|
+
if (field.includes('"') || field.includes(',') || field.includes('\n')) {
|
|
105
|
+
return `"${field.replace(/"/g, '""')}"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return field
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse YAML radar data
|
|
113
|
+
*/
|
|
114
|
+
export function parseRadarYaml(yamlContent: string): RadarData {
|
|
115
|
+
return parse(yamlContent) as RadarData
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read and parse radar YAML file
|
|
120
|
+
*/
|
|
121
|
+
export function readRadarYaml(yamlPath: string): RadarData {
|
|
122
|
+
const yamlContent = readFileSync(yamlPath, 'utf8')
|
|
123
|
+
return parseRadarYaml(yamlContent)
|
|
124
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technology Radar utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for working with technology radar data,
|
|
5
|
+
* including conversion between YAML and CSV formats, validation, and
|
|
6
|
+
* data manipulation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from '../types/radar.js'
|
|
10
|
+
export * from './converter.js'
|
|
11
|
+
export * from './validator.js'
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technology Radar data validation utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { QuadrantKey, RadarData, RingKey } from '../types/radar.js'
|
|
6
|
+
|
|
7
|
+
const VALID_QUADRANTS: QuadrantKey[] = [
|
|
8
|
+
'tools-libraries',
|
|
9
|
+
'techniques-patterns',
|
|
10
|
+
'features-capabilities',
|
|
11
|
+
'data-structures-formats',
|
|
12
|
+
]
|
|
13
|
+
const VALID_RINGS: RingKey[] = ['adopt', 'trial', 'assess', 'hold']
|
|
14
|
+
|
|
15
|
+
export interface ValidationError {
|
|
16
|
+
type: 'missing_field' | 'invalid_value' | 'duplicate_entry' | 'invalid_structure'
|
|
17
|
+
message: string
|
|
18
|
+
path?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ValidationResult {
|
|
22
|
+
isValid: boolean
|
|
23
|
+
errors: ValidationError[]
|
|
24
|
+
warnings: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate radar data structure
|
|
29
|
+
*/
|
|
30
|
+
export function validateRadarData(data: any): ValidationResult {
|
|
31
|
+
const errors: ValidationError[] = []
|
|
32
|
+
const warnings: string[] = []
|
|
33
|
+
|
|
34
|
+
// Validate metadata
|
|
35
|
+
if (!data.metadata) {
|
|
36
|
+
errors.push({
|
|
37
|
+
type: 'missing_field',
|
|
38
|
+
message: 'Missing metadata section',
|
|
39
|
+
path: 'metadata',
|
|
40
|
+
})
|
|
41
|
+
} else {
|
|
42
|
+
validateMetadata(data.metadata, errors)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate the entries section
|
|
46
|
+
if (!data.entries) {
|
|
47
|
+
errors.push({
|
|
48
|
+
type: 'missing_field',
|
|
49
|
+
message: 'Missing entries section',
|
|
50
|
+
path: 'entries',
|
|
51
|
+
})
|
|
52
|
+
} else {
|
|
53
|
+
validateEntries(data.entries, errors, warnings)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
isValid: errors.length === 0,
|
|
58
|
+
errors,
|
|
59
|
+
warnings,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate metadata section
|
|
65
|
+
*/
|
|
66
|
+
function validateMetadata(metadata: any, errors: ValidationError[]): void {
|
|
67
|
+
const requiredFields = ['title', 'subtitle', 'version', 'lastUpdated']
|
|
68
|
+
|
|
69
|
+
for (const field of requiredFields) {
|
|
70
|
+
if (!metadata[field] || typeof metadata[field] !== 'string') {
|
|
71
|
+
errors.push({
|
|
72
|
+
type: 'missing_field',
|
|
73
|
+
message: `Missing or invalid ${field} in metadata`,
|
|
74
|
+
path: `metadata.${field}`,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate version format
|
|
80
|
+
if (metadata.version && !/^\d+\.\d+\.\d+/.test(metadata.version)) {
|
|
81
|
+
errors.push({
|
|
82
|
+
type: 'invalid_value',
|
|
83
|
+
message: 'Version should follow semantic versioning (e.g., 1.0.0)',
|
|
84
|
+
path: 'metadata.version',
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Validate date format
|
|
89
|
+
if (metadata.lastUpdated && !/^\d{4}-\d{2}-\d{2}/.test(metadata.lastUpdated)) {
|
|
90
|
+
errors.push({
|
|
91
|
+
type: 'invalid_value',
|
|
92
|
+
message: 'Last updated should be in YYYY-MM-DD format',
|
|
93
|
+
path: 'metadata.lastUpdated',
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate quadrants section
|
|
100
|
+
*/
|
|
101
|
+
function _validateQuadrants(quadrants: any[], errors: ValidationError[]): void {
|
|
102
|
+
const quadrantIds = new Set<string>()
|
|
103
|
+
|
|
104
|
+
quadrants.forEach((quadrant, index) => {
|
|
105
|
+
if (!quadrant.id || typeof quadrant.id !== 'string') {
|
|
106
|
+
errors.push({
|
|
107
|
+
type: 'missing_field',
|
|
108
|
+
message: `Missing or invalid id in quadrant ${index}`,
|
|
109
|
+
path: `quadrants[${index}].id`,
|
|
110
|
+
})
|
|
111
|
+
} else if (quadrantIds.has(quadrant.id)) {
|
|
112
|
+
errors.push({
|
|
113
|
+
type: 'duplicate_entry',
|
|
114
|
+
message: `Duplicate quadrant id: ${quadrant.id}`,
|
|
115
|
+
path: `quadrants[${index}].id`,
|
|
116
|
+
})
|
|
117
|
+
} else {
|
|
118
|
+
quadrantIds.add(quadrant.id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!quadrant.name || typeof quadrant.name !== 'string') {
|
|
122
|
+
errors.push({
|
|
123
|
+
type: 'missing_field',
|
|
124
|
+
message: `Missing or invalid name in quadrant ${index}`,
|
|
125
|
+
path: `quadrants[${index}].name`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!quadrant.description || typeof quadrant.description !== 'string') {
|
|
130
|
+
errors.push({
|
|
131
|
+
type: 'missing_field',
|
|
132
|
+
message: `Missing or invalid description in quadrant ${index}`,
|
|
133
|
+
path: `quadrants[${index}].description`,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate rings section
|
|
141
|
+
*/
|
|
142
|
+
function _validateRings(rings: any[], errors: ValidationError[]): void {
|
|
143
|
+
const ringIds = new Set<string>()
|
|
144
|
+
|
|
145
|
+
rings.forEach((ring, index) => {
|
|
146
|
+
if (!ring.id || typeof ring.id !== 'string') {
|
|
147
|
+
errors.push({
|
|
148
|
+
type: 'missing_field',
|
|
149
|
+
message: `Missing or invalid id in ring ${index}`,
|
|
150
|
+
path: `rings[${index}].id`,
|
|
151
|
+
})
|
|
152
|
+
} else if (ringIds.has(ring.id)) {
|
|
153
|
+
errors.push({
|
|
154
|
+
type: 'duplicate_entry',
|
|
155
|
+
message: `Duplicate ring id: ${ring.id}`,
|
|
156
|
+
path: `rings[${index}].id`,
|
|
157
|
+
})
|
|
158
|
+
} else {
|
|
159
|
+
ringIds.add(ring.id)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!ring.name || typeof ring.name !== 'string') {
|
|
163
|
+
errors.push({
|
|
164
|
+
type: 'missing_field',
|
|
165
|
+
message: `Missing or invalid name in ring ${index}`,
|
|
166
|
+
path: `rings[${index}].name`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!ring.description || typeof ring.description !== 'string') {
|
|
171
|
+
errors.push({
|
|
172
|
+
type: 'missing_field',
|
|
173
|
+
message: `Missing or invalid description in ring ${index}`,
|
|
174
|
+
path: `rings[${index}].description`,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!ring.color || typeof ring.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(ring.color)) {
|
|
179
|
+
errors.push({
|
|
180
|
+
type: 'invalid_value',
|
|
181
|
+
message: `Invalid color format in ring ${index}. Should be hex color (e.g., #93c47d)`,
|
|
182
|
+
path: `rings[${index}].color`,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Validate entries section
|
|
190
|
+
*/
|
|
191
|
+
function validateEntries(entries: any, errors: ValidationError[], warnings: string[]): void {
|
|
192
|
+
const entryNames = new Set<string>()
|
|
193
|
+
|
|
194
|
+
Object.entries(entries).forEach(([quadrantKey, quadrantData]) => {
|
|
195
|
+
if (quadrantKey === 'metadata')
|
|
196
|
+
return // Skip metadata
|
|
197
|
+
|
|
198
|
+
if (!VALID_QUADRANTS.includes(quadrantKey as QuadrantKey)) {
|
|
199
|
+
warnings.push(`Unknown quadrant: ${quadrantKey}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof quadrantData !== 'object' || quadrantData === null) {
|
|
203
|
+
errors.push({
|
|
204
|
+
type: 'invalid_structure',
|
|
205
|
+
message: `Invalid quadrant data for ${quadrantKey}`,
|
|
206
|
+
path: `entries.${quadrantKey}`,
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Object.entries(quadrantData).forEach(([ringKey, items]) => {
|
|
212
|
+
if (!VALID_RINGS.includes(ringKey as RingKey)) {
|
|
213
|
+
warnings.push(`Unknown ring: ${ringKey} in quadrant ${quadrantKey}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!Array.isArray(items)) {
|
|
217
|
+
errors.push({
|
|
218
|
+
type: 'invalid_structure',
|
|
219
|
+
message: `Invalid ring data for ${ringKey} in quadrant ${quadrantKey}`,
|
|
220
|
+
path: `entries.${quadrantKey}.${ringKey}`,
|
|
221
|
+
})
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
items.forEach((item: any, index: number) => {
|
|
226
|
+
validateRadarEntry(item, errors, warnings, `${quadrantKey}.${ringKey}[${index}]`)
|
|
227
|
+
|
|
228
|
+
// Check for duplicate names
|
|
229
|
+
if (item.name && entryNames.has(item.name)) {
|
|
230
|
+
errors.push({
|
|
231
|
+
type: 'duplicate_entry',
|
|
232
|
+
message: `Duplicate entry name: ${item.name}`,
|
|
233
|
+
path: `entries.${quadrantKey}.${ringKey}[${index}].name`,
|
|
234
|
+
})
|
|
235
|
+
} else if (item.name) {
|
|
236
|
+
entryNames.add(item.name)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Validate individual radar entry
|
|
245
|
+
*/
|
|
246
|
+
function validateRadarEntry(
|
|
247
|
+
entry: any,
|
|
248
|
+
errors: ValidationError[],
|
|
249
|
+
warnings: string[],
|
|
250
|
+
path: string,
|
|
251
|
+
): void {
|
|
252
|
+
if (!entry.name || typeof entry.name !== 'string') {
|
|
253
|
+
errors.push({
|
|
254
|
+
type: 'missing_field',
|
|
255
|
+
message: 'Missing or invalid name',
|
|
256
|
+
path: `entries.${path}.name`,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!entry.description || typeof entry.description !== 'string') {
|
|
261
|
+
errors.push({
|
|
262
|
+
type: 'missing_field',
|
|
263
|
+
message: 'Missing or invalid description',
|
|
264
|
+
path: `entries.${path}.description`,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof entry.isNew !== 'boolean') {
|
|
269
|
+
errors.push({
|
|
270
|
+
type: 'missing_field',
|
|
271
|
+
message: 'Missing or invalid isNew field',
|
|
272
|
+
path: `entries.${path}.isNew`,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (entry.justification && typeof entry.justification !== 'string') {
|
|
277
|
+
errors.push({
|
|
278
|
+
type: 'invalid_value',
|
|
279
|
+
message: 'Justification must be a string',
|
|
280
|
+
path: `entries.${path}.justification`,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Validate radar data from file
|
|
287
|
+
*/
|
|
288
|
+
export async function validateRadarFile(filePath: string): Promise<ValidationResult> {
|
|
289
|
+
try {
|
|
290
|
+
const fs = await import('node:fs')
|
|
291
|
+
const yaml = await import('yaml')
|
|
292
|
+
|
|
293
|
+
const yamlContent = fs.readFileSync(filePath, 'utf8')
|
|
294
|
+
const data = yaml.parse(yamlContent) as RadarData
|
|
295
|
+
|
|
296
|
+
return validateRadarData(data)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return {
|
|
299
|
+
isValid: false,
|
|
300
|
+
errors: [
|
|
301
|
+
{
|
|
302
|
+
type: 'invalid_structure',
|
|
303
|
+
message: `Failed to parse file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
304
|
+
path: filePath,
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
warnings: [],
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|