yarramate 1.4.1 → 1.5.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.
@@ -501,8 +501,9 @@ export const startVisualServer = async (options) => {
501
501
  * session instead of serving a stale graph.
502
502
  */
503
503
  const workspaceSources = () => {
504
+ let reading = "";
504
505
  try {
505
- return [
506
+ const paths = [
506
507
  ...resolvedWorkspace.profiles,
507
508
  // Patterns are compiler input like profiles (#268). Without them a
508
509
  // session recompiles a workspace the manifest does not describe: an
@@ -512,18 +513,46 @@ export const startVisualServer = async (options) => {
512
513
  // rather than as a compile that failed.
513
514
  ...resolvedWorkspace.patterns,
514
515
  ...resolvedWorkspace.documents,
515
- ].map((path) => ({
516
- path,
517
- source: readFileSync(resolve(options.cwd, path), "utf8"),
518
- }));
516
+ ];
517
+ return {
518
+ ok: true,
519
+ sources: paths.map((path) => {
520
+ reading = path;
521
+ return {
522
+ path,
523
+ source: readFileSync(resolve(options.cwd, path), "utf8"),
524
+ };
525
+ }),
526
+ };
519
527
  }
520
- catch {
521
- // A source that cannot be read is one whose diagnostics belong to no
522
- // subject anyway, so an empty list is the right answer rather than a
523
- // throw from a path that is only trying to add detail.
524
- return [];
528
+ catch (cause) {
529
+ // Never an empty list. Compiling one SUCCEEDS - `compileWorkspaceResolved`
530
+ // over no sources returns an empty graph, not a failure - so swallowing
531
+ // the read error here made an unreadable workspace indistinguishable
532
+ // from an empty one, and the session then served "your model has nothing
533
+ // in it" as though it were an answer (#349). The older comment defended
534
+ // the empty list as belonging to a path "only trying to add detail";
535
+ // this feeds the single compile that produces what the browser draws.
536
+ return {
537
+ ok: false,
538
+ path: reading,
539
+ reason: cause instanceof Error ? cause.message : String(cause),
540
+ };
525
541
  }
526
542
  };
543
+ /**
544
+ * The sources, for attaching subjects to a diagnostic that already exists.
545
+ *
546
+ * Here an unreadable source really is nothing worth reporting: the caller is
547
+ * decorating a refusal it has already decided on, so a missing file costs a
548
+ * subject marker and nothing else. That was the whole of the old
549
+ * `workspaceSources` contract, and it stayed correct for exactly these two
550
+ * callers while being wrong for the compile (#349).
551
+ */
552
+ const sourcesForDetail = () => {
553
+ const read = workspaceSources();
554
+ return read.ok ? read.sources : [];
555
+ };
527
556
  /**
528
557
  * The digest of every projection the session knows about, read now.
529
558
  *
@@ -549,11 +578,22 @@ export const startVisualServer = async (options) => {
549
578
  };
550
579
  const recompileWorkspace = () => {
551
580
  try {
552
- const sources = workspaceSources();
581
+ const read = workspaceSources();
582
+ if (!read.ok) {
583
+ return {
584
+ ok: false,
585
+ diagnostics: [
586
+ serverDiagnostic("YMVS319", `Workspace source ${read.path} cannot be read, so the workspace was not recompiled; the last good model stays as it is: ${read.reason}`),
587
+ ],
588
+ };
589
+ }
590
+ const sources = read.sources;
553
591
  const compiled = compileWorkspaceWithProfileContext(sources);
554
592
  if (!compiled.ok) {
555
- compiledWorkspace = undefined;
556
- return false;
593
+ return {
594
+ ok: false,
595
+ diagnostics: published(compiled.diagnostics, sources),
596
+ };
557
597
  }
558
598
  compiledWorkspace = {
559
599
  graph: compiled.graph,
@@ -577,14 +617,36 @@ export const startVisualServer = async (options) => {
577
617
  // replacing the identity the session started with.
578
618
  views.splice(0, views.length, ...workspaceModel.views);
579
619
  rendered = workspaceModel.model;
580
- return true;
620
+ // Recovered: a browser connecting now is not handed a fault that has
621
+ // been fixed since. The connected ones clear it on the `model` frame
622
+ // this success is about to broadcast.
623
+ standingDiagnostics = [];
624
+ return { ok: true };
581
625
  }
582
- catch {
583
- compiledWorkspace = undefined;
584
- return false;
626
+ catch (cause) {
627
+ return {
628
+ ok: false,
629
+ diagnostics: [
630
+ serverDiagnostic("YMVS319", `Workspace could not be recompiled, so the last good model stays as it is: ${cause instanceof Error ? cause.message : String(cause)}`),
631
+ ],
632
+ };
585
633
  }
586
634
  };
587
- recompileWorkspace();
635
+ /**
636
+ * What the last recompile could not do, held for a browser that is not
637
+ * connected yet.
638
+ *
639
+ * The startup recompile runs before any socket exists, so a failure there
640
+ * has nobody to tell. Holding it means the first browser to arrive is told
641
+ * what every later one would have heard, rather than opening onto a session
642
+ * that looks well and answers nothing.
643
+ */
644
+ let standingDiagnostics = [];
645
+ {
646
+ const started = recompileWorkspace();
647
+ if (!started.ok)
648
+ standingDiagnostics = started.diagnostics;
649
+ }
588
650
  const filterMatchedIds = (query) => compiledWorkspace === undefined
589
651
  ? []
590
652
  : matchedIdsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext);
@@ -851,6 +913,36 @@ export const startVisualServer = async (options) => {
851
913
  for (const socket of connections.values())
852
914
  sendFrame(socket, frame);
853
915
  };
916
+ /**
917
+ * Tells the browser the workspace no longer compiles, and what said so.
918
+ *
919
+ * Journalled as an ordinary `diagnostic` response rather than a new frame
920
+ * kind, because that is the one the browser already renders - `Faults`
921
+ * reads `state.diagnostics`, which a `model` frame clears, so a recovered
922
+ * recompile clears the banner without anyone clearing it (#349). Held in
923
+ * `standingDiagnostics` too, for the browser that has not connected yet.
924
+ */
925
+ const reportRecompileFailure = async (diagnostics, eventId = drawHex(16)) => {
926
+ standingDiagnostics = diagnostics;
927
+ const response = {
928
+ format: "yarramate/visual-response/v1",
929
+ sessionId,
930
+ responseId: drawHex(16),
931
+ eventId,
932
+ type: "diagnostic",
933
+ timestamp: stamp(),
934
+ payload: { diagnostics },
935
+ };
936
+ const appended = await appendVisualResponse(paths, response);
937
+ if (appended.ok) {
938
+ transcriptBytes = appended.transcriptBytes;
939
+ recordResponse(response);
940
+ broadcast({ kind: "response", response });
941
+ }
942
+ else if (appended.freeze !== undefined) {
943
+ freeze(appended.freeze);
944
+ }
945
+ };
854
946
  const idleDelivery = () => ({
855
947
  waiting: true,
856
948
  lastSequence,
@@ -1172,8 +1264,17 @@ export const startVisualServer = async (options) => {
1172
1264
  kind: "apply-result",
1173
1265
  result: { ok: false, diagnostics: refused },
1174
1266
  });
1175
- if (recompileWorkspace())
1267
+ const refreshed = recompileWorkspace();
1268
+ if (refreshed.ok) {
1176
1269
  broadcast({ kind: "model", model: rendered, views });
1270
+ }
1271
+ else {
1272
+ // Nothing at all reached the browser here before (#349): a refused
1273
+ // apply whose refresh also failed left the reviewer with a
1274
+ // refusal about their rows and no word that the workspace itself
1275
+ // had stopped compiling.
1276
+ await reportRecompileFailure(refreshed.diagnostics);
1277
+ }
1177
1278
  return;
1178
1279
  }
1179
1280
  const operationsSource = stringify({
@@ -1193,7 +1294,7 @@ export const startVisualServer = async (options) => {
1193
1294
  kind: "apply-result",
1194
1295
  result: {
1195
1296
  ok: false,
1196
- diagnostics: published(loadedWorkspace.diagnostics, workspaceSources()),
1297
+ diagnostics: published(loadedWorkspace.diagnostics, sourcesForDetail()),
1197
1298
  },
1198
1299
  });
1199
1300
  return;
@@ -1232,7 +1333,7 @@ export const startVisualServer = async (options) => {
1232
1333
  kind: "apply-result",
1233
1334
  result: {
1234
1335
  ok: false,
1235
- diagnostics: published(outcome.diagnostics, workspaceSources()),
1336
+ diagnostics: published(outcome.diagnostics, sourcesForDetail()),
1236
1337
  },
1237
1338
  });
1238
1339
  return;
@@ -1249,7 +1350,8 @@ export const startVisualServer = async (options) => {
1249
1350
  kind: "apply-result",
1250
1351
  result: { ok: true, result: outcome.result },
1251
1352
  });
1252
- if (recompileWorkspace()) {
1353
+ const recompiled = recompileWorkspace();
1354
+ if (recompiled.ok) {
1253
1355
  broadcast({ kind: "model", model: rendered, views });
1254
1356
  return;
1255
1357
  }
@@ -1257,29 +1359,13 @@ export const startVisualServer = async (options) => {
1257
1359
  // landed but produced a document Core itself can no longer parse.
1258
1360
  // The freeze alone only tells the browser input is refused, not why
1259
1361
  // the graph on screen has gone stale, so a diagnostic response is
1260
- // journaled alongside it.
1261
- const diagnosticResponse = {
1262
- format: "yarramate/visual-response/v1",
1263
- sessionId,
1264
- responseId: drawHex(16),
1265
- eventId: event.eventId,
1266
- type: "diagnostic",
1267
- timestamp: stamp(),
1268
- payload: {
1269
- diagnostics: [
1270
- serverDiagnostic("YMVS310", "Workspace failed to recompile after a landed changeset"),
1271
- ],
1272
- },
1273
- };
1274
- const appendedResponse = await appendVisualResponse(paths, diagnosticResponse);
1275
- if (appendedResponse.ok) {
1276
- transcriptBytes = appendedResponse.transcriptBytes;
1277
- recordResponse(diagnosticResponse);
1278
- broadcast({ kind: "response", response: diagnosticResponse });
1279
- }
1280
- else if (appendedResponse.freeze !== undefined) {
1281
- freeze(appendedResponse.freeze);
1282
- }
1362
+ // journaled alongside it - and it carries the compiler's OWN
1363
+ // diagnostics, because YMVS310 by itself names no document, no code
1364
+ // and no line (#349).
1365
+ await reportRecompileFailure([
1366
+ serverDiagnostic("YMVS310", "Workspace failed to recompile after a landed changeset"),
1367
+ ...recompiled.diagnostics,
1368
+ ], event.eventId);
1283
1369
  freeze("recompile-failed");
1284
1370
  return;
1285
1371
  }
@@ -1431,6 +1517,22 @@ export const startVisualServer = async (options) => {
1431
1517
  return;
1432
1518
  }
1433
1519
  sendFrame(socket, { kind: "ready", snapshot: snapshot() });
1520
+ // A recompile that failed before this socket existed - the one at
1521
+ // startup, most often - has had nobody to tell until now (#349).
1522
+ if (standingDiagnostics.length > 0) {
1523
+ sendFrame(socket, {
1524
+ kind: "response",
1525
+ response: {
1526
+ format: "yarramate/visual-response/v1",
1527
+ sessionId,
1528
+ responseId: drawHex(16),
1529
+ eventId: drawHex(16),
1530
+ type: "diagnostic",
1531
+ timestamp: stamp(),
1532
+ payload: { diagnostics: standingDiagnostics },
1533
+ },
1534
+ });
1535
+ }
1434
1536
  });
