yarramate 0.5.0 → 0.6.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/README.md CHANGED
@@ -119,13 +119,15 @@ node dist/cli.js check .yarramate/workspace.yaml --json
119
119
  node dist/cli.js compile .yarramate/workspace.yaml
120
120
  node dist/cli.js context .yarramate/projections/context.yaml .yarramate/workspace.yaml
121
121
  node dist/cli.js view .yarramate/projections/context.yaml .yarramate/workspace.yaml
122
+ node dist/cli.js next .yarramate/projections/context.yaml .yarramate/workspace.yaml
122
123
  node dist/cli.js evidence .yarramate/evidence/repository.yaml .yarramate/workspace.yaml
123
124
  node dist/cli.js reconcile .yarramate/workspace.yaml
124
125
  ```
125
126
 
126
127
  `init` creates `.yarramate/architecture/main.yaml` and
127
128
  `.yarramate/workspace.yaml`. Commands accept explicit source documents or one
128
- explicit workspace manifest.
129
+ explicit workspace manifest. `check --strict` additionally fails when any
130
+ evidence observation contradicts the model, for gates that want one knob.
129
131
 
130
132
  For a local consumer test, create a package artifact:
131
133
 
@@ -77,8 +77,8 @@ const usage = 'Usage:\n' +
77
77
  ' yarramate-likec4 check <projection.yaml> <mapping.yaml> [--json] [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
78
78
  ' yarramate-likec4 check <likec4-project.yaml> [--json] <workspace-or-source...>\n' +
79
79
  ' yarramate-likec4 export <projection.yaml> <mapping.yaml> [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
80
- ' yarramate-likec4 export-project <projection.yaml> <mapping.yaml> <output-dir> [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
81
- ' yarramate-likec4 export-project <likec4-project.yaml> <output-dir> <workspace-or-source...>\n';
80
+ ' yarramate-likec4 export-project [--check] <projection.yaml> <mapping.yaml> <output-dir> [--kinds <kind-mapping.yaml>] [--compare <from-state> <to-state>] <workspace-or-source...>\n' +
81
+ ' yarramate-likec4 export-project [--check] <likec4-project.yaml> <output-dir> <workspace-or-source...>\n';
82
82
  const diagnosticJson = (diagnostics) => `${JSON.stringify({
83
83
  format: 'yarramate/likec4-diagnostic-result/v1',
84
84
  diagnostics,
@@ -89,10 +89,13 @@ const checkJson = (ok, diagnostics) => `${JSON.stringify({
89
89
  diagnostics,
90
90
  }, null, 2)}\n`;
91
91
  const unmappedSubjectPreviewLength = 3;
92
- const summarizeUnmappedConcepts = (diagnostics) => {
93
- const unmapped = diagnostics.filter((diagnostic) => diagnostic.code === 'YMLC102');
92
+ const summarizeUnmappedSubjects = (diagnostics) => [
93
+ ['YMLC102', 'concepts'],
94
+ ['YMLC111', 'relationships'],
95
+ ].reduce((current, [code, noun]) => {
96
+ const unmapped = current.filter((diagnostic) => diagnostic.code === code);
94
97
  if (unmapped.length <= unmappedSubjectPreviewLength)
95
- return diagnostics;
98
+ return current;
96
99
  const preview = unmapped
97
100
  .slice(0, unmappedSubjectPreviewLength)
98
101
  .map((diagnostic) => 'subject' in diagnostic && diagnostic.subject !== undefined
@@ -101,21 +104,20 @@ const summarizeUnmappedConcepts = (diagnostics) => {
101
104
  .join(', ');
102
105
  const summary = {
103
106
  ...unmapped[0],
104
- message: `${unmapped.length} projected concepts have no LikeC4 mapping ` +
107
+ message: `${unmapped.length} projected ${noun} have no LikeC4 mapping ` +
105
108
  `(first: ${preview}); run "yarramate-likec4 map --sync" to add ` +
106
109
  'the missing mappings',
107
110
  };
108
111
  let summarized = false;
109
- return diagnostics.flatMap((diagnostic) => {
110
- if (diagnostic.code !== 'YMLC102')
112
+ return current.flatMap((diagnostic) => {
113
+ if (diagnostic.code !== code)
111
114
  return [diagnostic];
112
115
  if (summarized)
113
116
  return [];
114
117
  summarized = true;
115
118
  return [summary];
116
119
  });
117
- };
118
- const sameJson = (left, right) => JSON.stringify(left) === JSON.stringify(right);
120
+ }, diagnostics);
119
121
  const lowerCamel = (value) => value.replaceAll(/-([a-z0-9])/g, (_, character) => character.toUpperCase());
120
122
  const runLikeC4MapSync = (args, cwd) => {
121
123
  const sync = args[0];
@@ -290,6 +292,33 @@ const runLikeC4MapSync = (args, cwd) => {
290
292
  return { exitCode: 2, stdout: '', stderr: `${message}\n` };
291
293
  }
292
294
  };
295
+ // Ownership fields never gate updates: a marker of either format version
296
+ // plus intact output digests proves the directory is entirely
297
+ // machine-generated, so regenerating after the inputs changed loses nothing.
298
+ const readGeneratedProjectMarker = (markerPath) => {
299
+ let marker;
300
+ try {
301
+ marker = JSON.parse(readFileSync(markerPath, 'utf8'));
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ return validateGeneratedProjectMarker(marker) ||
307
+ validateGeneratedProjectV2Marker(marker)
308
+ ? marker
309
+ : undefined;
310
+ };
311
+ const changedGeneratedFile = (projectPath, marker) => {
312
+ // Markers written before output digests existed are accepted once and
313
+ // upgraded on regeneration.
314
+ if (marker['digests'] === undefined)
315
+ return undefined;
316
+ const digests = marker['digests'];
317
+ return generatedFileNames.find((file) => {
318
+ const path = resolve(projectPath, file);
319
+ return (!existsSync(path) || sha256(readFileSync(path)) !== digests[file]);
320
+ });
321
+ };
293
322
  const publishGeneratedProject = (cwd, outputDirectory, input) => {
294
323
  const projectPath = resolve(cwd, outputDirectory);
295
324
  const markerPath = resolve(projectPath, 'yarramate.generated.json');
@@ -319,37 +348,21 @@ const publishGeneratedProject = (cwd, outputDirectory, input) => {
319
348
  stderr: `Generated project contains an unsafe file: ${unsafeFile}\n`,
320
349
  };
321
350
  }
322
- let marker;
323
- try {
324
- marker = JSON.parse(readFileSync(markerPath, 'utf8'));
325
- }
326
- catch {
327
- marker = undefined;
328
- }
329
- if (!input.validateMarker(marker) ||
330
- typeof marker !== 'object' ||
331
- marker === null ||
332
- Object.entries(input.ownership).some(([key, value]) => !sameJson(marker[key], value))) {
351
+ const marker = readGeneratedProjectMarker(markerPath);
352
+ if (marker === undefined) {
333
353
  return {
334
354
  exitCode: 2,
335
355
  stdout: '',
336
- stderr: `Output directory already exists: ${projectPath}\n`,
356
+ stderr: `Output directory exists but is not a YarraMate-generated project: ${projectPath}\n`,
337
357
  };
338
358
  }
339
- if ('digests' in marker && marker.digests !== undefined) {
340
- const digests = marker.digests;
341
- const changedFile = generatedFileNames.find((file) => {
342
- const path = resolve(projectPath, file);
343
- return (!existsSync(path) ||
344
- sha256(readFileSync(path)) !== digests[file]);
345
- });
346
- if (changedFile !== undefined) {
347
- return {
348
- exitCode: 2,
349
- stdout: '',
350
- stderr: `Generated project file has changed: ${resolve(projectPath, changedFile)}\n`,
351
- };
352
- }
359
+ const changedFile = changedGeneratedFile(projectPath, marker);
360
+ if (changedFile !== undefined) {
361
+ return {
362
+ exitCode: 2,
363
+ stdout: '',
364
+ stderr: `Generated project file has changed: ${resolve(projectPath, changedFile)}\n`,
365
+ };
353
366
  }
354
367
  }
355
368
  else {
@@ -373,6 +386,7 @@ const publishGeneratedProject = (cwd, outputDirectory, input) => {
373
386
  'model.likec4': sha256(input.modelSource),
374
387
  'specification.likec4': sha256(specificationSource),
375
388
  },
389
+ inputDigests: input.inputDigests,
376
390
  }, null, 2)}\n`;
377
391
  publishFiles([
378
392
  {
@@ -397,6 +411,85 @@ const publishGeneratedProject = (cwd, outputDirectory, input) => {
397
411
  stderr: '',
398
412
  };
399
413
  };
414
+ // Freshness is a pure digest comparison between the marker and the would-be
415
+ // export; nothing on disk is written or semantically judged.
416
+ const checkGeneratedProject = (cwd, outputDirectory, input) => {
417
+ const projectPath = resolve(cwd, outputDirectory);
418
+ if (!existsSync(projectPath)) {
419
+ return {
420
+ exitCode: 1,
421
+ stdout: 'Generated LikeC4 output: absent\n',
422
+ stderr: '',
423
+ };
424
+ }
425
+ const marker = readGeneratedProjectMarker(resolve(projectPath, 'yarramate.generated.json'));
426
+ if (marker === undefined) {
427
+ return {
428
+ exitCode: 2,
429
+ stdout: '',
430
+ stderr: `Output directory exists but is not a YarraMate-generated project: ${projectPath}\n`,
431
+ };
432
+ }
433
+ const changedFile = changedGeneratedFile(projectPath, marker);
434
+ if (changedFile !== undefined) {
435
+ return {
436
+ exitCode: 1,
437
+ stdout: 'Generated LikeC4 output: modified\n' +
438
+ 'Reason:\n' +
439
+ `- generated file changed: ${resolve(projectPath, changedFile)}\n` +
440
+ 'Safe to regenerate: no\n',
441
+ stderr: '',
442
+ };
443
+ }
444
+ const recordedInputs = marker['inputDigests'];
445
+ const reasons = [];
446
+ if (recordedInputs === undefined) {
447
+ reasons.push('marker predates input digests');
448
+ }
449
+ else {
450
+ const inputPaths = [
451
+ ...new Set([
452
+ ...Object.keys(recordedInputs),
453
+ ...Object.keys(input.inputDigests),
454
+ ]),
455
+ ].sort();
456
+ for (const path of inputPaths) {
457
+ const recorded = recordedInputs[path];
458
+ const current = input.inputDigests[path];
459
+ if (recorded === undefined)
460
+ reasons.push(`input added: ${path}`);
461
+ else if (current === undefined) {
462
+ reasons.push(`input removed: ${path}`);
463
+ }
464
+ else if (recorded !== current) {
465
+ reasons.push(`input changed: ${path}`);
466
+ }
467
+ }
468
+ }
469
+ const digests = marker['digests'];
470
+ if (digests !== undefined &&
471
+ sha256(input.modelSource) !== digests['model.likec4']) {
472
+ reasons.push('model source changed');
473
+ }
474
+ if (reasons.length === 0) {
475
+ return {
476
+ exitCode: 0,
477
+ stdout: 'Generated LikeC4 output: fresh\n',
478
+ stderr: '',
479
+ };
480
+ }
481
+ return {
482
+ exitCode: 1,
483
+ stdout: 'Generated LikeC4 output: stale\n' +
484
+ 'Reason:\n' +
485
+ reasons.map((reason) => `- ${reason}\n`).join('') +
486
+ 'Safe to regenerate: yes\n',
487
+ stderr: '',
488
+ };
489
+ };
490
+ const inputDigestsOf = (inputs) => Object.fromEntries(inputs
491
+ .map(({ path, source }) => [path, sha256(source)])
492
+ .sort(([left], [right]) => left.localeCompare(right)));
400
493
  export function runLikeC4Cli(args, cwd = process.cwd()) {
401
494
  if (args[0] === '--version') {
402
495
  return versionResult('yarramate-likec4');
@@ -404,7 +497,10 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
404
497
  if (args[0] === 'map') {
405
498
  return runLikeC4MapSync(args.slice(1), cwd);
406
499
  }
407
- const [command, projectionPath, mappingPath, ...options] = args;
500
+ const checkFreshness = args[0] === 'export-project' && args.includes('--check');
501
+ const [command, projectionPath, mappingPath, ...options] = checkFreshness
502
+ ? args.filter((argument) => argument !== '--check')
503
+ : args;
408
504
  let projectDefinitionMode = false;
409
505
  if ((command === 'check' || command === 'export-project') &&
410
506
  projectionPath !== undefined) {
@@ -493,7 +589,7 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
493
589
  const diagnosticOutput = (diagnostics) => json
494
590
  ? checkJson(false, diagnostics)
495
591
  : diagnosticJson(command === 'check'
496
- ? summarizeUnmappedConcepts(diagnostics)
592
+ ? summarizeUnmappedSubjects(diagnostics)
497
593
  : diagnostics);
498
594
  try {
499
595
  const resolved = resolveCliWorkspaceSources(sourcePaths, cwd);
@@ -584,6 +680,7 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
584
680
  ? {}
585
681
  : { comparison: view.compare }),
586
682
  vocabulary: 'bundled',
683
+ requireMappedRelationships: command === 'check',
587
684
  }),
588
685
  }));
589
686
  const failed = preparedViews.find(({ prepared }) => !prepared.ok);
@@ -813,6 +910,17 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
813
910
  ? undefined
814
911
  : `${first.prepared.kindMapping.id}@${first.prepared.kindMapping.version}`;
815
912
  const projectIdentity = `${loadedProject.document.value.id}@${loadedProject.document.value.version}`;
913
+ const inputDigests = inputDigestsOf([
914
+ projectSource,
915
+ ...sources,
916
+ ...referencedSources.values(),
917
+ ]);
918
+ if (checkFreshness) {
919
+ return checkGeneratedProject(cwd, outputDirectory, {
920
+ modelSource: exported.source,
921
+ inputDigests,
922
+ });
923
+ }
816
924
  return publishGeneratedProject(cwd, outputDirectory, {
817
925
  projectName: `yarramate-${projectIdentity}`.replaceAll(/[^A-Za-z0-9_-]/g, '-'),
818
926
  title: loadedProject.document.value.title,
@@ -832,29 +940,33 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
832
940
  : { comparison }),
833
941
  })),
834
942
  },
835
- validateMarker: (value) => validateGeneratedProjectV2Marker(value),
943
+ inputDigests,
836
944
  });
837
945
  }
946
+ const projectionSource = {
947
+ path: projectionPath,
948
+ source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
949
+ };
950
+ const mappingSource = {
951
+ path: mappingPath,
952
+ source: readFileSync(resolve(cwd, mappingPath), 'utf8'),
953
+ };
954
+ const kindMappingSource = kindMappingPath === undefined
955
+ ? undefined
956
+ : {
957
+ path: kindMappingPath,
958
+ source: readFileSync(resolve(cwd, kindMappingPath), 'utf8'),
959
+ };
838
960
  const prepared = prepareLikeC4Export({
839
961
  sources,
840
- projection: {
841
- path: projectionPath,
842
- source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
843
- },
844
- subjectMapping: {
845
- path: mappingPath,
846
- source: readFileSync(resolve(cwd, mappingPath), 'utf8'),
847
- },
848
- ...(kindMappingPath === undefined
962
+ projection: projectionSource,
963
+ subjectMapping: mappingSource,
964
+ ...(kindMappingSource === undefined
849
965
  ? {}
850
- : {
851
- kindMapping: {
852
- path: kindMappingPath,
853
- source: readFileSync(resolve(cwd, kindMappingPath), 'utf8'),
854
- },
855
- }),
966
+ : { kindMapping: kindMappingSource }),
856
967
  ...(comparison === undefined ? {} : { comparison }),
857
968
  vocabulary: command === 'export' ? 'consumer' : 'bundled',
969
+ requireMappedRelationships: command === 'check',
858
970
  });
859
971
  if (!prepared.ok) {
860
972
  return {
@@ -881,6 +993,18 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
881
993
  ? undefined
882
994
  : `${prepared.kindMapping.id}@${prepared.kindMapping.version}`;
883
995
  const comparisonIdentity = comparison;
996
+ const inputDigests = inputDigestsOf([
997
+ projectionSource,
998
+ mappingSource,
999
+ ...(kindMappingSource === undefined ? [] : [kindMappingSource]),
1000
+ ...sources,
1001
+ ]);
1002
+ if (checkFreshness) {
1003
+ return checkGeneratedProject(cwd, outputDirectory, {
1004
+ modelSource: prepared.source,
1005
+ inputDigests,
1006
+ });
1007
+ }
884
1008
  return publishGeneratedProject(cwd, outputDirectory, {
885
1009
  projectName: ['yarramate', projectionIdentity]
886
1010
  .join('-')
@@ -899,7 +1023,7 @@ export function runLikeC4Cli(args, cwd = process.cwd()) {
899
1023
  ? {}
900
1024
  : { comparison: comparisonIdentity }),
901
1025
  },
902
- validateMarker: (value) => validateGeneratedProjectMarker(value),
1026
+ inputDigests,
903
1027
  });
904
1028
  }
905
1029
  catch (error) {
@@ -4,7 +4,7 @@ import type { ProjectionResult } from '../projection.js';
4
4
  import type { LikeC4KindMapping } from './likec4-kind-mapping.js';
5
5
  export interface LikeC4ExportDiagnostic {
6
6
  readonly severity: 'error';
7
- readonly code: 'YMLC101' | 'YMLC102' | 'YMLC103' | 'YMLC104' | 'YMLC105' | 'YMLC106' | 'YMLC107' | 'YMLC108' | 'YMLC109';
7
+ readonly code: 'YMLC101' | 'YMLC102' | 'YMLC103' | 'YMLC104' | 'YMLC105' | 'YMLC106' | 'YMLC107' | 'YMLC108' | 'YMLC109' | 'YMLC111';
8
8
  readonly message: string;
9
9
  readonly subject?: string;
10
10
  readonly path: string;
@@ -13,6 +13,12 @@ export interface LikeC4PreparationInput {
13
13
  readonly to: string;
14
14
  };
15
15
  readonly vocabulary: 'bundled' | 'consumer';
16
+ /**
17
+ * Gate mode. Rendering a relationship needs no external identity — views
18
+ * select it by metadata — but `map --sync` still writes one, so only a
19
+ * check answering "would sync change anything" requires the entry.
20
+ */
21
+ readonly requireMappedRelationships?: boolean;
16
22
  }
17
23
  export type LikeC4PreparationDiagnostic = Diagnostic | LikeC4ExportDiagnostic;
18
24
  export type LikeC4PreparationResult = {
@@ -70,6 +70,31 @@ const unsupportedBundledKinds = (projection, kindMapping) => {
70
70
  }
71
71
  return diagnostics.sort(diagnosticOrder);
72
72
  };
73
+ const unmappedProjectedRelationships = (projection, mapping) => {
74
+ const mapped = new Set(mapping.mappings
75
+ .filter(({ type }) => type === 'relationship')
76
+ .map(({ native }) => native));
77
+ return projection.subjects
78
+ .filter(({ type, id }) => type === 'relationship' && !mapped.has(id))
79
+ .flatMap(({ id }) => {
80
+ const source = projection.claims.find((claim) => claim.id === id)?.source;
81
+ return source === undefined
82
+ ? []
83
+ : [
84
+ {
85
+ severity: 'error',
86
+ code: 'YMLC111',
87
+ message: `Projected relationship "${id}" has no LikeC4 mapping`,
88
+ subject: id,
89
+ path: source.path,
90
+ pointer: source.pointer,
91
+ line: source.line,
92
+ column: source.column,
93
+ },
94
+ ];
95
+ })
96
+ .sort(diagnosticOrder);
97
+ };
73
98
  export function prepareLikeC4Export(input) {
74
99
  const compilation = compileWorkspaceWithProfileContext(input.sources);
75
100
  if (!compilation.ok)
@@ -137,11 +162,24 @@ export function prepareLikeC4Export(input) {
137
162
  if (diagnostics.length > 0)
138
163
  return { ok: false, diagnostics };
139
164
  }
165
+ const unmappedRelationships = input.requireMappedRelationships === true
166
+ ? unmappedProjectedRelationships(projectionResult, subjectMapping.mapping)
167
+ : [];
140
168
  const exported = exportLikeC4(projectionResult, subjectMapping.mapping, kindMapping?.mapping, comparison === undefined
141
169
  ? undefined
142
170
  : { comparison: comparison.comparison });
143
- if (!exported.ok)
144
- return exported;
171
+ if (!exported.ok) {
172
+ return {
173
+ ok: false,
174
+ diagnostics: [
175
+ ...exported.diagnostics,
176
+ ...unmappedRelationships,
177
+ ].sort(diagnosticOrder),
178
+ };
179
+ }
180
+ if (unmappedRelationships.length > 0) {
181
+ return { ok: false, diagnostics: unmappedRelationships };
182
+ }
145
183
  return {
146
184
  ok: true,
147
185
  source: exported.source,
@@ -8,10 +8,23 @@ import { compileWorkspace } from './compiler.js';
8
8
  import { checkCoreContract, loadCoreContract, } from './core-contract.js';
9
9
  import { evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
10
10
  import { loadProjection } from './projection.js';
11
+ import { reconcileEvidenceReports, } from './reconciliation.js';
11
12
  const Ajv2020 = Ajv2020Module.default;
13
+ const strictFindingMessage = (finding) => {
14
+ const observed = finding.evidence.message === undefined
15
+ ? finding.evidence.uri
16
+ : `${finding.evidence.uri}: ${finding.evidence.message}`;
17
+ const assertion = finding.asserted === undefined
18
+ ? `Evidence contradicts ${finding.target.type} "${finding.target.id}"`
19
+ : `Evidence contradicts claim "${finding.target.id}": the model asserts ` +
20
+ `${finding.asserted.from} -> ${finding.asserted.to} (${finding.asserted.kind})`;
21
+ return (`${assertion}, but provider "${finding.provider}" observed otherwise ` +
22
+ `(${observed}); align the model or the evidence to pass --strict`);
23
+ };
12
24
  export function runCheckCommand(options, cwd) {
13
25
  const json = options.includes('--json');
14
- const paths = options.filter((option) => option !== '--json');
26
+ const strict = options.includes('--strict');
27
+ const paths = options.filter((option) => option !== '--json' && option !== '--strict');
15
28
  const unknownOption = paths.find((path) => path.startsWith('-'));
16
29
  if (unknownOption !== undefined || paths.length === 0) {
17
30
  return { exitCode: 2, stdout: '', stderr: usage };
@@ -171,6 +184,46 @@ export function runCheckCommand(options, cwd) {
171
184
  const diagnostics = result.ok
172
185
  ? optionalDiagnostics
173
186
  : result.diagnostics;
187
+ // Strict only tightens a passing check: base diagnostics already fail
188
+ // the gate, so contradictions are folded in only once everything else
189
+ // holds, and each one is anchored at the claim the model declares.
190
+ const strictEvaluation = strict && result.ok && ok
191
+ ? (() => {
192
+ const reports = evidenceEvaluation !== undefined && evidenceEvaluation.ok
193
+ ? evidenceEvaluation.reports
194
+ : [];
195
+ const graph = result.graph;
196
+ const contradicted = reconcileEvidenceReports('strict', reports, graph).findings.filter(({ result: outcome }) => outcome === 'contradicted');
197
+ return {
198
+ observations: reports.reduce((total, report) => total + report.observations.length, 0),
199
+ diagnostics: sortDiagnostics(contradicted.map((finding) => {
200
+ const anchor = graph.claims.find(({ id }) => id === finding.target.id) ??
201
+ graph.claims.find(({ subject, predicate }) => subject === finding.target.id &&
202
+ predicate === 'yarramate/concept/kind') ??
203
+ graph.claims.find(({ subject }) => subject === finding.target.id);
204
+ return {
205
+ severity: 'error',
206
+ code: 'YM901',
207
+ message: strictFindingMessage(finding),
208
+ path: anchor?.source.path ?? finding.evidenceDocument,
209
+ pointer: anchor?.source.pointer ?? '/',
210
+ line: anchor?.source.line ?? 1,
211
+ column: anchor?.source.column ?? 1,
212
+ };
213
+ })),
214
+ };
215
+ })()
216
+ : undefined;
217
+ const strictSummary = strictEvaluation === undefined
218
+ ? undefined
219
+ : {
220
+ observations: strictEvaluation.observations,
221
+ contradicted: strictEvaluation.diagnostics.length,
222
+ };
223
+ const strictOk = strictEvaluation === undefined
224
+ ? true
225
+ : strictEvaluation.diagnostics.length === 0;
226
+ const finalOk = ok && strictOk;
174
227
  const counted = result.ok
175
228
  ? (() => {
176
229
  const states = new Set(result.graph.claims
@@ -186,8 +239,15 @@ export function runCheckCommand(options, cwd) {
186
239
  : undefined;
187
240
  if (json) {
188
241
  return {
189
- exitCode: ok ? 0 : 1,
190
- stdout: checkResultJson(ok, ok ? [] : diagnostics, ok ? counted : undefined),
242
+ exitCode: finalOk ? 0 : 1,
243
+ stdout: checkResultJson(finalOk, finalOk ? [] : ok ? strictEvaluation.diagnostics : diagnostics, finalOk ? counted : undefined, strictSummary),
244
+ stderr: '',
245
+ };
246
+ }
247
+ if (ok && !strictOk) {
248
+ return {
249
+ exitCode: 1,
250
+ stdout: humanDiagnostics(strictEvaluation.diagnostics),
191
251
  stderr: '',
192
252
  };
193
253
  }
@@ -227,13 +287,19 @@ export function runCheckCommand(options, cwd) {
227
287
  ]
228
288
  : []),
229
289
  ].join(' and ');
290
+ const strictLine = strictSummary === undefined
291
+ ? ''
292
+ : strictSummary.observations === 0
293
+ ? 'Strict: no evidence observations to evaluate\n'
294
+ : `Strict: ${strictSummary.observations} ${strictSummary.observations === 1 ? 'observation' : 'observations'}, 0 contradicted\n`;
230
295
  return {
231
296
  exitCode: 0,
232
297
  stdout: `Checked ${checked} (` +
233
298
  `${successfulCounts.concepts} ${successfulCounts.concepts === 1 ? 'concept' : 'concepts'}, ` +
234
299
  `${successfulCounts.relationships} ${successfulCounts.relationships === 1 ? 'relationship' : 'relationships'}, ` +
235
300
  `${successfulCounts.states} ${successfulCounts.states === 1 ? 'state' : 'states'}` +
236
- '): no errors\n',
301
+ '): no errors\n' +
302
+ strictLine,
237
303
  stderr: '',
238
304
  };
239
305
  }
@@ -7,13 +7,16 @@ export interface CliResult {
7
7
  export declare const isMainModule: (moduleUrl: string, entrypoint: string | undefined) => boolean;
8
8
  export declare const packageVersion: string;
9
9
  export declare const versionResult: (binary: string) => CliResult;
10
- export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\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 new projection <projection.yaml> --id <id> [--version <v>] [--title <text>] [--description <text>] [--document <id> ...] [--subject <ref> ...] [--kind <qualified-kind> ...] [--relationships <mode>]\n yarramate status <workspace.yaml> [--json]\n yarramate compile <source.yaml> [source.yaml ...]\n yarramate context <projection.yaml> <source.yaml> [source.yaml ...] [--budget <tokens>]\n yarramate context --subject <document-id>#<local-id> [--subject ...] <source.yaml> [source.yaml ...] [--budget <tokens>]\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";
10
+ export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\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] [--strict]\n yarramate new projection <projection.yaml> --id <id> [--version <v>] [--title <text>] [--description <text>] [--document <id> ...] [--subject <ref> ...] [--kind <qualified-kind> ...] [--relationships <mode>]\n yarramate status <workspace.yaml> [--json]\n yarramate next <projection.yaml> <workspace.yaml> [--json]\n yarramate compile <source.yaml> [source.yaml ...]\n yarramate context <projection.yaml> <source.yaml> [source.yaml ...] [--budget <tokens>]\n yarramate context --subject <document-id>#<local-id> [--subject ...] <source.yaml> [source.yaml ...] [--budget <tokens>]\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";
11
11
  export declare const diagnosticJson: (diagnostics: unknown) => string;
12
12
  export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counted?: {
13
13
  readonly documents: number;
14
14
  readonly concepts: number;
15
15
  readonly relationships: number;
16
16
  readonly states: number;
17
+ }, strict?: {
18
+ readonly observations: number;
19
+ readonly contradicted: number;
17
20
  }) => string;
18
21
  export declare const humanDiagnostics: (diagnostics: readonly Pick<Diagnostic, "path" | "line" | "column" | "code" | "message">[]) => string;
19
22
  export declare const sortDiagnostics: <T extends Diagnostic>(diagnostics: readonly T[]) => T[];
@@ -23,16 +23,17 @@ export const versionResult = (binary) => ({
23
23
  stdout: `${binary} ${packageVersion}\n`,
24
24
  stderr: '',
25
25
  });
26
- export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\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 new projection <projection.yaml> --id <id> [--version <v>] [--title <text>] [--description <text>] [--document <id> ...] [--subject <ref> ...] [--kind <qualified-kind> ...] [--relationships <mode>]\n yarramate status <workspace.yaml> [--json]\n yarramate compile <source.yaml> [source.yaml ...]\n yarramate context <projection.yaml> <source.yaml> [source.yaml ...] [--budget <tokens>]\n yarramate context --subject <document-id>#<local-id> [--subject ...] <source.yaml> [source.yaml ...] [--budget <tokens>]\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';
26
+ export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\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] [--strict]\n yarramate new projection <projection.yaml> --id <id> [--version <v>] [--title <text>] [--description <text>] [--document <id> ...] [--subject <ref> ...] [--kind <qualified-kind> ...] [--relationships <mode>]\n yarramate status <workspace.yaml> [--json]\n yarramate next <projection.yaml> <workspace.yaml> [--json]\n yarramate compile <source.yaml> [source.yaml ...]\n yarramate context <projection.yaml> <source.yaml> [source.yaml ...] [--budget <tokens>]\n yarramate context --subject <document-id>#<local-id> [--subject ...] <source.yaml> [source.yaml ...] [--budget <tokens>]\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';
27
27
  export const diagnosticJson = (diagnostics) => `${JSON.stringify({
28
28
  format: 'yarramate/diagnostic-result/v1',
29
29
  diagnostics,
30
30
  }, null, 2)}\n`;
31
- export const checkResultJson = (ok, diagnostics, counted) => `${JSON.stringify({
31
+ export const checkResultJson = (ok, diagnostics, counted, strict) => `${JSON.stringify({
32
32
  format: 'yarramate/check-result/v1',
33
33
  ok,
34
34
  diagnostics,
35
35
  ...(counted === undefined ? {} : { counted }),
36
+ ...(strict === undefined ? {} : { strict }),
36
37
  }, null, 2)}\n`;
37
38
  export const humanDiagnostics = (diagnostics) => diagnostics
38
39
  .map((diagnostic) => `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} error ${diagnostic.code} ${diagnostic.message}\n`)
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { serializeSemanticGraph } from './graph.js';
8
8
  import { diagnosticJson, humanDiagnostics, isMainModule, resolveCliWorkspaceSources, usage, versionResult, } from './cli-support.js';
9
9
  import { runCheckCommand } from './check-command.js';
10
10
  import { runNewCommand } from './new-command.js';
11
+ import { runNextCommand } from './next-command.js';
11
12
  import { runStatusCommand } from './status-command.js';
12
13
  import { evaluateEvidence, evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
13
14
  import { reconcileEvidenceReports } from './reconciliation.js';
@@ -757,6 +758,9 @@ export function runCli(args, cwd = process.cwd()) {
757
758
  if (command === 'new') {
758
759
  return runNewCommand(options, cwd);
759
760
  }
761
+ if (command === 'next') {
762
+ return runNextCommand(options, cwd);
763
+ }
760
764
  return { exitCode: 2, stdout: '', stderr: usage };
761
765
  }
762
766
  if (isMainModule(import.meta.url, process.argv[1])) {
package/dist/compiler.js CHANGED
@@ -14,6 +14,23 @@ const validateDocument = new Ajv2020({ allErrors: true }).compile(documentSchema
14
14
  const validateProfile = new Ajv2020({ allErrors: true }).compile(profileSchema);
15
15
  const compareById = (left, right) => left.id.localeCompare(right.id);
16
16
  const presenceClaimId = (subject, state) => `${subject}~present-in-${Buffer.from(state, 'utf8').toString('hex')}`;
17
+ const describeAspect = (aspect) => aspect.replace('-', ' ');
18
+ // Candidate order is the policy-matrix declaration order: the resolved kind
19
+ // map inserts core policies first, then extension kinds as declared.
20
+ const candidateKindHint = (kinds, rejected, sourceAspect, targetAspect) => {
21
+ const candidates = [...kinds]
22
+ .filter(([id, kind]) => id !== rejected &&
23
+ (kind.sourceAspects?.includes(sourceAspect) ?? true) &&
24
+ (kind.targetAspects?.includes(targetAspect) ?? true))
25
+ .map(([id]) => id);
26
+ if (candidates.length === 0) {
27
+ return '';
28
+ }
29
+ const observed = sourceAspect === targetAspect
30
+ ? `both endpoints are ${describeAspect(sourceAspect)}`
31
+ : `source is ${describeAspect(sourceAspect)} and target is ${describeAspect(targetAspect)}`;
32
+ return `; ${observed}; valid candidates: ${candidates.join(', ')}`;
33
+ };
17
34
  const diagnosticFailure = (diagnostics) => ({
18
35
  ok: false,
19
36
  diagnostics: [...diagnostics].sort((left, right) => left.path.localeCompare(right.path) ||
@@ -779,27 +796,35 @@ function compileWorkspaceResolved(sources) {
779
796
  }
780
797
  const policy = selectedProfile.relationshipKinds.get(relationship.kind);
781
798
  if (policy !== undefined) {
782
- for (const endpoint of ['source', 'target']) {
783
- const reference = endpoint === 'source' ? relationship.from : relationship.to;
799
+ const aspectOf = (reference) => {
784
800
  const resolvedConcept = conceptByQualifiedId.get(qualifyReference(value.id, reference));
785
- const kind = resolvedConcept === undefined
801
+ return resolvedConcept === undefined
786
802
  ? undefined
787
803
  : profiles
788
804
  .get(resolvedConcept.profile)
789
- ?.conceptKinds.get(resolvedConcept.concept.kind);
805
+ ?.conceptKinds.get(resolvedConcept.concept.kind)?.aspect;
806
+ };
807
+ const sourceAspect = aspectOf(relationship.from);
808
+ const targetAspect = aspectOf(relationship.to);
809
+ const candidates = sourceAspect === undefined || targetAspect === undefined
810
+ ? ''
811
+ : candidateKindHint(selectedProfile.relationshipKinds, relationship.kind, sourceAspect, targetAspect);
812
+ for (const endpoint of ['source', 'target']) {
813
+ const reference = endpoint === 'source' ? relationship.from : relationship.to;
814
+ const aspect = endpoint === 'source' ? sourceAspect : targetAspect;
790
815
  const allowed = endpoint === 'source'
791
816
  ? policy.sourceAspects
792
817
  : policy.targetAspects;
793
- if (kind !== undefined &&
818
+ if (aspect !== undefined &&
794
819
  allowed !== undefined &&
795
- !allowed.includes(kind.aspect)) {
820
+ !allowed.includes(aspect)) {
796
821
  const field = endpoint === 'source' ? 'from' : 'to';
797
822
  const pointer = `/relationships/${index}/${field}`;
798
823
  const source = location(['relationships', index, field], pointer);
799
824
  diagnostics.push({
800
825
  severity: 'error',
801
826
  code: 'YM404',
802
- message: `Relationship "${relationship.kind}" requires a ${endpoint} with aspect ${allowed.map((aspect) => `"${aspect}"`).join(' or ')}; "${reference}" has aspect "${kind.aspect}"${policy.repair === undefined ? '' : `; ${policy.repair}`}`,
827
+ message: `Relationship "${relationship.kind}" requires a ${endpoint} with aspect ${allowed.map((entry) => `"${entry}"`).join(' or ')}; "${reference}" has aspect "${aspect}"${policy.repair === undefined ? '' : `; ${policy.repair}`}${candidates}`,
803
828
  path: input.path,
804
829
  pointer,
805
830
  line: source.line,
@@ -0,0 +1,25 @@
1
+ import { type CliResult } from './cli-support.js';
2
+ interface EvidenceCoverage {
3
+ readonly observations: number;
4
+ readonly confirmed: number;
5
+ readonly contradicted: number;
6
+ readonly unknown: number;
7
+ readonly notObserved: number;
8
+ }
9
+ export interface NextSubject {
10
+ readonly id: string;
11
+ readonly kind: string;
12
+ readonly name?: string;
13
+ readonly dependsOn: readonly string[];
14
+ readonly requiredBy: readonly string[];
15
+ readonly evidence: EvidenceCoverage;
16
+ readonly cycle?: true;
17
+ }
18
+ export interface NextResult {
19
+ readonly format: 'yarramate/next-result/v1';
20
+ readonly workspace: string;
21
+ readonly projection: string;
22
+ readonly subjects: readonly NextSubject[];
23
+ }
24
+ export declare function runNextCommand(options: readonly string[], cwd: string): CliResult;
25
+ export {};
@@ -0,0 +1,272 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
5
+ import { compileWorkspaceWithProfileContext, } from './compiler.js';
6
+ import { evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
7
+ import { evaluateProjection, loadProjection } from './projection.js';
8
+ import { loadWorkspaceManifest } from './workspace.js';
9
+ // Which endpoint of a declared relationship must exist before the other,
10
+ // read off each core kind's declared intent (ADR 0048). Kinds without a
11
+ // build-order reading (association, assignment, influence) contribute no
12
+ // ordering edge rather than a guessed one.
13
+ const corePrerequisiteEndpoints = new Map([
14
+ ['realization', 'source'],
15
+ ['serving', 'source'],
16
+ ['triggering', 'source'],
17
+ ['flow', 'source'],
18
+ ['composition', 'target'],
19
+ ['aggregation', 'target'],
20
+ ['access', 'target'],
21
+ ['specialization', 'target'],
22
+ ]);
23
+ const prerequisiteEndpoint = (kind, profileContext) => {
24
+ const candidates = [
25
+ kind,
26
+ ...(profileContext?.relationshipKindLineages.get(kind) ?? []),
27
+ ];
28
+ for (const candidate of candidates) {
29
+ const separator = candidate.indexOf('#');
30
+ if (separator === -1 || !candidate.startsWith('yarramate/core@')) {
31
+ continue;
32
+ }
33
+ const orientation = corePrerequisiteEndpoints.get(candidate.slice(separator + 1));
34
+ if (orientation !== undefined)
35
+ return orientation;
36
+ }
37
+ return undefined;
38
+ };
39
+ const claimValue = (claims, subject, predicate) => {
40
+ const object = claims.find((claim) => claim.subject === subject && claim.predicate === predicate)?.object;
41
+ return object !== undefined && 'value' in object ? object.value : undefined;
42
+ };
43
+ const plural = (count, singular) => `${count} ${count === 1 ? singular : `${singular}s`}`;
44
+ const coverageClause = (coverage) => {
45
+ if (coverage.observations === 0)
46
+ return 'no evidence';
47
+ const parts = [
48
+ ...(coverage.confirmed > 0 ? [`${coverage.confirmed} confirmed`] : []),
49
+ ...(coverage.contradicted > 0
50
+ ? [`${coverage.contradicted} contradicted`]
51
+ : []),
52
+ ...(coverage.unknown > 0 ? [`${coverage.unknown} unknown`] : []),
53
+ ...(coverage.notObserved > 0
54
+ ? [`${coverage.notObserved} not observed`]
55
+ : []),
56
+ ];
57
+ return `${plural(coverage.observations, 'observation')} (${parts.join(', ')})`;
58
+ };
59
+ export function runNextCommand(options, cwd) {
60
+ const json = options.includes('--json');
61
+ const rest = options.filter((option) => option !== '--json');
62
+ const [projectionPath, workspacePath] = rest;
63
+ if (rest.length !== 2 ||
64
+ projectionPath === undefined ||
65
+ workspacePath === undefined ||
66
+ rest.some((option) => option.startsWith('-'))) {
67
+ return { exitCode: 2, stdout: '', stderr: usage };
68
+ }
69
+ try {
70
+ const manifestSource = readFileSync(resolve(cwd, workspacePath), 'utf8');
71
+ if (parseDocument(manifestSource).get('format') !== 'yarramate/workspace/v1') {
72
+ return {
73
+ exitCode: 2,
74
+ stdout: '',
75
+ stderr: 'next requires an explicit workspace manifest (yarramate/workspace/v1)\n',
76
+ };
77
+ }
78
+ const failed = (diagnostics) => ({
79
+ exitCode: 1,
80
+ stdout: json ? diagnosticJson(diagnostics) : humanDiagnostics(diagnostics),
81
+ stderr: '',
82
+ });
83
+ const loadedWorkspace = loadWorkspaceManifest({ path: workspacePath, source: manifestSource }, cwd);
84
+ if (!loadedWorkspace.ok)
85
+ return failed(loadedWorkspace.diagnostics);
86
+ const workspace = loadedWorkspace.workspace;
87
+ const loadedProjection = loadProjection({
88
+ path: projectionPath,
89
+ source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
90
+ });
91
+ if (!loadedProjection.ok)
92
+ return failed(loadedProjection.diagnostics);
93
+ const compilation = compileWorkspaceWithProfileContext([...workspace.profiles, ...workspace.documents].map((path) => ({
94
+ path,
95
+ source: readFileSync(resolve(cwd, path), 'utf8'),
96
+ })));
97
+ if (!compilation.ok)
98
+ return failed(compilation.diagnostics);
99
+ const graph = compilation.graph;
100
+ const evidenceDocuments = [];
101
+ for (const path of workspace.evidence) {
102
+ const loaded = loadEvidence({
103
+ path,
104
+ source: readFileSync(resolve(cwd, path), 'utf8'),
105
+ });
106
+ if (!loaded.ok)
107
+ return failed(loaded.diagnostics);
108
+ evidenceDocuments.push(loaded.evidence);
109
+ }
110
+ const evaluation = evaluateEvidenceWorkspace(graph, evidenceDocuments);
111
+ if (!evaluation.ok)
112
+ return failed(evaluation.diagnostics);
113
+ const result = evaluateProjection(graph, loadedProjection.projection, compilation.profileContext);
114
+ const planned = result.subjects
115
+ .filter(({ id, type }) => type === 'concept' &&
116
+ claimValue(result.claims, id, 'yarramate/lifecycle/status') ===
117
+ 'planned')
118
+ .map(({ id }) => id);
119
+ const plannedIds = new Set(planned);
120
+ const dependsOn = new Map();
121
+ const requiredBy = new Map();
122
+ for (const subject of result.subjects) {
123
+ if (subject.type !== 'relationship')
124
+ continue;
125
+ const claim = result.claims.find(({ id, object }) => id === subject.id && 'ref' in object);
126
+ if (claim === undefined || !('ref' in claim.object))
127
+ continue;
128
+ const orientation = prerequisiteEndpoint(claim.predicate, compilation.profileContext);
129
+ if (orientation === undefined)
130
+ continue;
131
+ const prerequisite = orientation === 'source' ? claim.subject : claim.object.ref;
132
+ const dependent = orientation === 'source' ? claim.object.ref : claim.subject;
133
+ if (prerequisite === dependent ||
134
+ !plannedIds.has(prerequisite) ||
135
+ !plannedIds.has(dependent)) {
136
+ continue;
137
+ }
138
+ dependsOn.set(dependent, (dependsOn.get(dependent) ?? new Set()).add(prerequisite));
139
+ requiredBy.set(prerequisite, (requiredBy.get(prerequisite) ?? new Set()).add(dependent));
140
+ }
141
+ // Deterministic Kahn ordering: everything whose prerequisites are
142
+ // already emitted goes next, lexicographic within a round; a cycle
143
+ // cannot be ordered, so its members are appended sorted and marked.
144
+ const ordered = [];
145
+ const cycles = new Set();
146
+ const emitted = new Set();
147
+ const remaining = new Set([...planned].sort());
148
+ while (remaining.size > 0) {
149
+ const ready = [...remaining]
150
+ .filter((id) => [...(dependsOn.get(id) ?? [])].every((dependency) => emitted.has(dependency)))
151
+ .sort();
152
+ if (ready.length === 0) {
153
+ for (const id of [...remaining].sort()) {
154
+ ordered.push(id);
155
+ cycles.add(id);
156
+ }
157
+ break;
158
+ }
159
+ for (const id of ready) {
160
+ ordered.push(id);
161
+ emitted.add(id);
162
+ remaining.delete(id);
163
+ }
164
+ }
165
+ const relationshipIds = new Set(graph.subjects
166
+ .filter(({ type }) => type === 'relationship')
167
+ .map(({ id }) => id));
168
+ const relationshipEndpoints = new Map();
169
+ const claimOwners = new Map();
170
+ for (const claim of graph.claims) {
171
+ claimOwners.set(claim.id, claim.subject);
172
+ if (relationshipIds.has(claim.id) && 'ref' in claim.object) {
173
+ relationshipEndpoints.set(claim.id, [claim.subject, claim.object.ref]);
174
+ }
175
+ }
176
+ const coverageTargets = (observation) => {
177
+ const target = 'subject' in observation ? observation.subject : observation.claim;
178
+ const endpoints = relationshipEndpoints.get(target);
179
+ if (endpoints !== undefined)
180
+ return endpoints;
181
+ if ('claim' in observation) {
182
+ const owner = claimOwners.get(target);
183
+ if (owner === undefined)
184
+ return [];
185
+ return relationshipEndpoints.get(owner) ?? [owner];
186
+ }
187
+ return [target];
188
+ };
189
+ const coverage = new Map();
190
+ for (const id of planned) {
191
+ coverage.set(id, {
192
+ observations: 0,
193
+ confirmed: 0,
194
+ contradicted: 0,
195
+ unknown: 0,
196
+ notObserved: 0,
197
+ });
198
+ }
199
+ for (const report of evaluation.reports) {
200
+ for (const observation of report.observations) {
201
+ for (const target of coverageTargets(observation)) {
202
+ const tally = coverage.get(target);
203
+ if (tally === undefined)
204
+ continue;
205
+ tally.observations += 1;
206
+ if (observation.result === 'not-observed') {
207
+ tally.notObserved += 1;
208
+ }
209
+ else {
210
+ tally[observation.result] += 1;
211
+ }
212
+ }
213
+ }
214
+ }
215
+ const subjects = ordered.map((id) => {
216
+ const name = claimValue(result.claims, id, 'yarramate/concept/name');
217
+ return {
218
+ id,
219
+ kind: claimValue(result.claims, id, 'yarramate/concept/kind') ?? 'unknown',
220
+ ...(name === undefined ? {} : { name }),
221
+ dependsOn: [...(dependsOn.get(id) ?? [])].sort(),
222
+ requiredBy: [...(requiredBy.get(id) ?? [])].sort(),
223
+ evidence: coverage.get(id),
224
+ ...(cycles.has(id) ? { cycle: true } : {}),
225
+ };
226
+ });
227
+ const projectionLabel = `${loadedProjection.projection.id}@${loadedProjection.projection.version}`;
228
+ if (json) {
229
+ const payload = {
230
+ format: 'yarramate/next-result/v1',
231
+ workspace: workspace.id,
232
+ projection: projectionLabel,
233
+ subjects,
234
+ };
235
+ return {
236
+ exitCode: 0,
237
+ stdout: `${JSON.stringify(payload, null, 2)}\n`,
238
+ stderr: '',
239
+ };
240
+ }
241
+ if (subjects.length === 0) {
242
+ return {
243
+ exitCode: 0,
244
+ stdout: `No planned subjects in projection ${projectionLabel}.\n`,
245
+ stderr: '',
246
+ };
247
+ }
248
+ const width = Math.max(...subjects.map(({ id }) => id.length));
249
+ const lines = [
250
+ `Planned subjects in projection ${projectionLabel} (dependency order):`,
251
+ ...subjects.map((subject) => {
252
+ const clauses = [
253
+ ...(subject.requiredBy.length > 0
254
+ ? [`<- required by ${subject.requiredBy.join(', ')}`]
255
+ : []),
256
+ coverageClause(subject.evidence),
257
+ ...(subject.cycle === true ? ['dependency cycle'] : []),
258
+ ];
259
+ return ` ${subject.id.padEnd(width)} ${clauses.join('; ')}`;
260
+ }),
261
+ ];
262
+ return {
263
+ exitCode: 0,
264
+ stdout: `${lines.join('\n')}\n`,
265
+ stderr: '',
266
+ };
267
+ }
268
+ catch (error) {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ return { exitCode: 2, stdout: '', stderr: `${message}\n` };
271
+ }
272
+ }
@@ -28,7 +28,9 @@ export interface ReconciliationReport {
28
28
  readonly contradicted: number;
29
29
  readonly unknown: number;
30
30
  readonly notObserved: number;
31
+ readonly subjectsWithoutEvidence: number;
31
32
  };
32
33
  readonly findings: readonly ReconciliationFinding[];
34
+ readonly unobservedSubjects?: readonly string[];
33
35
  }
34
36
  export declare function reconcileEvidenceReports(workspace: string, reports: readonly EvidenceReport[], graph?: SemanticGraph): ReconciliationReport;
@@ -25,8 +25,44 @@ const assertedRelationshipsByClaim = (graph) => {
25
25
  }
26
26
  return asserted;
27
27
  };
28
+ const unobservedCurrentConcepts = (graph, reports) => {
29
+ if (graph === undefined)
30
+ return [];
31
+ const relationshipIds = new Set(graph.subjects
32
+ .filter(({ type }) => type === 'relationship')
33
+ .map(({ id }) => id));
34
+ const claimsById = new Map(graph.claims.map((claim) => [claim.id, claim]));
35
+ const observed = new Set();
36
+ for (const report of reports) {
37
+ for (const observation of report.observations) {
38
+ if ('subject' in observation) {
39
+ observed.add(observation.subject);
40
+ continue;
41
+ }
42
+ const claim = claimsById.get(observation.claim);
43
+ if (claim === undefined)
44
+ continue;
45
+ observed.add(claim.subject);
46
+ if (relationshipIds.has(claim.id) && 'ref' in claim.object) {
47
+ observed.add(claim.object.ref);
48
+ }
49
+ }
50
+ }
51
+ const conceptIds = new Set(graph.subjects
52
+ .filter(({ type }) => type === 'concept')
53
+ .map(({ id }) => id));
54
+ return graph.claims
55
+ .filter((claim) => claim.predicate === 'yarramate/lifecycle/status' &&
56
+ 'value' in claim.object &&
57
+ claim.object.value === 'current' &&
58
+ conceptIds.has(claim.subject) &&
59
+ !observed.has(claim.subject))
60
+ .map(({ subject }) => subject)
61
+ .sort((left, right) => left.localeCompare(right));
62
+ };
28
63
  export function reconcileEvidenceReports(workspace, reports, graph) {
29
64
  const assertedByClaim = assertedRelationshipsByClaim(graph);
65
+ const unobservedSubjects = unobservedCurrentConcepts(graph, reports);
30
66
  const summary = {
31
67
  evidenceDocuments: reports.length,
32
68
  observations: 0,
@@ -35,6 +71,7 @@ export function reconcileEvidenceReports(workspace, reports, graph) {
35
71
  contradicted: 0,
36
72
  unknown: 0,
37
73
  notObserved: 0,
74
+ subjectsWithoutEvidence: unobservedSubjects.length,
38
75
  };
39
76
  const findings = [];
40
77
  for (const report of reports) {
@@ -74,5 +111,6 @@ export function reconcileEvidenceReports(workspace, reports, graph) {
74
111
  workspace,
75
112
  summary,
76
113
  findings,
114
+ ...(unobservedSubjects.length === 0 ? {} : { unobservedSubjects }),
77
115
  };
78
116
  }
@@ -145,6 +145,9 @@ export function runStatusCommand(options, cwd) {
145
145
  ? ` (${result.reconciliation.contradicted} contradicted, ` +
146
146
  `${result.reconciliation.unknown} unknown, ` +
147
147
  `${result.reconciliation.notObserved} not observed)`
148
+ : '') +
149
+ (result.reconciliation.subjectsWithoutEvidence > 0
150
+ ? `, ${plural(result.reconciliation.subjectsWithoutEvidence, 'current subject')} without evidence`
148
151
  : ''));
149
152
  }
150
153
  lines.push(`Documents: ${documents.map(({ id }) => id).join(', ') || 'none'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,6 +59,7 @@
59
59
  "./schema/likec4-check-result": "./schema/yarramate-likec4-check-result.schema.json",
60
60
  "./schema/state-comparison": "./schema/yarramate-state-comparison.schema.json",
61
61
  "./schema/status-result": "./schema/yarramate-status-result.schema.json",
62
+ "./schema/next-result": "./schema/yarramate-next-result.schema.json",
62
63
  "./schema/graph-v2": "./schema/yarramate-graph-v2.schema.json",
63
64
  "./schema/workspace": "./schema/yarramate-workspace.schema.json",
64
65
  "./schema/evidence": "./schema/yarramate-evidence.schema.json",
@@ -20,6 +20,9 @@
20
20
  },
21
21
  "counted": {
22
22
  "$ref": "#/$defs/counted"
23
+ },
24
+ "strict": {
25
+ "$ref": "#/$defs/strict"
23
26
  }
24
27
  },
25
28
  "allOf": [
@@ -50,6 +53,15 @@
50
53
  }
51
54
  ],
52
55
  "$defs": {
56
+ "strict": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["observations", "contradicted"],
60
+ "properties": {
61
+ "observations": { "type": "integer", "minimum": 0 },
62
+ "contradicted": { "type": "integer", "minimum": 0 }
63
+ }
64
+ },
53
65
  "counted": {
54
66
  "type": "object",
55
67
  "additionalProperties": false,
@@ -70,6 +70,7 @@
70
70
  "add",
71
71
  "connect",
72
72
  "new",
73
+ "next",
73
74
  "check",
74
75
  "status",
75
76
  "compile",
@@ -45,6 +45,12 @@
45
45
  }
46
46
  }
47
47
  },
48
+ "inputDigests": {
49
+ "type": "object",
50
+ "additionalProperties": {
51
+ "$ref": "#/$defs/sha256"
52
+ }
53
+ },
48
54
  "files": {
49
55
  "const": [
50
56
  "likec4.config.json",
@@ -51,6 +51,12 @@
51
51
  }
52
52
  }
53
53
  },
54
+ "inputDigests": {
55
+ "type": "object",
56
+ "additionalProperties": {
57
+ "$ref": "#/$defs/sha256"
58
+ }
59
+ },
54
60
  "files": {
55
61
  "const": [
56
62
  "likec4.config.json",
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://yarramate.org/schema/next-result/v1",
4
+ "title": "YarraMate next result",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["format", "workspace", "projection", "subjects"],
8
+ "properties": {
9
+ "format": {
10
+ "const": "yarramate/next-result/v1"
11
+ },
12
+ "workspace": {
13
+ "type": "string",
14
+ "minLength": 1
15
+ },
16
+ "projection": {
17
+ "type": "string",
18
+ "minLength": 1
19
+ },
20
+ "subjects": {
21
+ "type": "array",
22
+ "items": {
23
+ "$ref": "#/$defs/subject"
24
+ }
25
+ }
26
+ },
27
+ "$defs": {
28
+ "subject": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": ["id", "kind", "dependsOn", "requiredBy", "evidence"],
32
+ "properties": {
33
+ "id": {
34
+ "type": "string",
35
+ "minLength": 1
36
+ },
37
+ "kind": {
38
+ "type": "string",
39
+ "minLength": 1
40
+ },
41
+ "name": {
42
+ "type": "string"
43
+ },
44
+ "dependsOn": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "string",
48
+ "minLength": 1
49
+ }
50
+ },
51
+ "requiredBy": {
52
+ "type": "array",
53
+ "items": {
54
+ "type": "string",
55
+ "minLength": 1
56
+ }
57
+ },
58
+ "evidence": {
59
+ "$ref": "#/$defs/coverage"
60
+ },
61
+ "cycle": {
62
+ "const": true
63
+ }
64
+ }
65
+ },
66
+ "coverage": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "required": [
70
+ "observations",
71
+ "confirmed",
72
+ "contradicted",
73
+ "unknown",
74
+ "notObserved"
75
+ ],
76
+ "properties": {
77
+ "observations": { "type": "integer", "minimum": 0 },
78
+ "confirmed": { "type": "integer", "minimum": 0 },
79
+ "contradicted": { "type": "integer", "minimum": 0 },
80
+ "unknown": { "type": "integer", "minimum": 0 },
81
+ "notObserved": { "type": "integer", "minimum": 0 }
82
+ }
83
+ }
84
+ }
85
+ }
@@ -18,7 +18,8 @@
18
18
  "findings",
19
19
  "contradicted",
20
20
  "unknown",
21
- "notObserved"
21
+ "notObserved",
22
+ "subjectsWithoutEvidence"
22
23
  ],
23
24
  "properties": {
24
25
  "evidenceDocuments": { "type": "integer", "minimum": 0 },
@@ -27,12 +28,17 @@
27
28
  "findings": { "type": "integer", "minimum": 0 },
28
29
  "contradicted": { "type": "integer", "minimum": 0 },
29
30
  "unknown": { "type": "integer", "minimum": 0 },
30
- "notObserved": { "type": "integer", "minimum": 0 }
31
+ "notObserved": { "type": "integer", "minimum": 0 },
32
+ "subjectsWithoutEvidence": { "type": "integer", "minimum": 0 }
31
33
  }
32
34
  },
33
35
  "findings": {
34
36
  "type": "array",
35
37
  "items": { "$ref": "#/$defs/finding" }
38
+ },
39
+ "unobservedSubjects": {
40
+ "type": "array",
41
+ "items": { "$ref": "#/$defs/subjectIdentity" }
36
42
  }
37
43
  },
38
44
  "$defs": {
@@ -79,7 +79,8 @@
79
79
  "findings",
80
80
  "contradicted",
81
81
  "unknown",
82
- "notObserved"
82
+ "notObserved",
83
+ "subjectsWithoutEvidence"
83
84
  ],
84
85
  "properties": {
85
86
  "evidenceDocuments": {
@@ -109,6 +110,10 @@
109
110
  "notObserved": {
110
111
  "type": "integer",
111
112
  "minimum": 0
113
+ },
114
+ "subjectsWithoutEvidence": {
115
+ "type": "integer",
116
+ "minimum": 0
112
117
  }
113
118
  }
114
119
  },