@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.
@@ -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
+ }