yarramate 1.6.0 → 1.7.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.
Files changed (33) hide show
  1. package/dist/adapters/visual/session-server.js +27 -1
  2. package/dist/adapters/visual/workspace-model.d.ts +16 -3
  3. package/dist/adapters/visual/workspace-model.js +12 -5
  4. package/dist/ask-command.js +25 -16
  5. package/dist/catalogue-sources.d.ts +20 -0
  6. package/dist/catalogue-sources.js +39 -0
  7. package/dist/check-command.js +38 -9
  8. package/dist/cli-support.d.ts +7 -0
  9. package/dist/cli-support.js +4 -0
  10. package/dist/design-command.js +21 -9
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/interrogate-command.d.ts +82 -1
  14. package/dist/interrogate-command.js +195 -29
  15. package/dist/interrogation-entry.d.ts +1 -1
  16. package/dist/interrogation-entry.js +1 -1
  17. package/dist/projection.d.ts +47 -0
  18. package/dist/projection.js +126 -0
  19. package/dist/visual-app-lib/editor.js +26771 -26713
  20. package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
  21. package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
  22. package/dist/visual-app-lib/types/projection.d.ts +47 -0
  23. package/dist/visual-app-lib/types/workspace.d.ts +15 -0
  24. package/dist/workbook-import-entry.d.ts +30 -0
  25. package/dist/workbook-import-entry.js +30 -0
  26. package/dist/workbook.js +15 -0
  27. package/dist/workspace.d.ts +15 -0
  28. package/dist/workspace.js +1 -0
  29. package/docs/CONSUMING-YARRAMATE.md +63 -0
  30. package/package.json +5 -1
  31. package/schema/yarramate-interrogation-report.schema.json +9 -0
  32. package/schema/yarramate-question-catalogue.schema.json +2 -3
  33. package/schema/yarramate-workspace.schema.json +4 -0
@@ -451,7 +451,15 @@ const describeCounterparts = (index, question, subjectId) => {
451
451
  return name === undefined ? id : `${name} (${id})`;
452
452
  });
453
453
  };
