yarramate 1.9.0 → 1.11.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.
@@ -6,7 +6,7 @@ import { basename, extname, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import Ajv2020Module from "ajv/dist/2020.js";
8
8
  import { WebSocketServer } from "ws";
9
- import { parse, stringify } from "yaml";
9
+ import { parse } from "yaml";
10
10
  import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, digestOf, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, toWireFileUri, visualBrowserInputType, } from "./protocol.js";
11
11
  import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from "./session-store.js";
12
12
  import { loadProjection, } from "../../projection.js";
@@ -15,6 +15,7 @@ import { adoptLandedViews, exclusionsOf, matchedIdsOf, planViewWrites, published
15
15
  import { compileWorkspaceWithProfileContext, } from "../../compiler.js";
16
16
  import { planOperations, posixDirectoryOf, writeConflictDiagnostics, } from "../../apply-command.js";
17
17
  import { createFileSystemStore } from "../../source-store.js";
18
+ import { emitYaml } from "../../yaml-emission.js";
18
19
  import { loadWorkspaceManifest, } from "../../workspace.js";
19
20
  import visualLayoutSchema from "../../../schema/yarramate-visual-layout.schema.json" with { type: "json" };
20
21
  import visualProjectionSchema from "../../../schema/yarramate-projection.schema.json" with { type: "json" };
@@ -1306,7 +1307,7 @@ export const startVisualServer = async (options) => {
1306
1307
  }
1307
1308
  return;
1308
1309
  }
