yarramate 1.20.0 → 1.21.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.
@@ -141,7 +141,7 @@ export function prepareLikeC4Export(input) {
141
141
  };
142
142
  }
143
143
  }
144
- const projectionResult = evaluateProjection(compilation.graph, projection.projection, compilation.profileContext);
144
+ const projectionResult = evaluateProjection(compilation.graph, projection.projection, compilation.profileContext, compilation.patternMemberships);
145
145
  const comparison = input.comparison === undefined
146
146
  ? undefined
147
147
  : compareArchitectureStates(compilation.graph, input.comparison.from, input.comparison.to);
@@ -9,6 +9,7 @@
9
9
  * `node:path`, or the schema documents into its bundle.
10
10
  */
11
11
  import type { CanvasGraph } from "../../graph-projection.js";
12
+ import type { NestingKind } from "../../nesting.js";
12
13
  import type { YarramateApplyResult, YarramateOperation } from "../../operations.js";
13
14
  import type { ProjectionDefinition, ProjectionExclusion, ProjectionQuery } from "../../projection.js";
14
15
  /**
@@ -163,6 +164,20 @@ export interface VisualKindOption {
163
164
  }
164
165
  export interface VisualFilterQueryPayload {
165
166
  readonly query: ProjectionQuery;
167
+ /**
168
+ * The nesting the canvas is drawing with, when the browser knows it (#473
169
+ * phase 2).
170
+ *
171
+ * Only `query.instances` reads it, and it must: the closure of an instance IS
172
+ * the containment tree, so evaluating it under a different nesting than the
173
+ * canvas answers a different question. On the ApertureX reference that is 15
174
+ * subjects against 2.
175
+ *
176
+ * Optional, so an older browser and every filter that names no instance keep
177
+ * working unchanged; absent, the evaluator falls back to the default nesting
178
+ * exactly as it did before.
179
+ */
180
+ readonly nesting?: readonly NestingKind[];
166
181
  }
167
182
  export interface VisualFilterResultPayload {
168
183
  readonly query: ProjectionQuery;
@@ -690,9 +690,15 @@ export const startVisualServer = async (options) => {
690
690
  if (!started.ok)
691
691
  standingDiagnostics = started.diagnostics;
692
692
  }
693
- const filterMatchedIds = (query) => compiledWorkspace === undefined
693
+ const filterMatchedIds = (query, nesting) => compiledWorkspace === undefined
694
694
  ? []
695
- : matchedIdsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext);
695
+ : matchedIdsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext,
696
+ // A view's query can name `instances`, and the facet resolves to the
697
+ // instance alone without these (ADR 0144).
698
+ compiledWorkspace.patternMemberships,
699
+ // And it resolves the WRONG closure without the nesting the canvas is
700
+ // drawing with, which is a wrong number rather than a missing one.
701
+ nesting);
696
702
  /**
697
703
  * Why a query dropped what it dropped, as the editor's "excluded, and why"
698
704
  * list reads it (#248).
@@ -704,9 +710,9 @@ export const startVisualServer = async (options) => {
704
710
  * the reason the editor shows and the set the canvas draws cannot come from
705
711
  * two readings of the same query.
706
712
  */
707
- const filterExclusions = (query) => compiledWorkspace === undefined
713
+ const filterExclusions = (query, nesting) => compiledWorkspace === undefined
708
714
  ? []
709
- : exclusionsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext);
715
+ : exclusionsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext, compiledWorkspace.patternMemberships, nesting);
710
716
  let listening = false;
711
717
  let bootstrapSpent = false;
712
718
  let agentAttached = false;
@@ -1243,8 +1249,8 @@ export const startVisualServer = async (options) => {
1243
1249
  kind: "filter-result",
1244
1250
  result: {
1245
1251
  query: event.payload.query,
1246
- matchedIds: filterMatchedIds(event.payload.query),
1247
- excluded: filterExclusions(event.payload.query),
1252
+ matchedIds: filterMatchedIds(event.payload.query, event.payload.nesting),
1253
+ excluded: filterExclusions(event.payload.query, event.payload.nesting),
1248
1254
  },
1249
1255
  });
1250
1256
  return;
@@ -2,7 +2,7 @@ import type { Diagnostic } from "../../compiler.js";
2
2
  import type { PendingWrite, SourceStore } from "../../source-store.js";
3
3
  import type { ResolvedWorkspace } from "../../workspace.js";