1435
1537
  };
1436
1538
  // ------------------------------------------------------------- agent routes
@@ -471,7 +471,7 @@ export function runAskCommand(options, cwd) {
471
471
  const loadedCatalogue = loadQuestionCatalogue({
472
472
  path: shippedCataloguePath,
473
473
  source: readFileSync(shippedCataloguePath, 'utf8'),
474
- });
474
+ }, compilation.profileContext);
475
475
  if (!loadedCatalogue.ok)
476
476
  return failed(loadedCatalogue.diagnostics);
477
477
  const report = evaluateCatalogue(loadedCatalogue.catalogue, compilation.graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations));
@@ -701,7 +701,7 @@ export function runAskCommand(options, cwd) {
701
701
  const loadedCatalogue = loadQuestionCatalogue({
702
702
  path: cataloguePath ?? resolvedCataloguePath,
703
703
  source: readFileSync(resolvedCataloguePath, 'utf8'),
704
- });
704
+ }, compilation.profileContext);
705
705
  if (!loadedCatalogue.ok)
706
706
  return failed(loadedCatalogue.diagnostics);
707
707
  // The evidence overlay rides along for the one condition that
@@ -996,7 +996,7 @@ export function runAskCommand(options, cwd) {
996
996
  const loadedCatalogue = loadQuestionCatalogue({
997
997
  path: cataloguePath ?? resolvedCataloguePath,
998
998
  source: readFileSync(resolvedCataloguePath, 'utf8'),
999
- });
999
+ }, compilation.profileContext);
1000
1000
  if (!loadedCatalogue.ok)
