yarramate 0.2.0 → 0.3.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.
@@ -4,8 +4,10 @@ import { resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import Ajv2020Module from 'ajv/dist/2020.js';
7
- import { parseDocument } from 'yaml';
7
+ import { isSeq, parseDocument } from 'yaml';
8
8
  import { isMainModule, resolveCliWorkspaceSources, } from '../cli-support.js';
9
+ import { compileWorkspace } from '../compiler.js';
10
+ import { adapterMappingLocation, loadAdapterMapping, validateAdapterMapping, } from '../adapter-mapping.js';
9
11
  import { locateSourcePath } from '../source-document.js';
10
12
  import { prepareLikeC4Export, } from './likec4-prepare.js';
11
13
  import { exportLikeC4Project, loadLikeC4ProjectDefinition, } from './likec4-project.js';
@@ -71,6 +73,7 @@ const publishFiles = (files) => {
71
73
  }
72
74
  };
73
75
  const usage = 'Usage:\n' +
76
+ ' yarramate-likec4 map --sync <mapping.yaml> <workspace-or-source...>\n' +
74
77
  ' yarramate-likec4 check <projection.yaml> <mapping.yaml> [--json] [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
75
78
  ' yarramate-likec4 check <likec4-project.yaml> [--json] <workspace-or-source...>\n' +
76
79
  ' yarramate-likec4 export <projection.yaml> <mapping.yaml> [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
@@ -86,6 +89,149 @@ const checkJson = (ok, diagnostics) => `${JSON.stringify({
86
89
  diagnostics,
87
90
  }, null, 2)}\n`;
88
91
  const sameJson = (left, right) => JSON.stringify(left) === JSON.stringify(right);
92
+ const lowerCamel = (value) => value.replaceAll(/-([a-z0-9])/g, (_, character) => character.toUpperCase());
93
+ const runLikeC4MapSync = (args, cwd) => {
94
+ const [sync, mappingPath, ...sourcePaths] = args;
95
+ if (sync !== '--sync' ||
96
+ mappingPath === undefined ||
97
+ mappingPath.startsWith('-') ||
98
+ sourcePaths.length === 0 ||
99
+ sourcePaths.some((path) => path.startsWith('-'))) {
100
+ return { exitCode: 2, stdout: '', stderr: usage };
101
+ }
102
+ try {
103
+ const resolved = resolveCliWorkspaceSources(sourcePaths, cwd);
104
+ if (!resolved.ok) {
105
+ return {
106
+ exitCode: 1,
107
+ stdout: diagnosticJson(resolved.diagnostics),
108
+ stderr: '',
109
+ };
110
+ }
111
+ const compilation = compileWorkspace(resolved.paths.map((path) => ({
112
+ path,
113
+ source: readFileSync(resolve(cwd, path), 'utf8'),
114
+ })));
115
+ if (!compilation.ok) {
116
+ return {
117
+ exitCode: 1,
118
+ stdout: diagnosticJson(compilation.diagnostics),
119
+ stderr: '',
120
+ };
121
+ }
122
+ const absoluteMappingPath = resolve(cwd, mappingPath);
123
+ const original = readFileSync(absoluteMappingPath, 'utf8');
124
+ const loaded = loadAdapterMapping({
125
+ path: mappingPath,
126
+ source: original,
127
+ });
128
+ if (!loaded.ok) {
129
+ return {
130
+ exitCode: 1,
131
+ stdout: diagnosticJson(loaded.diagnostics),
132
+ stderr: '',
133
+ };
134
+ }
135
+ if (loaded.mapping.adapter !== 'likec4') {
136
+ const location = adapterMappingLocation(loaded.mapping, 'adapter');
137
+ return {
138
+ exitCode: 1,
139
+ stdout: diagnosticJson([{
140
+ severity: 'error',
141
+ code: 'YMLC101',
142
+ message: `Adapter mapping targets "${loaded.mapping.adapter}", not "likec4"`,
143
+ ...location,
144
+ }]),
145
+ stderr: '',
146
+ };
147
+ }
148
+ const validation = validateAdapterMapping(compilation.graph, loaded.mapping);
149
+ if (!validation.ok) {
150
+ return {
151
+ exitCode: 1,
152
+ stdout: diagnosticJson(validation.diagnostics),
153
+ stderr: '',
154
+ };
155
+ }
156
+ const mapped = new Set(loaded.mapping.mappings.map(({ native }) => native));
157
+ const claimedExternal = new Set(loaded.mapping.mappings.map(({ external }) => external));
158
+ const architectureStates = new Set(compilation.graph.claims
159
+ .filter(({ predicate }) => predicate === 'yarramate/state/type')
160
+ .map(({ subject }) => subject));
161
+ const additions = compilation.graph.subjects
162
+ .filter(({ id }) => !mapped.has(id) && !architectureStates.has(id))
163
+ .map((subject) => {
164
+ const [documentId, localId] = subject.id.split('#');
165
+ const local = lowerCamel(localId);
166
+ let external = local;
167
+ if (claimedExternal.has(external)) {
168
+ external = `${lowerCamel(documentId)}_${local}`;
169
+ }
170
+ let suffix = 2;
171
+ const base = external;
172
+ while (claimedExternal.has(external)) {
173
+ external = `${base}_${suffix}`;
174
+ suffix += 1;
175
+ }
176
+ claimedExternal.add(external);
177
+ return {
178
+ native: subject.id,
179
+ external,
180
+ type: subject.type,
181
+ };
182
+ });
183
+ if (additions.length === 0) {
184
+ return {
185
+ exitCode: 0,
186
+ stdout: `LikeC4 mapping ${mappingPath} is already synchronized\n`,
187
+ stderr: '',
188
+ };
189
+ }
190
+ const document = parseDocument(original);
191
+ for (const addition of additions) {
192
+ document.addIn(['mappings'], addition);
193
+ }
194
+ const mappings = document.getIn(['mappings'], true);
195
+ if (isSeq(mappings))
196
+ mappings.flow = false;
197
+ const candidate = document.toString({ lineWidth: 0 });
198
+ const candidateMapping = loadAdapterMapping({
199
+ path: mappingPath,
200
+ source: candidate,
201
+ });
202
+ if (!candidateMapping.ok) {
203
+ return {
204
+ exitCode: 1,
205
+ stdout: diagnosticJson(candidateMapping.diagnostics),
206
+ stderr: '',
207
+ };
208
+ }
209
+ const candidateValidation = validateAdapterMapping(compilation.graph, candidateMapping.mapping);
210
+ if (!candidateValidation.ok) {
211
+ return {
212
+ exitCode: 1,
213
+ stdout: diagnosticJson(candidateValidation.diagnostics),
214
+ stderr: '',
215
+ };
216
+ }
217
+ const staged = stageFile(absoluteMappingPath, candidate);
218
+ try {
219
+ staged.publish();
220
+ }
221
+ finally {
222
+ staged.cleanup();
223
+ }
224
+ return {
225
+ exitCode: 0,
226
+ stdout: `Added ${additions.length} LikeC4 mappings to ${mappingPath}\n`,
227
+ stderr: '',
228
+ };
229
+ }
230
+ catch (error) {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ return { exitCode: 2, stdout: '', stderr: `${message}\n` };
233
+ }
234
+ };
89
235
  const publishGeneratedProject = (cwd, outputDirectory, input) => {
90
236
  const projectPath = resolve(cwd, outputDirectory);
91
237
  const markerPath = resolve(projectPath, 'yarramate.generated.json');
@@ -194,6 +340,9 @@ const publishGeneratedProject = (cwd, outputDirectory, input) => {
194
340
  };
195
341
  };