4
4
  import type { VisualDiagnostic, VisualViewOperation } from "./protocol-contract.js";
5
- import type { ProjectionDefinition, ProjectionExclusion, ProjectionQuery } from "../../projection.js";
5
+ import type { NestingKind, ProjectionDefinition, ProjectionExclusion, ProjectionQuery } from "../../projection.js";
6
6
  import type { ResolvedProfileContext, SemanticGraph } from "../../compiler.js";
7
7
  import { type CataloguePatternMembership, type CataloguePatternVacancy } from "../../interrogate-command.js";
8
8
  import type { VisualKindOption, VisualViewSummary } from "./protocol-contract.js";
@@ -41,7 +41,7 @@ export declare const kindOptionsOf: (lineages: ReadonlyMap<string, readonly stri
41
41
  * subject count. A view over three components with two relationships between
42
42
  * them would read as five, and the reviewer counting boxes would find three.
43
43
  */
44
- export declare const conceptCountOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext) => number;
44
+ export declare const conceptCountOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext, memberships?: readonly CataloguePatternMembership[], nesting?: readonly NestingKind[]) => number;
45
45
  /**
46
46
  * Folds one interrogation report into what the canvas draws (#292).
47
47
  *
@@ -109,9 +109,9 @@ export declare const renderedWorkspaceOf: (compiled: {
109
109
  readonly views: readonly VisualViewSummary[];
110
110
  };
111
111
  /** Every subject a query draws, concepts and relationships alike. */
112
- export declare const matchedIdsOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext) => readonly string[];
112
+ export declare const matchedIdsOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext, memberships?: readonly CataloguePatternMembership[], nesting?: readonly NestingKind[]) => readonly string[];
113
113
  /** Every concept a query dropped, and the facet that dropped it (#248). */
114
- export declare const exclusionsOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext) => readonly ProjectionExclusion[];
114
+ export declare const exclusionsOf: (graph: SemanticGraph, query: ProjectionQuery, profileContext: ResolvedProfileContext, memberships?: readonly CataloguePatternMembership[], nesting?: readonly NestingKind[]) => readonly ProjectionExclusion[];
115
115
  /**
116
116
  * One saved view, as the rail reads it. `subjectCount` is the caller's,
117
117
  * because counting needs a compiled graph and a session builds its first list
@@ -45,7 +45,7 @@ export const kindOptionsOf = (lineages) => [...lineages.keys()].map((id) => ({
45
45
  * subject count. A view over three components with two relationships between
46
46
  * them would read as five, and the reviewer counting boxes would find three.
47
47
  */