1001
1001
  return failed(loadedCatalogue.diagnostics);
1002
1002
  // Loaded ahead of evaluation so the overlay feeds the one condition
@@ -191,12 +191,10 @@ export function runDesignCommand(options, cwd) {
191
191
  const resolvedCataloguePath = cataloguePath === undefined
192
192
  ? shippedCataloguePath
193
193
  : resolve(cwd, cataloguePath);
194
- const loadedCatalogue = loadQuestionCatalogue({
195
- path: cataloguePath ?? resolvedCataloguePath,
196
- source: readFileSync(resolvedCataloguePath, 'utf8'),
197
- });
198
- if (!loadedCatalogue.ok)
199
- return failed(loadedCatalogue.diagnostics);
194
+ // Compiled BEFORE the catalogue loads, so the catalogue can be checked
195
+ // against the vocabulary its kinds are written against (#351). A
196
+ // catalogue naming a kind its own profile does not have loads clean
197
+ // otherwise, and the question it names is dead on arrival.
200
198
  const compilation = compileWorkspaceWithProfileContext([
201
199
  ...workspace.profiles,
202
200
  ...workspace.patterns,
@@ -207,6 +205,12 @@ export function runDesignCommand(options, cwd) {
207
205
  })));
208
206
  if (!compilation.ok)