196
342
  export function runLikeC4Cli(args, cwd = process.cwd()) {
343
+ if (args[0] === 'map') {
344
+ return runLikeC4MapSync(args.slice(1), cwd);
345
+ }
197
346
  const [command, projectionPath, mappingPath, ...options] = args;
198
347
  let projectDefinitionMode = false;
199
348
  if ((command === 'check' || command === 'export-project') &&
@@ -307,25 +456,63 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
307
456
  stderr: '',
308
457
  };
309
458
  }
310
- const preparedViews = loadedProject.document.value.views.map((view) => ({
459
+ const referencedSources = new Map();
460
+ const referenceDiagnostics = [];
461
+ const readProjectReference = (path, label, yamlPath, pointer) => {
462
+ const existing = referencedSources.get(path);
463
+ if (existing !== undefined)
464
+ return existing;
465
+ try {
466
+ const source = {
467
+ path,
468
+ source: readFileSync(resolve(cwd, path), 'utf8'),
469
+ };
470
+ referencedSources.set(path, source);
471
+ return source;
472
+ }
473
+ catch (error) {
474
+ const location = locateSourcePath(projectSource.path, loadedProject.document.yaml, loadedProject.document.lineCounter, yamlPath, pointer);
475
+ const absent = error instanceof Error &&
476
+ 'code' in error &&
477
+ error.code === 'ENOENT';
478
+ referenceDiagnostics.push({
479
+ severity: 'error',
480
+ code: 'YMLC110',
481
+ message: absent
482
+ ? `LikeC4 project ${label} "${path}" does not exist`
483
+ : `LikeC4 project ${label} "${path}" cannot be read`,
484
+ ...location,
485
+ });
486
+ return undefined;
487
+ }
488
+ };
489
+ const subjectMapping = readProjectReference(loadedProject.document.value.mapping, 'mapping', ['mapping'], '/mapping');
490
+ const kindMapping = loadedProject.document.value.kindMapping === undefined
491
+ ? undefined
492
+ : readProjectReference(loadedProject.document.value.kindMapping, 'kind mapping', ['kindMapping'], '/kindMapping');
493
+ const projections = loadedProject.document.value.views.map((view, index) => readProjectReference(view.projection, 'projection', ['views', index, 'projection'], `/views/${index}/projection`));
494
+ if (referenceDiagnostics.length > 0 ||
495
+ subjectMapping === undefined) {
496
+ return {
497
+ exitCode: 1,
498
+ stdout: diagnosticOutput(referenceDiagnostics.sort((left, right) => left.path.localeCompare(right.path) ||
499
+ left.line - right.line ||
500
+ left.column - right.column ||
501
+ left.code.localeCompare(right.code) ||
502
+ left.message.localeCompare(right.message))),
503
+ stderr: '',
504
+ };
505
+ }
506
+ const preparedViews = loadedProject.document.value.views.map((view, index) => ({
311
507
  view,
312
508
  prepared: prepareLikeC4Export({
313
509
  sources,
314
- projection: {
315
- path: view.projection,
316
- source: readFileSync(resolve(cwd, view.projection), 'utf8'),
317
- },
318
- subjectMapping: {
319
- path: loadedProject.document.value.mapping,
320
- source: readFileSync(resolve(cwd, loadedProject.document.value.mapping), 'utf8'),
321
- },
510
+ projection: projections[index],
511
+ subjectMapping,
322
512
  ...(loadedProject.document.value.kindMapping === undefined
323
513
  ? {}
324
514
  : {
325
- kindMapping: {
326
- path: loadedProject.document.value.kindMapping,
327
- source: readFileSync(resolve(cwd, loadedProject.document.value.kindMapping), 'utf8'),
328
- },
515
+ kindMapping: kindMapping,
329
516
  }),
330
517
  ...(view.compare === undefined
331
518
  ? {}
@@ -171,14 +171,28 @@ export function runCheckCommand(options, cwd) {
171
171
  const diagnostics = result.ok
172
172
  ? optionalDiagnostics
173
173
  : result.diagnostics;
174
+ const counted = result.ok
175
+ ? (() => {
176
+ const states = new Set(result.graph.claims
177
+ .filter(({ predicate }) => predicate === 'yarramate/state/type')
178
+ .map(({ subject }) => subject));
179
+ return {
180
+ documents: result.graph.documents.length,
181
+ concepts: result.graph.subjects.filter(({ id, type }) => type === 'concept' && !states.has(id)).length,
182
+ relationships: result.graph.subjects.filter(({ type }) => type === 'relationship').length,
183
+ states: states.size,
184
+ };
185
+ })()
186
+ : undefined;
174
187
  if (json) {
175
188
  return {
176
189
  exitCode: ok ? 0 : 1,
177
- stdout: checkResultJson(ok, ok ? [] : diagnostics),
190
+ stdout: checkResultJson(ok, ok ? [] : diagnostics, ok ? counted : undefined),
178
191
  stderr: '',
179
192
  };
180
193
  }
181
194
  if (ok && result.ok) {
195
+ const successfulCounts = counted;
182
196
  const documentCount = result.graph.documents.length;
183
197
  const profileCount = coreSources.length - documentCount;
184
198
  const mappingCount = mappingSources.length;
@@ -215,7 +229,11 @@ export function runCheckCommand(options, cwd) {
215
229
  ].join(' and ');
216
230
  return {
217
231
  exitCode: 0,
218
- stdout: `Checked ${checked}: no errors\n`,
232
+ stdout: `Checked ${checked} (` +
233
+ `${successfulCounts.concepts} ${successfulCounts.concepts === 1 ? 'concept' : 'concepts'}, ` +
234
+ `${successfulCounts.relationships} ${successfulCounts.relationships === 1 ? 'relationship' : 'relationships'}, ` +
235
+ `${successfulCounts.states} ${successfulCounts.states === 1 ? 'state' : 'states'}` +
236
+ '): no errors\n',
219
237
  stderr: '',
220
238
  };
221
239
  }
@@ -7,7 +7,12 @@ export interface CliResult {
7
7
  export declare const isMainModule: (moduleUrl: string, entrypoint: string | undefined) => boolean;
8
8
  export declare const usage = "Usage:\n yarramate init <directory>\n yarramate add <document.yaml> --id <id> --kind <kind> --name <name> [--status <status>] [--description <text>] [--owner <ref>] [--constraint <id>=<ref> ...] [--reference <id>=<ref> ...] [--present-in <state-ref> ...] [--source <source.yaml> ...]\n yarramate connect <document.yaml> --id <id> --kind <kind> --from <ref> --to <ref> [--name <name>] [--description <text>] [--status <status>] [--mode <mode>] [--content <text>] [--reference <id>=<ref> ...] [--present-in <state-ref> ...] [--source <source.yaml> ...]\n yarramate check <source.yaml> [source.yaml ...] [--json]\n yarramate compile <source.yaml> [source.yaml ...]\n yarramate context <projection.yaml> <source.yaml> [source.yaml ...]\n yarramate view <projection.yaml> <source.yaml> [source.yaml ...]\n yarramate compare <from-state> <to-state> <source.yaml> [source.yaml ...]\n yarramate evidence <evidence.yaml> <source.yaml> [source.yaml ...]\n yarramate reconcile <workspace.yaml>\n";
9
9
  export declare const diagnosticJson: (diagnostics: unknown) => string;
10
- export declare const checkResultJson: (ok: boolean, diagnostics: unknown) => string;
10
+ export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counted?: {
11
+ readonly documents: number;
12
+ readonly concepts: number;
13
+ readonly relationships: number;
14
+ readonly states: number;
15
+ }) => string;
11
16
  export declare const humanDiagnostics: (diagnostics: readonly Pick<Diagnostic, "path" | "line" | "column" | "code" | "message">[]) => string;
12
17
  export declare const sortDiagnostics: <T extends Diagnostic>(diagnostics: readonly T[]) => T[];
13
18
  export declare const resolveCliWorkspaceSources: (paths: readonly string[], cwd: string, options?: {
@@ -19,10 +19,11 @@ export const diagnosticJson = (diagnostics) => `${JSON.stringify({
19
19
  format: 'yarramate/diagnostic-result/v1',
20
20
  diagnostics,
21
21
  }, null, 2)}\n`;
22
- export const checkResultJson = (ok, diagnostics) => `${JSON.stringify({
22
+ export const checkResultJson = (ok, diagnostics, counted) => `${JSON.stringify({
23
23
  format: 'yarramate/check-result/v1',
24
24
  ok,
25
25
  diagnostics,
26
+ ...(counted === undefined ? {} : { counted }),
26
27
  }, null, 2)}\n`;
27
28
  export const humanDiagnostics = (diagnostics) => diagnostics
28
29
  .map((diagnostic) => `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} error ${diagnostic.code} ${diagnostic.message}\n`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,9 @@
17
17
  "items": {
18
18
  "$ref": "#/$defs/diagnostic"
19
19
  }
20
+ },
21
+ "counted": {
22
+ "$ref": "#/$defs/counted"
20
23
  }
21
24
  },
22
25
  "allOf": [
@@ -30,6 +33,7 @@
30
33
  "required": ["ok"]
31
34
  },
32
35
  "then": {
36
+ "required": ["counted"],
33
37
  "properties": {
34
38
  "diagnostics": {
35
39
  "maxItems": 0
@@ -46,6 +50,17 @@
46
50
  }
47
51
  ],
48
52
  "$defs": {
53
+ "counted": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["documents", "concepts", "relationships", "states"],
57
+ "properties": {
58
+ "documents": { "type": "integer", "minimum": 0 },
59
+ "concepts": { "type": "integer", "minimum": 0 },
60
+ "relationships": { "type": "integer", "minimum": 0 },
61
+ "states": { "type": "integer", "minimum": 0 }
62
+ }
63
+ },
49
64
  "diagnostic": {
50
65
  "type": "object",
51
66
  "additionalProperties": false,
@@ -34,7 +34,7 @@
34
34
  "const": "error"
35
35
  },
36
36
  "code": {
37
- "enum": ["YMLC101", "YMLC102", "YMLC103", "YMLC104", "YMLC105", "YMLC106"]
37
+ "enum": ["YMLC101", "YMLC102", "YMLC103", "YMLC104", "YMLC105", "YMLC106", "YMLC107", "YMLC108", "YMLC109", "YMLC110"]
38
38
  },
39
39
  "message": {
40
40
  "type": "string",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "path": {
43
43
  "type": "string",
44
+ "description": "Repository-relative path resolved from the CLI working directory. Parent traversal, absolute paths, and backslashes are rejected.",
44
45
  "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
45
46
  },
46
47
  "subjectIdentity": {
@@ -190,6 +190,9 @@ yarramate view <projection.yaml> .yarramate/workspace.yaml
190
190
  yarramate compare <from-state> <to-state> .yarramate/workspace.yaml
191
191
  yarramate evidence <evidence.yaml> .yarramate/workspace.yaml
192
192
  yarramate reconcile .yarramate/workspace.yaml
193
+ yarramate-likec4 map --sync \
194
+ .yarramate/integrations/likec4/subject-mapping.yaml \
195
+ .yarramate/workspace.yaml
193
196
  ```
194
197
 
195
198
  Treat exit `0` as successful execution, `1` as correctness diagnostics, and