48
- export const conceptCountOf = (graph, query, profileContext) => evaluateProjection(graph, adHoc(query), profileContext).subjects.filter(({ type }) => type === "concept").length;
48
+ export const conceptCountOf = (graph, query, profileContext, memberships, nesting) => evaluateProjection(graph, adHoc(query, nesting), profileContext, memberships).subjects.filter(({ type }) => type === "concept").length;
49
49
  export const interrogationOverlayOf = (compiled,
50
50
  /**
51
51
  * The catalogue, or the composed SET a workspace carries (#345, ADR 0129).
@@ -118,7 +118,14 @@ dismissed = []) => {
118
118
  export const renderedWorkspaceOf = (compiled, views, metadata, catalogue, dismissed) => {
119
119
  const refreshedViews = views.map((view) => ({
120
120
  ...view,
121
- subjectCount: conceptCountOf(compiled.graph, view.query, compiled.profileContext),
121
+ subjectCount: conceptCountOf(compiled.graph, view.query, compiled.profileContext,
122
+ // Without these an `instances` view counts 1 and the rail says so, which
123
+ // is a wrong number rather than a missing one (ADR 0144).
124
+ compiled.patternMemberships,
125
+ // Each view's OWN nesting, because each view's closure is its own. A rail
126
+ // sitting beside the canvas must not count a different tree than the
127
+ // canvas draws.
128
+ view.presentation?.nesting),
122
129
  }));
123
130
  const interrogation = catalogue === undefined
124
131
  ? undefined
@@ -148,18 +155,23 @@ export const renderedWorkspaceOf = (compiled, views, metadata, catalogue, dismis
148
155
  };
149
156
  };
150
157
  /** Every subject a query draws, concepts and relationships alike. */
151
- export const matchedIdsOf = (graph, query, profileContext) => evaluateProjection(graph, adHoc(query), profileContext).subjects.map(({ id }) => id);
158
+ export const matchedIdsOf = (graph, query, profileContext, memberships, nesting) => evaluateProjection(graph, adHoc(query, nesting), profileContext, memberships).subjects.map(({ id }) => id);
152
159
  /** Every concept a query dropped, and the facet that dropped it (#248). */
153
- export const exclusionsOf = (graph, query, profileContext) => explainProjection(graph, adHoc(query), profileContext);
160
+ export const exclusionsOf = (graph, query, profileContext, memberships, nesting) => explainProjection(graph, adHoc(query, nesting), profileContext, memberships);
154
161
  /**
155
162
  * A query on its own is not a projection, and every evaluator here wants one.
156
163
  * The id is a placeholder that never reaches a document.
157
164
  */
158
- const adHoc = (query) => ({
165
+ const adHoc = (query, nesting) => ({
159
166
  format: "yarramate/projection/v1",
160
167
  id: "ad-hoc",
161
168
  version: "0",
162
169
  query,
170
+ // `query.instances` resolves its closure through the view's nesting, so an
171
+ // ad-hoc projection that dropped the nesting would answer a DIFFERENT
172
+ // question than the canvas is drawing: 2 subjects against 15 on the
173
+ // ApertureX reference, with nothing to say it had (#473 phase 2).
174
+ ...(nesting === undefined ? {} : { presentation: { nesting } }),
163
175
  });
164
176
  /**
165
177
  * One saved view, as the rail reads it. `subjectCount` is the caller's,
@@ -784,7 +784,7 @@ export function runAskCommand(options, cwd) {
784
784
  });
785
785
  if (!loaded.ok)
786
786
  continue;
787
- const membership = evaluateProjection(graph, loaded.projection, compilation.profileContext);
787
+ const membership = evaluateProjection(graph, loaded.projection, compilation.profileContext, compilation.patternMemberships);
788
788
  for (const subject of membership.subjects) {
789
789
  covered.add(subject.id);
790
790
  }
@@ -857,7 +857,7 @@ export function runAskCommand(options, cwd) {
857
857
  });
858
858
  if (!loaded.ok)
859
859
  return failed(loaded.diagnostics);
860
- const evaluated = evaluateProjection(graph, loaded.projection, compilation.profileContext);
860
+ const evaluated = evaluateProjection(graph, loaded.projection, compilation.profileContext, compilation.patternMemberships);
861
861
  const result = {
862
862
  format: 'yarramate/ask-result/v1',
863
863
  workspace: workspace.id,
@@ -214,7 +214,18 @@ export function runCheckCommand(options, cwd) {
214
214
  // does not build would bury the real failure under its consequences.
215
215
  const referenceDiagnostics = result.ok
216
216
  ? loadedProjections.flatMap(({ source, loaded }) => loaded.ok
217
- ? projectionReferenceDiagnostics(source, loaded.projection, result.graph, result.profileContext)
217
+ ? projectionReferenceDiagnostics(source, loaded.projection, result.graph, result.profileContext,
218
+ // Instance-hood from BOTH lists. A membership row exists only
219
+ // for a BOUND slot, so an instance whose slots are all empty
220
+ // has none - and judging it by bindings alone would call a real
221
+ // instance "not an instance" on the day it was authored, before
222
+ // anything was wired into it. The honest question is not "did
223
+ // it bind anything" but "does the model know it as an
224
+ // instance" (rule 2).
225
+ new Set([
226
+ ...(result.patternMemberships ?? []).map(({ instance }) => instance),
227
+ ...(result.patternVacancies ?? []).map(({ instance }) => instance),
228
+ ]))
218
229
  : [])
219
230
  : [];
220
231
  const optionalDiagnostics = sortDiagnostics([
@@ -250,7 +250,10 @@ export function runExportCommand(options, cwd) {
250
250
  });
251
251
  if (!loadedProjection.ok)
252
252
  return failed(loadedProjection.diagnostics);
253
- result = evaluateProjection(compilation.graph, loadedProjection.projection, compilation.profileContext);
253
+ result = evaluateProjection(compilation.graph, loadedProjection.projection, compilation.profileContext,
254
+ // An AUTHORED projection can name `instances`, and the facet resolves
255
+ // to the instance alone without these (ADR 0144).
256
+ compilation.patternMemberships);
254
257
  }
255
258
  if (kind === 'xlsx') {
256
259
  // A workbook an architect can work in (#355). It takes a PROJECTION,
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { deriveAttestationStaleness } from './attestation-staleness.js';
11
11
  export { deriveArtifactCoverage, type ArtifactCoverage, type CoverageScopePattern, } from './artifact-coverage.js';
12
12
  export { buildRtm, renderRtmMarkdown, type RequirementsTraceabilityMatrix, type RtmAttestation, type RtmContextEntry, type RtmDescopedEntry, type RtmEvidenceVerdict, type RtmLineageEntry, type RtmRealizer, type RtmRow, type RtmSource, } from './rtm.js';
13
13
  export type { CompilationCache, CompilationResult, ContextualCompilationResult, IncrementalCompilationResult, ParsedWorkspaceSource, PatternMembership, PatternVacancy, Diagnostic, GraphClaim, GraphSource, SemanticGraph, ResolvedProfileContext, WorkspaceSource, } from './compiler.js';
14
- export { canonicalProjection, evaluateProjection, explainProjection, loadProjection, renderProjectionMarkdown, type ConceptFacet, type ProjectionExclusion, } from './projection.js';
14
+ export { canonicalProjection, evaluateProjection, explainProjection, instanceClosureOf, loadProjection, renderProjectionMarkdown, type ConceptFacet, type ProjectionExclusion, } from './projection.js';
15
15
  export { loadAdapterMapping, validateAdapterMapping, validateAdapterMappings, type AdapterMapping, type AdapterMappingLoadResult, type AdapterMappingValidationResult, type AdapterMappingsValidationResult, type AdapterSubjectMapping, } from './adapter-mapping.js';
16
16
  export type { LifecycleStatus, ProjectionDefinition, ProjectionLoadResult, ProjectionResult, } from './projection.js';
17
17
  export { createFileSystemStore, type PendingWrite, type SourceStore, type StoredSource, type WriteConflict, type WriteOutcome, } from './source-store.js';
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { constraintExpectsPredicate, reconcileEvidenceReports, } from './reconci
10
10
  export { deriveAttestationStaleness } from './attestation-staleness.js';
11
11
  export { deriveArtifactCoverage, } from './artifact-coverage.js';
12
12
  export { buildRtm, renderRtmMarkdown, } from './rtm.js';
13
- export { canonicalProjection, evaluateProjection, explainProjection, loadProjection, renderProjectionMarkdown, } from './projection.js';
13
+ export { canonicalProjection, evaluateProjection, explainProjection, instanceClosureOf, loadProjection, renderProjectionMarkdown, } from './projection.js';
14
14
  export { loadAdapterMapping, validateAdapterMapping, validateAdapterMappings, } from './adapter-mapping.js';
15
15
  export { createFileSystemStore, } from './source-store.js';
16
16
  export { applyOperations, landOperations, posixDirectoryOf, } from './apply-command.js';
@@ -6,6 +6,25 @@ export interface ProjectionDefinition {
6
6
  readonly version: string;
7
7
  readonly query: {
8
8
  readonly subjects?: readonly string[];
9
+ /**
10
+ * Pattern instances whose CONTENTS this query wants (#473, ADR 0144).
11
+ *
12
+ * Each id names an instance, and the facet selects that instance together
13
+ * with everything the fold tree would draw inside it - the same closure
14
+ * `presentation.fold: instances` collapses into one box, read through the
15
+ * view's own `nesting`. A view that wanted the box wanted what is in it.
16
+ *
17
+ * This is the ONE facet that adds rather than narrows, and it is why
18
+ * `subjects` and this are read as a single identity facet whose values
19
+ * combine with OR (`docs/PROJECTIONS.md`). Every other field still ANDs
20
+ * over the union: `instances` says which subjects are in play, and `kinds`
21
+ * or `statuses` narrow that set the way they always did.
22
+ *
23
+ * Hand-listing the members instead is what this replaces, and the list goes
24
+ * stale the moment the pattern binds another slot. The names here are the
25
+ * instance, not its parts, so the view follows the model.
26
+ */
27
+ readonly instances?: readonly string[];
9
28
  /**
10
29
  * Subjects this query would otherwise select and the author has taken out
11
30
  * (#267, ADR 0122). A facet view states a rule, and every interesting rule
@@ -90,6 +109,7 @@ export { DEFAULT_NESTING, type NestingKind } from './nesting.js';
90
109
  */
91
110
  export { DEFAULT_FOLD, type FoldMode } from './fold-tree.js';
92
111
  import type { NestingKind } from './nesting.js';
112
+ import { type FoldMembership } from './fold-tree.js';
93
113
  /**
94
114
  * Which way a view runs, and the default. Split out for the same reason as the
95
115
  * nesting vocabulary above, and re-exported here on the same terms (ADR 0121).
@@ -118,12 +138,40 @@ export declare function canonicalProjection(projection: ProjectionDefinition): P
118
138
  * A facet of a query, named the way the query names it. What
119
139
  * {@link explainProjection} reports as the reason a subject is not in a view.
120
140
  */
121
- export type ConceptFacet = 'exclude' | 'states' | 'subjects' | 'documents' | 'kinds' | 'layers' | 'statuses' | 'excludeStatuses' | 'owners' | 'constraints';
141
+ export type ConceptFacet = 'exclude' | 'states' | 'subjects' | 'instances' | 'documents' | 'kinds' | 'layers' | 'statuses' | 'excludeStatuses' | 'owners' | 'constraints';
122
142
  /** One subject a query dropped, and the facet that dropped it. */
123
143
  export interface ProjectionExclusion {
124
144
  readonly id: string;
125
145
  readonly facet: ConceptFacet;
126
146
  }
147
+ /**
148
+ * How a query decides about concepts, built once and shared by the two things
149
+ * that ask.
150
+ *
151
+ * `evaluateProjection` asks whether a subject is in; `explainProjection` asks
152
+ * why one is out. They must never be able to disagree, which is why there is
153
+ * one selector rather than a filter here and a reason-finder somewhere else.
154
+ */
155
+ /**
156
+ * Which subjects each named pattern instance holds, transitively.
157
+ *
158
+ * The SAME tree the canvas folds, computed from the same module over the same
159
+ * inputs, because a view that names an instance must select exactly what the
160
+ * box would have contained. Two implementations of "what is inside this
161
+ * instance" would be two answers to one question, and the one the reader sees
162
+ * is whichever they happened to open (rule: a test must go through the seam it
163
+ * is testing - so must a second caller).
164
+ *
165
+ * Nesting comes from THIS view's `presentation.nesting`, not the default, for
166
+ * the reason folding reads it too: containment is a property of the view, and a
167
+ * view that nests on composition alone holds less than one that also nests on
168
+ * assignment.
169
+ *
170
+ * Without `memberships` the closure is the named instances ALONE. That is a
171
+ * degradation, not an answer, and it is why `check` refuses rather than
172
+ * quietly returning a smaller view (#447, #450).
173
+ */
174
+ export declare function instanceClosureOf(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext, memberships?: readonly FoldMembership[]): ReadonlyMap<string, string>;
127
175
  /** A query facet naming something the model does not have. */
128
176
  export interface UnmatchedSelector {
129
177
  readonly facet: string;
@@ -170,7 +218,7 @@ export declare function unmatchedSelectors(graph: SemanticGraph, projection: Pro
170
218
  * caller so `check`, and anything that adopts this later, refuse in identical
171
219
  * words with an identical code.
172
220
  */
173
- export declare function projectionReferenceDiagnostics(source: WorkspaceSource, projection: ProjectionDefinition, graph: SemanticGraph, profileContext?: ResolvedProfileContext): readonly Diagnostic[];
221
+ export declare function projectionReferenceDiagnostics(source: WorkspaceSource, projection: ProjectionDefinition, graph: SemanticGraph, profileContext?: ResolvedProfileContext, instances?: ReadonlySet<string>): readonly Diagnostic[];
174
222
  /**
175
223
  * Every concept a query leaves out, and the facet that left it out.
176
224
  *
@@ -186,7 +234,7 @@ export declare function projectionReferenceDiagnostics(source: WorkspaceSource,
186
234
  * `additionalProperties: false` and this is a question about a query rather
187
235
  * than part of what a projection IS.
188
236
  */
189
- export declare function explainProjection(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext): readonly ProjectionExclusion[];
190
- export declare function evaluateProjection(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext): ProjectionResult;
237
+ export declare function explainProjection(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext, memberships?: readonly FoldMembership[]): readonly ProjectionExclusion[];
238
+ export declare function evaluateProjection(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext, memberships?: readonly FoldMembership[]): ProjectionResult;
191
239
  export declare function renderProjectionMarkdown(result: ProjectionResult, profileContext?: ResolvedProfileContext): string;
192
240
  export declare function renderBudgetedContext(result: ProjectionResult, budgetTokens: number): string;
@@ -15,6 +15,9 @@ export { DEFAULT_NESTING } from './nesting.js';
15
15
  * vocabulary above (#473).
16
16
  */
17
17
  export { DEFAULT_FOLD } from './fold-tree.js';
18
+ import { DEFAULT_NESTING as NESTING_DEFAULT } from './nesting.js';
19
+ import { foldTree } from './fold-tree.js';
20
+ import { kindLabelOf } from './kind-label.js';
18
21
  /**
19
22
  * Which way a view runs, and the default. Split out for the same reason as the
20
23
  * nesting vocabulary above, and re-exported here on the same terms (ADR 0121).
@@ -36,6 +39,7 @@ export function canonicalProjection(projection) {
36
39
  version: projection.version,
37
40
  query: {
38
41
  ...(query.subjects === undefined ? {} : { subjects: [...query.subjects].sort() }),
42
+ ...(query.instances === undefined ? {} : { instances: [...query.instances].sort() }),
39
43
  ...(query.documents === undefined ? {} : { documents: [...query.documents].sort() }),
40
44
  ...(query.kinds === undefined ? {} : { kinds: [...query.kinds].sort() }),
41
45
  ...(query.layers === undefined ? {} : { layers: [...query.layers].sort() }),
@@ -88,7 +92,106 @@ const claimReferences = (claims, subject, predicate) => claims.flatMap((claim) =
88
92
  * why one is out. They must never be able to disagree, which is why there is
89
93
  * one selector rather than a filter here and a reason-finder somewhere else.
90
94
  */
91
- const conceptSelector = (graph, projection, profileContext) => {
95
+ /**
96
+ * Which subjects each named pattern instance holds, transitively.
97
+ *
98
+ * The SAME tree the canvas folds, computed from the same module over the same
99
+ * inputs, because a view that names an instance must select exactly what the
100
+ * box would have contained. Two implementations of "what is inside this
101
+ * instance" would be two answers to one question, and the one the reader sees
102
+ * is whichever they happened to open (rule: a test must go through the seam it
103
+ * is testing - so must a second caller).
104
+ *
105
+ * Nesting comes from THIS view's `presentation.nesting`, not the default, for
106
+ * the reason folding reads it too: containment is a property of the view, and a
107
+ * view that nests on composition alone holds less than one that also nests on
108
+ * assignment.
109
+ *
110
+ * Without `memberships` the closure is the named instances ALONE. That is a
111
+ * degradation, not an answer, and it is why `check` refuses rather than
112
+ * quietly returning a smaller view (#447, #450).
113
+ */
114
+ export function instanceClosureOf(graph, projection, profileContext, memberships) {
115
+ const named = projection.query.instances;
116
+ const broughtInBy = new Map();
117
+ if (named === undefined || named.length === 0)
118
+ return broughtInBy;
119
+ // Sorted so that an instance nested inside another named instance resolves to
120
+ // a stable owner rather than to whichever the author typed first.
121
+ const roots = [...named].sort();
122
+ for (const root of roots)
123
+ broughtInBy.set(root, root);
124
+ if (memberships === undefined)
125
+ return broughtInBy;
126
+ const kindOf = new Map();
127
+ for (const claim of graph.claims) {
128
+ if (claim.predicate !== 'yarramate/concept/kind')
129
+ continue;
130
+ if ('value' in claim.object)
131
+ kindOf.set(claim.subject, claim.object.value);
132
+ }
133
+ const nodes = graph.subjects
134
+ .filter(({ type }) => type === 'concept')
135
+ .map(({ id }) => {
136
+ const kind = kindOf.get(id) ?? '';
137
+ return {
138
+ id,
139
+ kind,
140
+ // The same reading `projectGraphForCanvas` gives `coreKindLabel`: the
141
+ // nearest declared ancestor, labelled. Every rule in `foldTree` reads
142
+ // this and never the authored kind (#473).
143
+ coreKind: kindLabelOf(profileContext?.conceptKindLineages.get(kind)?.[0] ?? kind),
144
+ };
145
+ });
146
+ const edges = graph.subjects
147
+ .filter(({ type }) => type === 'relationship')
148
+ .flatMap(({ id }) => {
149
+ const claim = graph.claims.find((candidate) => candidate.id === id);
150
+ if (claim === undefined || !('ref' in claim.object))
151
+ return [];
152
+ return [
153
+ {
154
+ id,
155
+ kind: claim.predicate,
156
+ from: claim.subject,
157
+ to: claim.object.ref,
158
+ },
159
+ ];
160
+ });
161
+ const tree = foldTree({
162
+ nodes,
163
+ edges,
164
+ memberships,
165
+ nesting: projection.presentation?.nesting ?? NESTING_DEFAULT,
166
+ });
167
+ const childrenOf = new Map();
168
+ for (const [child, parent] of tree.parentOf) {
169
+ const siblings = childrenOf.get(parent);
170
+ if (siblings === undefined)
171
+ childrenOf.set(parent, [child]);
172
+ else
173
+ siblings.push(child);
174
+ }
175
+ // Transitive, because the box is: unfolding one reveals the folded rows
176
+ // inside it, and a view naming the outer instance wants those too.
177
+ for (const root of roots) {
178
+ const queue = [root];
179
+ while (queue.length > 0) {
180
+ const current = queue.pop();
181
+ for (const child of childrenOf.get(current) ?? []) {
182
+ if (broughtInBy.has(child))
183
+ continue;
184
+ broughtInBy.set(child, root);
185
+ queue.push(child);
186
+ }
187
+ }
188
+ }
189
+ return broughtInBy;
190
+ }
191
+ const conceptSelector = (graph, projection, profileContext, memberships) => {
192
+ // Computed once for the whole query rather than per subject: the fold tree is
193
+ // a fact about the model and the view, not about the subject being judged.
194
+ const instanceClosure = instanceClosureOf(graph, projection, profileContext, memberships);
92
195
  const architectureStateIds = new Set(graph.claims
93
196
  .filter(({ predicate }) => predicate === 'yarramate/state/type')
94
197
  .map(({ subject }) => subject));
@@ -143,8 +246,22 @@ const conceptSelector = (graph, projection, profileContext) => {
143
246
  (architectureStateIds.has(id) || !participatesInSelectedState(id))) {
144
247
  return 'states';
145
248
  }
146
- if (query.subjects !== undefined && !query.subjects.includes(id)) {
147
- return 'subjects';
249
+ // `subjects` and `instances` are ONE identity facet spelled two ways,
250
+ // and values within a facet combine with OR - the rule
251
+ // `docs/PROJECTIONS.md` already states for the values inside a list,
252
+ // applied to the two lists that name the same thing. Naming a subject and
253
+ // naming the instance that holds it are the same act of selection, so a
254
+ // query carrying both selects the union and every other facet then ANDs
255
+ // over it, exactly as before.
256
+ //
257
+ // The reported facet keeps `subjects` whenever the author wrote one, so
258
+ // that no view which exists today changes the answer it gives the editor;
259
+ // `instances` is reported only where it is the sole identity facet.
260
+ if (query.subjects !== undefined || query.instances !== undefined) {
261
+ if (query.subjects?.includes(id) !== true &&
262
+ !instanceClosure.has(id)) {
263
+ return query.subjects === undefined ? 'instances' : 'subjects';
264
+ }
148
265
  }
149
266
  if (query.documents !== undefined &&
150
267
  !query.documents.includes(documentId)) {
@@ -249,6 +366,11 @@ export function unmatchedSelectors(graph, projection, profileContext) {
249
366
  };
250
367
  const subjectIds = new Set(graph.subjects.map(({ id }) => id));
251
368
  check('subjects', query.subjects, subjectIds);
369
+ // Only whether the id names a subject at all. Whether that subject is a
370
+ // pattern INSTANCE is a second question with a different answer and its own
371
+ // diagnostic, because "you misspelled this" and "this is real but it is not
372
+ // an instance" send an author to two different places.
373
+ check('instances', query.instances, subjectIds);
252
374
  check('exclude', query.exclude, subjectIds);
253
375
  // Against the SUBJECT list rather than against owners currently in use: a
254
376
  // team that owns nothing yet is a real concept and selecting it is a real
@@ -296,8 +418,8 @@ const locate = (source, value) => {
296
418
  * caller so `check`, and anything that adopts this later, refuse in identical
297
419
  * words with an identical code.
298
420
  */
299
- export function projectionReferenceDiagnostics(source, projection, graph, profileContext) {
300
- return unmatchedSelectors(graph, projection, profileContext).map(({ facet, value, nearest }) => ({
421
+ export function projectionReferenceDiagnostics(source, projection, graph, profileContext, instances) {
422
+ const unmatched = unmatchedSelectors(graph, projection, profileContext).map(({ facet, value, nearest }) => ({
301
423
  severity: 'error',
302
424
  code: 'YM921',
303
425
  message: `Projection query \`${facet}\` names ${JSON.stringify(value)}, ` +
@@ -309,6 +431,50 @@ export function projectionReferenceDiagnostics(source, projection, graph, profil
309
431
  pointer: `/query/${facet}`,
310
432
  ...locate(source.source, value),
311
433
  }));
434
+ const named = projection.query.instances;
435
+ if (named === undefined)
436
+ return unmatched;
437
+ // The facet cannot be resolved without knowing which subjects are instances,
438
+ // and a caller that did not look is not the same as a workspace with none.
439
+ // Answering anyway would return the instances alone, which is a SMALLER view
440
+ // that reads exactly like a correct one - the failure #450 is about.
441
+ if (instances === undefined) {
442
+ return [
443
+ ...unmatched,
444
+ {
445
+ severity: 'error',
446
+ code: 'YM923',
447
+ message: 'Projection query `instances` cannot be resolved here, because this ' +
448
+ 'check was given no pattern instances to resolve it against. ' +
449
+ 'Without them the facet would select each named instance alone and ' +
450
+ 'silently drop everything it holds.',
451
+ path: source.path,
452
+ pointer: '/query/instances',
453
+ // The facet itself is what is wrong here, not any one name in it, so
454
+ // point at the word `instances` rather than at an arbitrary member.
455
+ ...locate(source.source, 'instances'),
456
+ },
457
+ ];
458
+ }
459
+ const knownIds = new Set(graph.subjects.map(({ id }) => id));
460
+ return [
461
+ ...unmatched,
462
+ ...named
463
+ // A name that resolves to nothing is already YM921's; saying it twice
464
+ // would send the author looking for a second fault.
465
+ .filter((value) => knownIds.has(value) && !instances.has(value))
466
+ .map((value) => ({
467
+ severity: 'error',
468
+ code: 'YM922',
469
+ message: `Projection query \`instances\` names ${JSON.stringify(value)}, ` +
470
+ 'which this workspace has but which is not a pattern instance, so ' +
471
+ 'it holds nothing for the facet to select. Name it under ' +
472
+ '`subjects` to select the subject itself.',
473
+ path: source.path,
474
+ pointer: '/query/instances',
475
+ ...locate(source.source, value),
476
+ })),
477
+ ];
312
478
  }
313
479
  /**
314
480
  * Every concept a query leaves out, and the facet that left it out.
@@ -325,8 +491,8 @@ export function projectionReferenceDiagnostics(source, projection, graph, profil
325
491
  * `additionalProperties: false` and this is a question about a query rather
326
492
  * than part of what a projection IS.
327
493
  */
328
- export function explainProjection(graph, projection, profileContext) {
329
- const { droppedBy } = conceptSelector(graph, projection, profileContext);
494
+ export function explainProjection(graph, projection, profileContext, memberships) {
495
+ const { droppedBy } = conceptSelector(graph, projection, profileContext, memberships);
330
496
  const exclusions = [];
331
497
  for (const subject of graph.subjects) {
332
498
  if (subject.type !== 'concept')
@@ -337,8 +503,8 @@ export function explainProjection(graph, projection, profileContext) {
337
503
  }
338
504
  return exclusions;
339
505
  }
340
- export function evaluateProjection(graph, projection, profileContext) {
341
- const { droppedBy, architectureStateIds, participatesInSelectedState } = conceptSelector(graph, projection, profileContext);
506
+ export function evaluateProjection(graph, projection, profileContext, memberships) {
507
+ const { droppedBy, architectureStateIds, participatesInSelectedState } = conceptSelector(graph, projection, profileContext, memberships);
342
508
  const endpointExcluded = (id) => {
343
509
  // An exclusion is final (#267, ADR 0122). Dropping the subject from the
344
510
  // initial selection alone would not be: `relationships: connected` adds