209
207
  return failed(compilation.diagnostics);
208
+ const loadedCatalogue = loadQuestionCatalogue({
209
+ path: cataloguePath ?? resolvedCataloguePath,
210
+ source: readFileSync(resolvedCataloguePath, 'utf8'),
211
+ }, compilation.profileContext);
212
+ if (!loadedCatalogue.ok)
213
+ return failed(loadedCatalogue.diagnostics);
210
214
  // The evidence overlay rides along for the one condition that reads
211
215
  // it (unchallenged-evidence). A workspace declaring no evidence
212
216
  // passes an empty overlay — known to be empty, which keeps that
@@ -203,5 +203,5 @@ export type CatalogueLoadResult = {
203
203
  readonly ok: false;
204
204
  readonly diagnostics: readonly Diagnostic[];
205
205
  };
206
- export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource): CatalogueLoadResult;
206
+ export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource, profileContext?: ResolvedProfileContext): CatalogueLoadResult;
207
207
  export declare function renderInterrogationReport(report: InterrogationReport): string;
@@ -527,9 +527,97 @@ export function evaluateCatalogue(catalogue, graph, profileContext, evidence) {
527
527
  waves,
528
528
  };
529
529
  }
530
+ /**
531
+ * Every qualified kind a catalogue names, from all three fields that carry
532
+ * one.
533
+ *
534
+ * All three die the same way when the kind does not resolve, and two of them
535
+ * are easy to forget. A trigger's kind never matches, so the question never
536
+ * opens. A subject selector's kind selects nothing, so the question is scoped
537
+ * to an empty set. A wave gate's kind never holds, so after #334 the whole
538
+ * wave never opens and carries no questions at all - one typo silently
539
+ * retiring a wave (#351).
540
+ */
541
+ const kindReferencesOf = (catalogue) => {
542
+ const found = [];
543
+ const fromCondition = (condition, path) => {
544
+ if (typeof condition !== 'object' || condition === null)
545
+ return;
546
+ for (const field of ['kinds', 'counterpartKinds']) {
547
+ const value = condition[field];
548
+ if (!Array.isArray(value))
549
+ continue;
550
+ value.forEach((kind, index) => {
551
+ if (typeof kind === 'string')
552
+ found.push({ kind, path: [...path, field, index] });
553
+ });
554
+ }
555
+ };
556
+ catalogue.waves.forEach((wave, waveIndex) => {
557
+ ;
558
+ (wave.opensWhen ?? []).forEach((condition, conditionIndex) => {
559
+ fromCondition(condition, ['waves', waveIndex, 'opensWhen', conditionIndex]);
560
+ });
561
+ });
562
+ catalogue.questions.forEach((question, questionIndex) => {
563
+ ;
564
+ (question.subjects?.kinds ?? []).forEach((kind, index) => {
565
+ found.push({
566
+ kind,
567
+ path: ['questions', questionIndex, 'subjects', 'kinds', index],
568
+ });
569
+ });
570
+ question.trigger.forEach((condition, conditionIndex) => {
571
+ fromCondition(condition, [
572
+ 'questions',
573
+ questionIndex,
574
+ 'trigger',
575
+ conditionIndex,
576
+ ]);
577
+ });
578
+ });
579
+ return found;
580
+ };
581
+ /**
582
+ * Kinds this catalogue names that the profile they belong to does not have.
583
+ *
584
+ * The check is deliberately narrow, and the narrowness is the design (#351).
585
+ * A kind is reported ONLY when its profile is loaded and the kind is absent
586
+ * from it, which is unambiguously a typo. A kind whose profile is not loaded
587
+ * at all is left alone, because that is a legitimately dormant cross-profile
588
+ * question rather than a mistake: `core-enrichment` names four
589
+ * `yarramate/policy@0.1` constraint kinds, and `yarramate/policy@0.1` loads
590
+ * only when a document selects it or a profile extends it. Reporting those
591
+ * four would put four false positives on the catalogue this repository ships,
592
+ * and a check that cries wolf on its own catalogue gets turned off.
593
+ *
594
+ * Resolution is tested against the kind maps rather than a declared-kinds
595
+ * list, so a kind inherited through `extends` counts. A profile that declares
596
+ * no kinds of its own and inherits every one of them is the case a
597
+ * declared-kinds check would call entirely missing.
598
+ */
599
+ const unresolvableKinds = (catalogue, profileContext) => {
600
+ const known = new Set([
601
+ ...profileContext.conceptKindLineages.keys(),
602
+ ...profileContext.relationshipKindLineages.keys(),
603
+ ]);
604
+ const loadedProfiles = new Set();
605
+ for (const identity of known) {
606
+ const hash = identity.indexOf('#');
607
+ if (hash > 0)
608
+ loadedProfiles.add(identity.slice(0, hash));
609
+ }
610
+ return kindReferencesOf(catalogue).filter(({ kind }) => {
611
+ if (known.has(kind))
612
+ return false;
613
+ const hash = kind.indexOf('#');
614
+ // Profile absent entirely: dormant, not wrong.
615
+ return hash > 0 && loadedProfiles.has(kind.slice(0, hash));
616
+ });
617
+ };
530
618
  // Shared by interrogate and design: schema validation plus the YM911