454
- export function evaluateCatalogue(catalogue, graph, profileContext, evidence) {
454
+ export function evaluateCatalogue(catalogue, graph, profileContext, evidence,
455
+ /**
456
+ * Contributing catalogues, from {@link composeCatalogues}. Composition
457
+ * happens BEFORE evaluation and hands this function an ordinary catalogue,
458
+ * so the only thing evaluation learns about composition is what to name in
459
+ * the report. A fifth optional parameter rather than an options object,
460
+ * because this signature is published and a consumer already calls it.
461
+ */
462
+ catalogues) {
455
463
  const index = indexGraph(graph);
456
464
  let open = 0;
457
465
  let openQuestions = 0;
@@ -512,6 +520,11 @@ export function evaluateCatalogue(catalogue, graph, profileContext, evidence) {
512
520
  return {
513
521
  format: 'yarramate/interrogation-report/v1',
514
522
  catalogue: `${catalogue.id}@${catalogue.version}`,
523
+ // Only when composition actually happened. A single-catalogue report is
524
+ // byte-identical to what it was before this existed.
525
+ ...(catalogues === undefined || catalogues.length < 2
526
+ ? {}
527
+ : { catalogues }),
515
528
  semantics: INTERROGATION_SEMANTICS_VERSION,
516
529
  summary: {
517
530
  // Questions in OPENED waves only (#334, ADR 0125). A closed wave's
@@ -615,43 +628,196 @@ const unresolvableKinds = (catalogue, profileContext) => {
615
628
  return hash > 0 && loadedProfiles.has(kind.slice(0, hash));
616
629
  });
617
630
  };
618
- // Shared by interrogate and design: schema validation plus the YM911
619
- // undeclared-wave check, both source-located against the catalogue file.
620
- export function loadQuestionCatalogue(catalogueSource, profileContext) {
621
- const loadedCatalogue = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
622
- if (!loadedCatalogue.ok) {
623
- return { ok: false, diagnostics: loadedCatalogue.diagnostics };
631
+ const loadCatalogueDocument = (catalogueSource) => {
632
+ const loaded = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
633
+ if (!loaded.ok)
634
+ return { ok: false, diagnostics: loaded.diagnostics };
635
+ return {
636
+ ok: true,
637
+ document: {
638
+ source: catalogueSource,
639
+ catalogue: loaded.document.value,
640
+ locate: (path) => locateSourcePath(catalogueSource.path, loaded.document.yaml, loaded.document.lineCounter, path, `/${path.join('/')}`),
641
+ },
642
+ };
643
+ };
644
+ /**
645
+ * The YM911 undeclared-wave check, against a set of wave ids that may be
646
+ * WIDER than the catalogue's own.
647
+ *
648
+ * That width is the whole of #345. A project catalogue's reason to exist is
649
+ * adding "one more Assurance question for this client" to a wave the domain
650
+ * catalogue declared, so checking each file against only its own waves would
651
+ * refuse precisely the case the feature enables. Composed, the set is the
652
+ * union; alone, it is the catalogue's own and the check is what it always was.
653
+ */
654
+ const undeclaredWaveDiagnostics = ({ catalogue, locate }, declaredWaves) => catalogue.questions.flatMap((question, questionIndex) => declaredWaves.has(question.wave)
655
+ ? []
656
+ : [
657
+ {
658
+ severity: 'error',
659
+ code: 'YM911',
660
+ message: `Question "${question.id}" references undeclared wave "${question.wave}"`,
661
+ ...locate(['questions', questionIndex, 'wave']),
662
+ },
663
+ ]);
664
+ const unresolvableKindDiagnostics = ({ catalogue, locate }, profileContext) =>
665
+ // Only when a caller has a compiled workspace to check against. Without one
666
+ // there is no way to tell a typo from a kind whose profile simply is not
667
+ // here, and guessing would be the false positive this check exists to avoid.
668
+ profileContext === undefined
669
+ ? []
670
+ : unresolvableKinds(catalogue, profileContext).map((reference) => ({
671
+ severity: 'error',
672
+ code: 'YM914',
673
+ 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`,
674
+ ...locate(reference.path),
675
+ }));
676
+ /**
677
+ * A catalogue is qualified on the way OUT, never in what an author writes.
678
+ *
679
+ * The authored schema keeps ids local (`^[a-z][a-z0-9-]*$`) and that does not
680
+ * change; a consultant writes `regulator-signoff`, not
681
+ * `consulting#regulator-signoff`. The engine qualifies when it composes, which
682
+ * is the only moment two catalogues can be confused for each other.
683
+ *
684
+ * NO VERSION, and that is the decision rather than an omission (ADR 0129).
685
+ * `core-enrichment` went 1.0 to 1.3 in a single day renaming nothing; a
686
+ * versioned identity would have stranded every stored dismissal in every
687
+ * adopter's database three times that day, for changes that removed no
688
+ * question. Versioned identity is safe for things that are AUTHORED - a
689
+ * document keeps naming the version it was written against and an author
690
+ * updates it deliberately - and unsafe for things that are STORED, because a
691
+ * row in someone's database has no author to update it.
692
+ */
693
+ export const qualifiedQuestionId = (catalogueId, questionId) => `${catalogueId}#${questionId}`;
694
+ /**
695
+ * Compose a base catalogue with the ones a workspace carries (#345, ADR 0129).
696
+ *
697
+ * ADDITIVE. `--catalogue` and `MountOptions.catalogue` replace the base; this
698
+ * adds to it, which is what lets a consultant author a question at any point
699
+ * in an engagement with no product release.
700
+ *
701
+ * A WAVE IS DECLARED EXACTLY ONCE across the resolved set, and any catalogue
702
+ * may contribute questions to a wave it did not declare. That one rule settles
703
+ * three questions composition would otherwise raise. Wave identity: a project
704
+ * catalogue joins a declared wave rather than colliding with it. Ordering:
705
+ * only a declaration places a wave, so the base's order is untouched and new
706
+ * waves append. And the `opensWhen` precedence ADR 0125 made load-bearing does
707
+ * not arise at all, because there is only ever one declarer to ask.
708
+ *
709
+ * QUALIFICATION IS A PROPERTY OF COMPOSITION, NOT OF EVALUATION. A caller
710
+ * that hands `evaluateCatalogue` a catalogue directly still gets local ids,
711
+ * exactly as before this existed. Route through here even for ONE catalogue -
712
+ * which is what every CLI verb does - and ids are qualified from the start, so
713
+ * they do not change later when a workspace first carries a question of its
714
+ * own. That transition is the one thing an adopter keying stored judgments on
715
+ * a question id must not experience, and composing unconditionally is how it
716
+ * is avoided.
717
+ *
718
+ * ID COLLISIONS DISSOLVE rather than being resolved. Two catalogues may both
719
+ * carry `outcome-missing`; qualified, they are two different questions. There
720
+ * is no merge rule, no last-wins and no refusal, because there is nothing to
721
+ * merge.
722
+ */
723
+ export function composeCatalogues(sources, profileContext) {
724
+ const documents = [];
725
+ const loadDiagnostics = [];
726
+ for (const source of sources) {
727
+ const loaded = loadCatalogueDocument(source);
728
+ if (loaded.ok)
729
+ documents.push(loaded.document);
730
+ else
731
+ loadDiagnostics.push(...loaded.diagnostics);
732
+ }
733
+ if (loadDiagnostics.length > 0) {
734
+ return { ok: false, diagnostics: loadDiagnostics };
624
735
  }
625
- const catalogue = loadedCatalogue.document.value;
626
- const waveIds = new Set(catalogue.waves.map(({ id }) => id));
627
- const waveDiagnostics = catalogue.questions.flatMap((question, questionIndex) => waveIds.has(question.wave)
628
- ? []
629
- : [
630
- {
736
+ const base = documents[0];
737
+ if (base === undefined) {
738
+ // Never reached through the CLI, which always has the shipped catalogue,
739
+ // but an empty set must not compose into an empty catalogue that reports
740
+ // a finished interview.
741
+ return {
742
+ ok: false,
743
+ diagnostics: [
744
+ {
745
+ severity: 'error',
746
+ code: 'YM915',
747
+ message: 'No question catalogue to compose',
748
+ path: '',
749
+ pointer: '',
750
+ line: 1,
751
+ column: 1,
752
+ },
753
+ ],
754
+ };
755
+ }
756
+ // Declared exactly once. A second declaration is refused rather than merged,
757
+ // because the two carry independent `opensWhen` gates and silently picking
758
+ // one would decide when a wave opens by file order.
759
+ const declaredBy = new Map();
760
+ const duplicates = [];
761
+ for (const document of documents) {
762
+ document.catalogue.waves.forEach((wave, waveIndex) => {
763
+ const first = declaredBy.get(wave.id);
764
+ if (first === undefined) {
765
+ declaredBy.set(wave.id, document);
766
+ return;
767
+ }
768
+ duplicates.push({
631
769
  severity: 'error',
632
- code: 'YM911',
633
- message: `Question "${question.id}" references undeclared wave "${question.wave}"`,
634
- ...locateSourcePath(catalogueSource.path, loadedCatalogue.document.yaml, loadedCatalogue.document.lineCounter, ['questions', questionIndex, 'wave'], `/questions/${questionIndex}/wave`),
770
+ code: 'YM915',
771
+ message: `Wave "${wave.id}" is already declared by catalogue "${first.catalogue.id}" ` +
772
+ `(${first.source.path}). A wave is declared once and contributed to freely: ` +
773
+ 'drop the declaration here and questions in this catalogue will join it.',
774
+ ...document.locate(['waves', waveIndex, 'id']),
775
+ });
776
+ });
777
+ }
778
+ if (duplicates.length > 0)
779
+ return { ok: false, diagnostics: duplicates };
780
+ const declaredWaves = new Set(declaredBy.keys());
781
+ const crossDiagnostics = documents.flatMap((document) => [
782
+ ...undeclaredWaveDiagnostics(document, declaredWaves),
783
+ ...unresolvableKindDiagnostics(document, profileContext),
784
+ ]);
785
+ if (crossDiagnostics.length > 0) {
786
+ return { ok: false, diagnostics: crossDiagnostics };
787
+ }
788
+ return {
789
+ ok: true,
790
+ composed: {
791
+ catalogue: {
792
+ ...base.catalogue,
793
+ // The base names the composition, which is why the report's
794
+ // `catalogue` field keeps its value shape while `catalogues` lists
795
+ // every contributor.
796
+ waves: documents.flatMap(({ catalogue }) => catalogue.waves),
797
+ questions: documents.flatMap(({ catalogue }) => catalogue.questions.map((question) => ({
798
+ ...question,
799
+ id: qualifiedQuestionId(catalogue.id, question.id),
800
+ }))),
635
801
  },
636
- ]);
802
+ catalogues: documents.map(({ catalogue }) => `${catalogue.id}@${catalogue.version}`),
803
+ },
804
+ };
805
+ }
806
+ // Shared by interrogate and design: schema validation plus the YM911
807
+ // undeclared-wave check, both source-located against the catalogue file.
808
+ export function loadQuestionCatalogue(catalogueSource, profileContext) {
809
+ const loaded = loadCatalogueDocument(catalogueSource);
810
+ if (!loaded.ok)
811
+ return { ok: false, diagnostics: loaded.diagnostics };
812
+ const waveDiagnostics = undeclaredWaveDiagnostics(loaded.document, new Set(loaded.document.catalogue.waves.map(({ id }) => id)));
637
813
  if (waveDiagnostics.length > 0) {
638
814
  return { ok: false, diagnostics: waveDiagnostics };
639
815
  }
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
- }));
816
+ const kindDiagnostics = unresolvableKindDiagnostics(loaded.document, profileContext);
651
817
  if (kindDiagnostics.length > 0) {
652
818
  return { ok: false, diagnostics: kindDiagnostics };
653
819
  }
654
- return { ok: true, catalogue };
820
+ return { ok: true, catalogue: loaded.document.catalogue };
655
821
  }
656
822
  // Shared by interrogate and `ask --open`: the wave-by-wave human report.
657
823
  export function renderInterrogationReport(report) {
@@ -1 +1 @@
1
- export { INTERROGATION_SEMANTICS_VERSION, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueEvidenceObservation, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
1
+ export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCompositionResult, type ComposedCatalogue, type CatalogueCondition, type CatalogueEvidenceObservation, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
@@ -7,4 +7,4 @@
7
7
  // alone: catalogue loading takes a WorkspaceSource, evaluation takes an
8
8
  // in-memory graph, and a test pins the import graph free of Node builtins.
9
9
  // The same shape the visual-graph projector uses (`./adapter/visual-graph`).
10
- export { INTERROGATION_SEMANTICS_VERSION, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, } from './interrogate-command.js';
10
+ export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, } from './interrogate-command.js';
@@ -108,6 +108,53 @@ export interface ProjectionExclusion {
108
108
  readonly id: string;
109
109
  readonly facet: ConceptFacet;
110
110
  }
111
+ /** A query facet naming something the model does not have. */
112
+ export interface UnmatchedSelector {
113
+ readonly facet: string;
114
+ readonly value: string;
115
+ /** The closest name the facet does offer, when one is close enough to be a likely typo. */
116
+ readonly nearest?: string;
117
+ }
118
+ /**
119
+ * Every value in a projection query that names nothing.
120
+ *
121
+ * A projection is a DOCUMENT, and a query holds references the same way a
122
+ * relationship does. YarraMate refuses a relationship pointing at a concept
123
+ * that does not exist; it did not refuse a query naming a state that does not
124
+ * exist, and the symptom is silent. `states: [target-stat]` selects no state,
125
+ * which selects no subject, which exports a clean empty artifact with exit 0.
126
+ * Someone hands that to a client.
127
+ *
128
+ * Checked at `check`, not at `export`, because the typo is in a file rather
129
+ * than in an invocation: CI catches it, and every verb over the same
130
+ * projection inherits the guard instead of each growing its own.
131
+ *
132
+ * ONLY FACETS WITH A CLOSED NAMESPACE ARE CHECKED. `statuses` and
133
+ * `excludeStatuses` are schema enums, refused upstream before this runs.
134
+ * Everything else names something: `owners` and `constraints` are REFS to
135
+ * concepts, which the compiler itself proves by refusing an unresolved owner
136
+ * with YM304, so their namespace is the subject list like `subjects`.
137
+ *
138
+ * Each namespace is derived the way the FILTER derives it, so the check cannot
139
+ * drift from what it guards: `documents` reads the same provenance the
140
+ * `documents` facet compares against, and `states` is the same
141
+ * `yarramate/state/type` scan `conceptSelector` runs.
142
+ *
143
+ * A kind whose profile is not loaded is DORMANT rather than wrong, the same
144
+ * distinction #351 drew for question catalogues, so the kind facets are
145
+ * checked only when a profile context is present.
146
+ *
147
+ * An empty RESULT is not reported here and must not be. A query whose every
148
+ * name resolves and which selects nothing is a real answer to a real question:
149
+ * a target state nobody has populated yet is empty, correctly.
150
+ */
151
+ export declare function unmatchedSelectors(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext): readonly UnmatchedSelector[];
152
+ /**
153
+ * {@link unmatchedSelectors} as diagnostics, built HERE rather than at each
154
+ * caller so `check`, and anything that adopts this later, refuse in identical
155
+ * words with an identical code.
156
+ */
157
+ export declare function projectionReferenceDiagnostics(source: WorkspaceSource, projection: ProjectionDefinition, graph: SemanticGraph, profileContext?: ResolvedProfileContext): readonly Diagnostic[];
111
158
  /**
112
159
  * Every concept a query leaves out, and the facet that left it out.
113
160
  *
@@ -1,6 +1,7 @@
1
1
  import Ajv2020Import from 'ajv/dist/2020.js';
2
2
  import { isDeclaredNonGoal } from './brief.js';
3
3
  import { loadSourceDocument } from './source-document.js';
4
+ import { similarity } from './subject-identity.js';
4
5
  import projectionSchema from '../schema/yarramate-projection.schema.json' with { type: 'json'
5
6
  };
6
7
  const ajv2020Module = Ajv2020Import;
@@ -182,6 +183,131 @@ const conceptSelector = (graph, projection, profileContext) => {
182
183
  },
183
184
  };
184
185
  };
186
+ /** Below this, a suggestion is noise rather than a hint. */
187
+ const SUGGESTION_THRESHOLD = 0.6;
188
+ /**
189
+ * Every value in a projection query that names nothing.
190
+ *
191
+ * A projection is a DOCUMENT, and a query holds references the same way a
192
+ * relationship does. YarraMate refuses a relationship pointing at a concept
193
+ * that does not exist; it did not refuse a query naming a state that does not
194
+ * exist, and the symptom is silent. `states: [target-stat]` selects no state,
195
+ * which selects no subject, which exports a clean empty artifact with exit 0.
196
+ * Someone hands that to a client.
197
+ *
198
+ * Checked at `check`, not at `export`, because the typo is in a file rather
199
+ * than in an invocation: CI catches it, and every verb over the same
200
+ * projection inherits the guard instead of each growing its own.
201
+ *
202
+ * ONLY FACETS WITH A CLOSED NAMESPACE ARE CHECKED. `statuses` and
203
+ * `excludeStatuses` are schema enums, refused upstream before this runs.
204
+ * Everything else names something: `owners` and `constraints` are REFS to
205
+ * concepts, which the compiler itself proves by refusing an unresolved owner
206
+ * with YM304, so their namespace is the subject list like `subjects`.
207
+ *
208
+ * Each namespace is derived the way the FILTER derives it, so the check cannot
209
+ * drift from what it guards: `documents` reads the same provenance the
210
+ * `documents` facet compares against, and `states` is the same
211
+ * `yarramate/state/type` scan `conceptSelector` runs.
212
+ *
213
+ * A kind whose profile is not loaded is DORMANT rather than wrong, the same
214
+ * distinction #351 drew for question catalogues, so the kind facets are
215
+ * checked only when a profile context is present.
216
+ *
217
+ * An empty RESULT is not reported here and must not be. A query whose every
218
+ * name resolves and which selects nothing is a real answer to a real question:
219
+ * a target state nobody has populated yet is empty, correctly.
220
+ */
221
+ export function unmatchedSelectors(graph, projection, profileContext) {
222
+ const query = projection.query;
223
+ const found = [];
224
+ const check = (facet, values, known) => {
225
+ if (values === undefined)
226
+ return;
227
+ for (const value of values) {
228
+ if (known.has(value))
229
+ continue;
230
+ let nearest;
231
+ let best = 0;
232
+ for (const candidate of known) {
233
+ const score = similarity(value, candidate);
234
+ if (score > best) {
235
+ best = score;
236
+ nearest = candidate;
237
+ }
238
+ }
239
+ found.push({
240
+ facet,
241
+ value,
242
+ ...(nearest !== undefined && best >= SUGGESTION_THRESHOLD
243
+ ? { nearest }
244
+ : {}),
245
+ });
246
+ }
247
+ };
248
+ const subjectIds = new Set(graph.subjects.map(({ id }) => id));
249
+ check('subjects', query.subjects, subjectIds);
250
+ check('exclude', query.exclude, subjectIds);
251
+ // Against the SUBJECT list rather than against owners currently in use: a
252
+ // team that owns nothing yet is a real concept and selecting it is a real
253
+ // question with an empty answer, which is exactly what must not be refused.
254
+ check('owners', query.owners, subjectIds);
255
+ check('constraints', query.constraints, subjectIds);
256
+ // The same provenance `documents` filters on: a subject id no longer carries
257
+ // the document that declared it, so both read it off the kind claim.
258
+ const documentIds = new Set();
259
+ for (const claim of graph.claims) {
260
+ if (claim.predicate !== 'yarramate/concept/kind')
261
+ continue;
262
+ const document = claim.source?.document;
263
+ if (typeof document === 'string')
264
+ documentIds.add(document);
265
+ }
266
+ check('documents', query.documents, documentIds);
267
+ check('states', query.states, new Set(graph.claims
268
+ .filter(({ predicate }) => predicate === 'yarramate/state/type')
269
+ .map(({ subject }) => subject)));
270
+ if (profileContext !== undefined) {
271
+ check('kinds', query.kinds, new Set(profileContext.conceptKindLineages.keys()));
272
+ check('relationshipKinds', query.relationshipKinds, new Set(profileContext.relationshipKindLineages.keys()));
273
+ check('layers', query.layers, new Set(profileContext.conceptKindLayers.values()));
274
+ }
275
+ return found;
276
+ }
277
+ /**
278
+ * Where a value sits in the projection source, so the diagnostic is clickable.
279
+ * A query value appears once in a list item, and finding it by text is enough:
280
+ * the alternative is threading a YAML CST through a check that only needs to
281
+ * point at a line.
282
+ */
283
+ const locate = (source, value) => {
284
+ const lines = source.split('\n');
285
+ for (let index = 0; index < lines.length; index += 1) {
286
+ const at = lines[index].indexOf(value);
287
+ if (at >= 0)
288
+ return { line: index + 1, column: at + 1 };
289
+ }
290
+ return { line: 1, column: 1 };
291
+ };
292
+ /**
293
+ * {@link unmatchedSelectors} as diagnostics, built HERE rather than at each
294
+ * caller so `check`, and anything that adopts this later, refuse in identical
295
+ * words with an identical code.
296
+ */
297
+ export function projectionReferenceDiagnostics(source, projection, graph, profileContext) {
298
+ return unmatchedSelectors(graph, projection, profileContext).map(({ facet, value, nearest }) => ({
299
+ severity: 'error',
300
+ code: 'YM921',
301
+ message: `Projection query \`${facet}\` names ${JSON.stringify(value)}, ` +
302
+ 'which this workspace does not have, so it can never match. ' +
303
+ (nearest === undefined
304
+ ? 'Check the spelling against the model.'
305
+ : `Did you mean ${JSON.stringify(nearest)}?`),
306
+ path: source.path,
307
+ pointer: `/query/${facet}`,
308
+ ...locate(source.source, value),
309
+ }));
310
+ }
185
311
  /**
186
312
  * Every concept a query leaves out, and the facet that left it out.
187
313
  *