1309
- const operationsSource = stringify({
1310
+ const operationsSource = emitYaml({
1310
1311
  format: "yarramate/operations/v1",
1311
1312
  operations: event.payload.operations,
1312
1313
  });
@@ -1417,7 +1418,7 @@ export const startVisualServer = async (options) => {
1417
1418
  }
1418
1419
  const path = `.yarramate/visual-layout/${projectionId}.yaml`;
1419
1420
  mkdirSync(layoutDir, { recursive: true });
1420
- writeFileSync(resolve(options.cwd, path), stringify({
1421
+ writeFileSync(resolve(options.cwd, path), emitYaml({
1421
1422
  format: "yarramate/visual-layout/v1",
1422
1423
  projectionId,
1423
1424
  positions,
@@ -1,5 +1,5 @@
1
- import { stringify } from "yaml";
2
1
  import { loadProjection } from "../../projection.js";
2
+ import { emitYaml } from "../../yaml-emission.js";
3
3
  import { withDiagnosticSubjects } from "../../compiler.js";
4
4
  import { posixDirectoryOf } from "../../apply-command.js";
5
5
  import { projectGraphForCanvas } from "../../graph-projection.js";
@@ -259,7 +259,7 @@ export const planViewWrites = (operations, workspace, store) => {
259
259
  diagnostics.push(serverDiagnostic("YMVS315", `The workspace manifest covers no projection in "${posixDirectoryOf(operation.path)}", so a view saved there would never load`));
260
260
  continue;
261
261
  }
262
- const source = stringify(operation.projection);
262
+ const source = emitYaml(operation.projection);
263
263
  const loaded = loadProjection({ path: operation.path, source });
264
264
  if (!loaded.ok) {
265
265
  // The composed document is the only source these are about, so they are
@@ -1,8 +1,9 @@
1
- import { isMap, isScalar, isSeq, parseDocument, stringify, } from 'yaml';
1
+ import { isMap, isScalar, isSeq, parseDocument, } from 'yaml';
2
2
  import Ajv2020Module from 'ajv/dist/2020.js';
3
3
  import { loadAdapterMapping } from './adapter-mapping.js';
4
4
  import { compileWorkspace, withDiagnosticSubjects, } from './compiler.js';
5
5
  import { evaluateEvidence, loadEvidence } from './evidence.js';
6
+ import { emitYaml } from './yaml-emission.js';
6
7
  import { loadProjection } from './projection.js';
7
8
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
8
9
  import { declaredStateIds, rewriteSubjectReferences, scanSubjectReferences, } from './subject-references.js';
@@ -61,7 +62,7 @@ const reindent = (text, indent) => text.split('\n').join(`\n${' '.repeat(indent)
61
62
  // A plain value as YAML source. lineWidth 0 keeps strings we author on one
62
63
  // line; genuinely multi-line strings become block scalars and are re-indented
63
64
  // by the caller.
64
- const valueText = (value) => stringify(value, { lineWidth: 0 }).trimEnd();
65
+ const valueText = (value) => emitYaml(value, { lineWidth: 0 }).trimEnd();
65
66
  const pairFor = (map, key) => map.items.find((pair) => isScalar(pair.key) && pair.key.value === key);
66
67
  const nodeRange = (node) => {
67
68
  const range = node.range;
@@ -72,7 +73,7 @@ const nodeRange = (node) => {
72
73
  };
73
74
  // Renders `items` as block sequence entries at the given marker indent.
74
75
  const sequenceEntries = (items, markerIndent) => {
75
- const rendered = stringify(items, { lineWidth: 0 }).trimEnd();
76
+ const rendered = emitYaml(items, { lineWidth: 0 }).trimEnd();
76
77
  return `${' '.repeat(markerIndent)}${reindent(rendered, markerIndent)}`;
77
78
  };
78
79
  const splice = (source, start, end, text) => source.slice(0, start) + text + source.slice(end);
@@ -186,7 +187,7 @@ const rewriteFlowItem = (source, map, mutate) => {
186
187
  const mutated = mutate(map.toJSON());
187
188
  const [start, valueEnd] = nodeRange(map);
188
189
  const fieldIndent = indentAt(source, start);
189
- const rendered = reindent(stringify(mutated, { lineWidth: 0 }).trimEnd(), fieldIndent);
190
+ const rendered = reindent(emitYaml(mutated, { lineWidth: 0 }).trimEnd(), fieldIndent);
190
191
  return splice(source, start, valueEnd, rendered);
191
192
  };
192
193
  const setScalarField = (source, map, key, value) => {
@@ -235,7 +236,7 @@ const appendListField = (source, map, key, additions) => {
235
236
  ? nodeRange(sequence)
236
237
  : nodeRange(pair.value);
237
238
  if (isSeq(sequence) && sequence.flow) {
238
- const flow = stringify(merged, {
239
+ const flow = emitYaml(merged, {
239
240
  collectionStyle: 'flow',
240
241
  lineWidth: 0,
241
242
  }).trimEnd();
@@ -278,7 +279,7 @@ const removeCollectionItem = (source, collection, matches) => {
278
279
  const [start, valueEnd] = nodeRange(sequence);
279
280
  return splice(source, start, valueEnd, remaining.length === 0
280
281
  ? '[]'
281
- : stringify(remaining, {
282
+ : emitYaml(remaining, {
282
283
  collectionStyle: 'flow',
283
284
  lineWidth: 0,
284
285
  }).trimEnd());
@@ -9,10 +9,10 @@ import { buildWorkbookSheets } from './workbook.js';
9
9
  import { baselineSheets, mergeWorkbook } from './workbook-merge.js';
10
10
  import { operationsFrom, operationsDocument } from './workbook-operations.js';
11
11
  import { readWorkbook } from './workbook-read.js';
12
+ import { emitYaml } from './yaml-emission.js';
12
13
  import { writeFileSync, mkdtempSync } from 'node:fs';
13
14
  import { tmpdir } from 'node:os';
14
15
  import { join } from 'node:path';
15
- import { stringify } from 'yaml';
16
16
  /**
17
17
  * `yarramate import xlsx <file> <workspace.yaml>` (#355, ADR 0127).
18
18
  *
@@ -137,7 +137,7 @@ export async function runImportCommand(options, cwd) {
137
137
  // atomic gate, and the surgical YAML editing that keeps comments.
138
138
  const scratch = mkdtempSync(join(tmpdir(), 'yarramate-import-'));
139
139
  const operationsPath = join(scratch, 'operations.yaml');
140
- writeFileSync(operationsPath, stringify(operationsDocument(planned.operations)), 'utf8');
140
+ writeFileSync(operationsPath, emitYaml(operationsDocument(planned.operations)), 'utf8');
141
141
  const applied = runApplyCommand([operationsPath, workspacePath], cwd);
142
142
  if (applied.exitCode !== 0)
143
143
  return applied;
@@ -178,6 +178,16 @@ export interface ReportQuestion {
178
178
  readonly scope: 'workspace' | 'subject';
179
179
  readonly authority: 'human' | 'agent' | 'either';
180
180
  readonly open: boolean;
181
+ /**
182
+ * Whether the question was asked at all (#375, ADR 0132). Absent means
183
+ * true; only a subject-scoped question whose selector matched NO subject
184
+ * carries `asked: false`. Without it, never-asked and answered were
185
+ * byte-identical (`open: false`), and a host summing closed questions
186
+ * read an empty model as a satisfied interview — completion inferred
187
+ * from an empty set, the exact reading ADR 0125 refuses one level up by
188
+ * not evaluating a closed wave's questions at all.
189
+ */
190
+ readonly asked?: boolean;
181
191
  readonly question: string;
182
192
  readonly materiality: string;
183
193
  readonly resolution: string;
@@ -1,6 +1,7 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
3
3
  import { nearDuplicateIndex } from './subject-identity.js';
4
+ import { sourceKindsPermitting, tableKnowsConceptKind, tableKnowsRelationshipKind, targetKindsPermitting, } from './relationship-matrix.js';
4
5
  import catalogueSchema from '../schema/yarramate-question-catalogue.schema.json' with { type: 'json'
5
6
  };
6
7
  // `.default ?? module`, not a bare `.default`: NodeNext sees the raw CJS
@@ -517,7 +518,16 @@ patternMemberships) {
517
518
  }
518
519
  return { ...base, open: isOpen };
519
520
  }
520
- const matches = selectSubjects(index, question.subjects, profileContext).filter((id) => question.trigger.every((condition) => conditionHolds(index, condition, id, profileContext, evidence, patternMemberships)));
521
+ // Selection and trigger filtering are separate reads on purpose
522
+ // (#375, ADR 0132): a selector matching nobody means the question
523
+ // was never asked, and reporting that as a closed question said it
524
+ // had been asked and answered — completion inferred from an empty
525
+ // set, the reading ADR 0125 refuses one level up.
526
+ const selected = selectSubjects(index, question.subjects, profileContext);
527
+ if (selected.length === 0) {
528
+ return { ...base, open: false, asked: false };
529
+ }
530
+ const matches = selected.filter((id) => question.trigger.every((condition) => conditionHolds(index, condition, id, profileContext, evidence, patternMemberships)));
521
531
  if (matches.length === 0) {
522
532
  return { ...base, open: false };
523
533
  }
@@ -648,6 +658,128 @@ const unresolvableKinds = (catalogue, profileContext) => {
648
658
  return hash > 0 && loadedProfiles.has(kind.slice(0, hash));
649
659
  });
650
660
  };
661
+ // The core kind an authored kind resolves to, or undefined when this workspace
662
+ // cannot resolve it at all. Lineage is ancestor-first, so `lineage[0]` is the
663
+ // core identity and an extension inherits its parent's row in the table
664
+ // (ADR 0097); a core kind is its own lineage head. This is also why
665
+ // `kindMatching: descendants` needs no special handling: a descendant shares
666
+ // its ancestor's row and column, so checking the named kind covers them all.
667
+ // A kind the table has no row for answers every query with an empty set,
668
+ // which reads exactly like "forbidden". The guards keep the two apart, so a
669
+ // vocabulary the table cannot judge is passed over in silence rather than
670
+ // accused - the direction every ambiguity here resolves in. They are the
671
+ // reason nothing below casts: the typed queries are reached through the
672
+ // check rather than around it.
673
+ const localKindOf = (kind, lineages) => {
674
+ const lineage = lineages.get(kind);
675
+ if (lineage === undefined)
676
+ return undefined;
677
+ const identity = lineage[0] ?? kind;
678
+ return identity.slice(identity.indexOf('#') + 1);
679
+ };
680
+ const coreConceptKindOf = (kind, lineages) => {
681
+ const local = localKindOf(kind, lineages);
682
+ return local !== undefined && tableKnowsConceptKind(local) ? local : undefined;
683
+ };
684
+ const coreRelationshipKindOf = (kind, lineages) => {
685
+ const local = localKindOf(kind, lineages);
686
+ return local !== undefined && tableKnowsRelationshipKind(local)
687
+ ? local
688
+ : undefined;
689
+ };
690
+ /**
691
+ * Remedies a trigger offers that no model could author.
692
+ *
693
+ * A trigger names the ways its question can be satisfied, and each one is an
694
+ * OFFER: add this relationship, in this direction, from one of these kinds.
695
+ * The ArchiMate table the compiler admits relationships against decides which
696
+ * of those are authorable. An offer it forbids is a lie the catalogue tells,
697
+ * and the unit is the offer rather than the question, because a question with
698
+ * three offers and one dead one still reads as answerable: a reader takes the
699
+ * dead option, authors it, and the compiler refuses the write (ADR 0133).
700
+ *
701
+ * Both conditions naming a relationship are checked, and `missing-linkage` is
702
+ * the MORE checkable of the two. `missing-relationship` asks whether any of
703
+ * the 62 core kinds may stand opposite, which only the seven kinds nothing may
704
+ * realize can fail. `missing-linkage` names its own counterpart kinds, so the
705
+ * question narrows to those, a dead offer is far likelier, and the diagnostic
706
+ * can point at the exact list that admits nothing. Both of the defects that
707
+ * prompted this check in the reporting consumer's catalogue were linkage
708
+ * offers, not relationship ones.
709
+ *
710
+ * `missing-attestation` and `missing-claim` are not checked: they name no
711
+ * relationship, so there is no table to consult, and inventing one for them
712
+ * would be the second encoding of ArchiMate's rules this check exists to
713
+ * prevent.
714
+ *
715
+ * Narrow in the same two ways `unresolvableKinds` is narrow. Without a profile
716
+ * context there is no lineage to resolve an extension kind through, so nothing
717
+ * is reported rather than guessed; and a kind that resolves nowhere is
718
+ * `YM914`'s business, not this one. A trigger with any unresolvable counterpart
719
+ * is skipped whole, because a partial reading could accuse a question that a
720
+ * dormant cross-profile kind would have answered.
721
+ */
722
+ const unauthorableOffers = (catalogue, profileContext) => {
723
+ const found = [];
724
+ const conceptCore = (kind) => coreConceptKindOf(kind, profileContext.conceptKindLineages);
725
+ for (const [questionIndex, question] of catalogue.questions.entries()) {
726
+ const subjectKinds = question.subjects?.kinds ?? [];
727
+ if (subjectKinds.length === 0)
728
+ continue;
729
+ for (const [conditionIndex, condition] of question.trigger.entries()) {
730
+ const trigger = condition;
731
+ if (trigger.condition !== 'missing-relationship' &&
732
+ trigger.condition !== 'missing-linkage') {
733
+ continue;
734
+ }
735
+ // `any` and `either` are satisfied by a relationship in either
736
+ // direction, so an offer is dead only when both directions are.
737
+ const directions = trigger.direction === 'any' || trigger.direction === 'either'
738
+ ? ['incoming', 'outgoing']
739
+ : [trigger.direction ?? 'any'];
740
+ const named = trigger.counterpartKinds;
741
+ const counterparts = named?.map(conceptCore);
742
+ if (counterparts?.some((kind) => kind === undefined) === true)
743
+ continue;
744
+ for (const subjectKind of subjectKinds) {
745
+ const subject = conceptCore(subjectKind);
746
+ if (subject === undefined)
747
+ continue;
748
+ for (const [kindIndex, relationshipKind] of (trigger.kinds ?? []).entries()) {
749
+ const relationship = coreRelationshipKindOf(relationshipKind, profileContext.relationshipKindLineages);
750
+ if (relationship === undefined)
751
+ continue;
752
+ const authorable = directions.some((direction) => {
753
+ const opposite = direction === 'incoming'
754
+ ? sourceKindsPermitting(relationship, subject)
755
+ : targetKindsPermitting(relationship, subject);
756
+ return counterparts === undefined
757
+ ? opposite.size > 0
758
+ : counterparts.some((kind) => kind !== undefined && opposite.has(kind));
759
+ });
760
+ if (authorable)
761
+ continue;
762
+ found.push({
763
+ questionId: question.id,
764
+ subjectKind,
765
+ relationshipKind,
766
+ direction: trigger.direction ?? 'any',
767
+ counterpartKinds: named,
768
+ path: [
769
+ 'questions',
770
+ questionIndex,
771
+ 'trigger',
772
+ conditionIndex,
773
+ 'kinds',
774
+ kindIndex,
775
+ ],
776
+ });
777
+ }
778
+ }
779
+ }
780
+ }
781
+ return found;
782
+ };
651
783
  const loadCatalogueDocument = (catalogueSource) => {
652
784
  const loaded = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
653
785
  if (!loaded.ok)
@@ -693,6 +825,25 @@ profileContext === undefined
693
825
  message: `Kind "${reference.kind}" is not declared by profile "${reference.kind.slice(0, reference.kind.indexOf('#'))}", which this workspace loads, so the question can never fire`,
694
826
  ...locate(reference.path),
695
827
  }));
828
+ // YM916, the sibling of YM914. YM914 refuses a question that can never FIRE;
829
+ // this refuses one that offers a remedy nobody could author. Both failures are
830
+ // invisible, and this one twice over: the question reads as ordinary
831
+ // unfinished work, and a reader who takes the dead offer learns only when the
832
+ // compiler refuses the write it led them to (ADR 0133).
833
+ const unauthorableOfferDiagnostics = ({ catalogue, locate }, profileContext) => profileContext === undefined
834
+ ? []
835
+ : unauthorableOffers(catalogue, profileContext).map((offer) => ({
836
+ severity: 'error',
837
+ code: 'YM916',
838
+ message: `Question "${offer.questionId}" offers "${offer.relationshipKind}" ` +
839
+ `${offer.direction} on "${offer.subjectKind}", which the ` +
840
+ 'ArchiMate relationship table permits from ' +
841
+ (offer.counterpartKinds === undefined
842
+ ? 'no kind at all'
843
+ : `none of the counterpart kinds it names (${offer.counterpartKinds.join(', ')})`) +
844
+ ', so no model could author it',
845
+ ...locate(offer.path),
846
+ }));
696
847
  /**
697
848
  * A catalogue is qualified on the way OUT, never in what an author writes.
698
849
  *
@@ -801,6 +952,7 @@ export function composeCatalogues(sources, profileContext) {
801
952
  const crossDiagnostics = documents.flatMap((document) => [
802
953
  ...undeclaredWaveDiagnostics(document, declaredWaves),
803
954
  ...unresolvableKindDiagnostics(document, profileContext),
955
+ ...unauthorableOfferDiagnostics(document, profileContext),
804
956
  ]);
805
957
  if (crossDiagnostics.length > 0) {
806
958
  return { ok: false, diagnostics: crossDiagnostics };
@@ -837,6 +989,10 @@ export function loadQuestionCatalogue(catalogueSource, profileContext) {
837
989
  if (kindDiagnostics.length > 0) {
838
990
  return { ok: false, diagnostics: kindDiagnostics };
839
991
  }
992
+ const offerDiagnostics = unauthorableOfferDiagnostics(loaded.document, profileContext);
993
+ if (offerDiagnostics.length > 0) {
994
+ return { ok: false, diagnostics: offerDiagnostics };
995
+ }
840
996
  return { ok: true, catalogue: loaded.document.catalogue };
841
997
  }
842
998
  // Shared by interrogate and `ask --open`: the wave-by-wave human report.
@@ -859,6 +1015,13 @@ export function renderInterrogationReport(report) {
859
1015
  continue;
860
1016
  }
861
1017
  for (const question of wave.questions) {
1018
+ if (question.asked === false) {
1019
+ // Never asked is not closed (#375): `closed` says answered, and a
1020
+ // selector that matched nobody asked nothing — the question-level
1021
+ // twin of the wave's own "not yet" line above.
1022
+ lines.push(` unasked ${question.id} — nothing it selects exists yet`);
1023
+ continue;
1024
+ }
862
1025
  if (!question.open) {
863
1026
  lines.push(` closed ${question.id}`);
864
1027
  continue;
@@ -6,6 +6,8 @@ export declare const isCoreConceptKindId: (id: string) => id is CoreConceptKindI
6
6
  export declare const permittedRelationshipKinds: (from: CoreConceptKindId, to: CoreConceptKindId) => ReadonlySet<RelationshipKind>;
7
7
  export declare const relationshipPermitted: (from: CoreConceptKindId, kind: RelationshipKind, to: CoreConceptKindId) => boolean;
8
8
  export declare const matrixEndpointAspects: (kind: RelationshipKind, endpoint: 'source' | 'target') => ReadonlySet<Aspect>;
9
+ export declare const tableKnowsConceptKind: (kind: string) => kind is CoreConceptKindId;
10
+ export declare const tableKnowsRelationshipKind: (kind: string) => kind is RelationshipKind;
9
11
  /** Kinds that may stand as the source of `kind` into `to`. */
10
12
  export declare const sourceKindsPermitting: (kind: RelationshipKind, to: CoreConceptKindId) => ReadonlySet<CoreConceptKindId>;
11
13
  /** Kinds that may stand as the target of `kind` from `from`. */
@@ -66,6 +66,30 @@ export const matrixEndpointAspects = (kind, endpoint) => {
66
66
  endpointAspectCache.set(key, aspects);
67
67
  return aspects;
68
68
  };
69
+ /**
70
+ * Whether the table has a row and column for this kind at all.
71
+ *
72
+ * Every query below answers an ABSENT kind with an empty set, which reads
73
+ * identically to "the table forbids this" - the empty-set conflation, in the
74
+ * one place where mistaking it turns a gate into a false accuser. A caller
75
+ * deciding whether something is forbidden must therefore ask this first, so
76
+ * that "not in the table" resolves toward silence rather than toward blame.
77
+ *
78
+ * Nothing reachable through a profile should fail it: `parent` is required on
79
+ * every declared kind and resolves to a core ancestor, so a lineage head is
80
+ * always a table kind. The predicates exist because that guarantee is another
81
+ * module's to keep, and a gate should not rest on one silently.
82
+ *
83
+ * They are type guards rather than booleans so that a caller holding a bare
84
+ * string reaches the typed queries through the check instead of around it by
85
+ * a cast. The cast is where the hole actually opens: the signatures already
86
+ * refuse an unknown kind, and asserting past them is what lets an absent kind
87
+ * arrive and be read as a forbidden one.
88
+ */
89
+ const CORE_CONCEPT_KINDS = new Set(CORE_CONCEPT_KIND_ORDER);
90
+ const CORE_RELATIONSHIP_KIND_SET = new Set(relationshipKinds);
91
+ export const tableKnowsConceptKind = (kind) => CORE_CONCEPT_KINDS.has(kind);
92
+ export const tableKnowsRelationshipKind = (kind) => CORE_RELATIONSHIP_KIND_SET.has(kind);
69
93
  /** Kinds that may stand as the source of `kind` into `to`. */
70
94
  export const sourceKindsPermitting = (kind, to) => new Set(CORE_CONCEPT_KIND_ORDER.filter((from) => relationshipPermitted(from, kind, to)));
71
95
  /** Kinds that may stand as the target of `kind` from `from`. */