531
619
  // undeclared-wave check, both source-located against the catalogue file.
532
- export function loadQuestionCatalogue(catalogueSource) {
620
+ export function loadQuestionCatalogue(catalogueSource, profileContext) {
533
621
  const loadedCatalogue = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
534
622
  if (!loadedCatalogue.ok) {
535
623
  return { ok: false, diagnostics: loadedCatalogue.diagnostics };
@@ -549,6 +637,20 @@ export function loadQuestionCatalogue(catalogueSource) {
549
637
  if (waveDiagnostics.length > 0) {
550
638
  return { ok: false, diagnostics: waveDiagnostics };
551
639
  }
640
+ // Only when a caller has a compiled workspace to check against. Without one
641
+ // there is no way to tell a typo from a kind whose profile simply is not
642
+ // here, and guessing would be the false positive this check exists to avoid.
643
+ const kindDiagnostics = profileContext === undefined
644
+ ? []
645
+ : unresolvableKinds(catalogue, profileContext).map(({ kind, path }) => ({
646
+ severity: 'error',
647
+ code: 'YM914',
648
+ message: `Kind "${kind}" is not declared by profile "${kind.slice(0, kind.indexOf('#'))}", which this workspace loads, so the question can never fire`,
649
+ ...locateSourcePath(catalogueSource.path, loadedCatalogue.document.yaml, loadedCatalogue.document.lineCounter, path, `/${path.join('/')}`),
650
+ }));
651
+ if (kindDiagnostics.length > 0) {
652
+ return { ok: false, diagnostics: kindDiagnostics };
653
+ }
552
654
  return { ok: true, catalogue };
553
655
  }
554
656
  // Shared by interrogate and `ask --open`: the wave-by-wave human report.