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.
- package/dist/adapters/visual/session-server.js +27 -1
- package/dist/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/adapters/visual/workspace-model.js +12 -5
- package/dist/ask-command.js +25 -16
- package/dist/catalogue-sources.d.ts +20 -0
- package/dist/catalogue-sources.js +39 -0
- package/dist/check-command.js +38 -9
- package/dist/cli-support.d.ts +7 -0
- package/dist/cli-support.js +4 -0
- package/dist/design-command.js +21 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/interrogate-command.d.ts +82 -1
- package/dist/interrogate-command.js +195 -29
- package/dist/interrogation-entry.d.ts +1 -1
- package/dist/interrogation-entry.js +1 -1
- package/dist/projection.d.ts +47 -0
- package/dist/projection.js +126 -0
- package/dist/visual-app-lib/editor.js +26771 -26713
- package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
- package/dist/visual-app-lib/types/projection.d.ts +47 -0
- package/dist/visual-app-lib/types/workspace.d.ts +15 -0
- package/dist/workbook-import-entry.d.ts +30 -0
- package/dist/workbook-import-entry.js +30 -0
- package/dist/workbook.js +15 -0
- package/dist/workspace.d.ts +15 -0
- package/dist/workspace.js +1 -0
- package/docs/CONSUMING-YARRAMATE.md +63 -0
- package/package.json +5 -1
- package/schema/yarramate-interrogation-report.schema.json +9 -0
- package/schema/yarramate-question-catalogue.schema.json +2 -3
- 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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
626
|
-
|
|
627
|
-
|
|
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: '
|
|
633
|
-
message: `
|
|
634
|
-
|
|
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
|
-
|
|
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';
|
package/dist/projection.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/projection.js
CHANGED
|
@@ -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
|
*
|