yarramate 1.16.0 → 1.18.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.
@@ -624,6 +624,8 @@ export const startVisualServer = async (options) => {
624
624
  // Threaded whole (ADR 0131): a narrow copy here is how a slot
625
625
  // question would silently never fire in the embedded pane.
626
626
  patternMemberships: compiled.patternMemberships,
627
+ // The vacant half, on the same rule (#447).
628
+ patternVacancies: compiled.patternVacancies,
627
629
  };
628
630
  const workspaceModel = renderedWorkspaceOf(compiledWorkspace, views, {
629
631
  authority: rendered.authority,
@@ -4,7 +4,7 @@ import type { ResolvedWorkspace } from "../../workspace.js";
4
4
  import type { VisualDiagnostic, VisualViewOperation } from "./protocol-contract.js";
5
5
  import type { ProjectionDefinition, ProjectionExclusion, ProjectionQuery } from "../../projection.js";
6
6
  import type { ResolvedProfileContext, SemanticGraph } from "../../compiler.js";
7
- import { type CataloguePatternMembership } from "../../interrogate-command.js";
7
+ import { type CataloguePatternMembership, type CataloguePatternVacancy } from "../../interrogate-command.js";
8
8
  import type { VisualKindOption, VisualViewSummary } from "./protocol-contract.js";
9
9
  import type { VisualInterrogationOverlay, VisualRenderedModel } from "./wire.js";
10
10
  /**
@@ -60,6 +60,8 @@ export declare const interrogationOverlayOf: (compiled: {
60
60
  readonly profileContext: ResolvedProfileContext;
61
61
  /** From the compilation (ADR 0131); absent, slot questions stay quiet. */
62
62
  readonly patternMemberships?: readonly CataloguePatternMembership[];
63
+ /** From the compilation (#447); absent, `missing-part` stays quiet. */
64
+ readonly patternVacancies?: readonly CataloguePatternVacancy[];
63
65
  },
64
66
  /**
65
67
  * The catalogue, or the composed SET a workspace carries (#345, ADR 0129).
@@ -94,6 +96,8 @@ export declare const renderedWorkspaceOf: (compiled: {
94
96
  readonly graph: SemanticGraph;
95
97
  readonly profileContext: ResolvedProfileContext;
96
98
  readonly patternMemberships?: readonly CataloguePatternMembership[];
99
+ /** Threaded on to the overlay (#447); absent, `missing-part` stays quiet. */
100
+ readonly patternVacancies?: readonly CataloguePatternVacancy[];
97
101
  }, views: readonly VisualViewSummary[], metadata: Omit<VisualRenderedModel, "graph" | "vocabulary" | "interrogation">, catalogue?: {
98
102
  readonly path: string;
99
103
  readonly source: string;
@@ -64,7 +64,7 @@ dismissed = []) => {
64
64
  const composed = composeCatalogues(Array.isArray(catalogue) ? catalogue : [catalogue]);
65
65
  if (!composed.ok)
66
66
  return undefined;
67
- const report = evaluateCatalogue(composed.composed.catalogue, compiled.graph, compiled.profileContext, undefined, composed.composed.catalogues, compiled.patternMemberships);
67
+ const report = evaluateCatalogue(composed.composed.catalogue, compiled.graph, compiled.profileContext, undefined, composed.composed.catalogues, compiled.patternMemberships, compiled.patternVacancies);
68
68
  const dismissedEverywhere = new Set(dismissed
69
69
  .filter(({ subject }) => subject === undefined)
70
70
  .map(({ questionId }) => questionId));
@@ -475,7 +475,7 @@ export function runAskCommand(options, cwd) {
475
475
  }, workspace, cwd), compilation.profileContext);
476
476
  if (!composed.ok)
477
477
  return failed(composed.diagnostics);
478
- const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues, compilation.patternMemberships);
478
+ const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues, compilation.patternMemberships, compilation.patternVacancies);
479
479
  const result = {
480
480
  format: 'yarramate/ask-result/v1',
481
481
  workspace: workspace.id,
@@ -719,7 +719,7 @@ export function runAskCommand(options, cwd) {
719
719
  evidenceObservations.push(...loaded.evidence.observations);
720
720
  }
721
721
  const report = {
722
- ...evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues, compilation.patternMemberships),
722
+ ...evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues, compilation.patternMemberships, compilation.patternVacancies),
723
723
  workspace: workspace.id,
724
724
  };
725
725
  // Field by field, to fix key ORDER in the emitted JSON. Every optional
@@ -1021,7 +1021,7 @@ export function runAskCommand(options, cwd) {
1021
1021
  return failed(loaded.diagnostics);
1022
1022
  evidenceDocuments.push(loaded.evidence);
1023
1023
  }
1024
- const report = evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues, compilation.patternMemberships);
1024
+ const report = evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues, compilation.patternMemberships, compilation.patternVacancies);
1025
1025
  const openQuestions = [];
1026
1026
  for (const wave of report.waves) {
1027
1027
  for (const question of wave.questions) {
@@ -109,6 +109,31 @@ export interface PatternMembership {
109
109
  readonly instance: string;
110
110
  readonly pattern: string;
111
111
  }
112
+ /**
113
+ * One slot of one pattern instance that nothing is bound into (#447): the
114
+ * shape of a question a pattern already knows to ask. The mirror of
115
+ * {@link PatternMembership}, and it has to be a second array rather than a
116
+ * widening of that one, because a vacancy has no `member` and making that
117
+ * field optional would break every reader.
118
+ *
119
+ * `required` is here because a vacancy on a SUCCESSFUL compile can be a
120
+ * required slot after all. The first draft of this shape left the field out,
121
+ * reasoning that a required slot left unbound is `YM416` and so never reaches
122
+ * a result. That is true only of an instance that declares `parts`: a concept
123
+ * whose kind has a pattern but which declares no parts at all is not a
124
+ * `PatternInstance`, never reaches YM416, and compiles clean with every slot
125
+ * vacant. So the flag is load-bearing rather than permanently `false`, and it
126
+ * is the difference between "you have not decided this yet" and "this model
127
+ * does not stand up without it".
128
+ */
129
+ export interface PatternVacancy {
130
+ readonly instance: string;
131
+ readonly pattern: string;
132
+ readonly slot: string;
133
+ readonly slotKind: string;
134
+ /** The pattern declares this part `required` (ADR 0123). */
135
+ readonly required: boolean;
136
+ }
112
137
  export type CompilationResult = {
113
138
  readonly ok: true;
114
139
  readonly graph: SemanticGraph;
@@ -119,6 +144,8 @@ export type CompilationResult = {
119
144
  * to `evaluateCatalogue`, or `fills-pattern-slot` never fires.
120
145
  */
121
146
  readonly patternMemberships?: readonly PatternMembership[];
147
+ /** Same rule as `patternMemberships`, for the same reason (#447). */
148
+ readonly patternVacancies?: readonly PatternVacancy[];
122
149
  } | {
123
150
  readonly ok: false;
124
151
  readonly diagnostics: readonly Diagnostic[];
@@ -128,6 +155,7 @@ export type ContextualCompilationResult = {
128
155
  readonly graph: SemanticGraph;
129
156
  readonly profileContext: ResolvedProfileContext;
130
157
  readonly patternMemberships?: readonly PatternMembership[];
158
+ readonly patternVacancies?: readonly PatternVacancy[];
131
159
  } | {
132
160
  readonly ok: false;
133
161
  readonly diagnostics: readonly Diagnostic[];
package/dist/compiler.js CHANGED
@@ -846,6 +846,18 @@ function compileWorkspaceResolved(parsed) {
846
846
  return diagnosticFailure(patternDiagnostics);
847
847
  }
848
848
  const patternInstances = [];
849
+ /**
850
+ * Concepts whose kind HAS a pattern but which declare no `parts` at all —
851
+ * the greenfield instance ADR 0123 named and left to a later phase, and the
852
+ * one an interview has the most to ask (#447).
853
+ *
854
+ * Deliberately NOT `patternInstances`. That list drives YM416, wiring
855
+ * expansion and membership, and adding these to it would start refusing
856
+ * workspaces that compile today. Instance-hood for the purpose of BEING
857
+ * ASKED is a wider question than instance-hood for the purpose of being
858
+ * expanded, so it gets its own list rather than a widened one.
859
+ */
860
+ const unbegunInstances = [];
849
861
  const documents = documentInputs.map(({ input, entry, fresh }) => {
850
862
  // Schema-checked by `parseWorkspaceSource`; the faults it found are the
851
863
  // `schemaDiagnostics` returned below, and they gate every later phase.
@@ -1562,9 +1574,17 @@ function compileWorkspaceResolved(parsed) {
1562
1574
  // A pattern instance is only COLLECTED here. What its slots bind may be
1563
1575
  // declared in any document, so the bindings cannot be checked until
1564
1576
  // every document has been read (#268, ADR 0123).
1577
+ const conceptKindIdentity = selectedProfile.conceptKinds.get(concept.kind)?.identity ?? concept.kind;
1578
+ if (concept.parts === undefined) {
1579
+ // Nothing to bind and nothing to expand, but the kind still promises a
1580
+ // shape, so the interview can still ask about it (#447).
1581
+ const pattern = patternsByKind.get(conceptKindIdentity);
1582
+ if (pattern !== undefined) {
1583
+ unbegunInstances.push({ instance: subject, pattern });
1584
+ }
1585
+ }
1565
1586
  if (concept.parts !== undefined) {
1566
- const kindIdentity = selectedProfile.conceptKinds.get(concept.kind)?.identity ??
1567
- concept.kind;
1587
+ const kindIdentity = conceptKindIdentity;
1568
1588
  const pattern = patternsByKind.get(kindIdentity);
1569
1589
  if (pattern === undefined) {
1570
1590
  const where = location(['concepts', index, 'parts'], `/concepts/${index}/parts`);
@@ -2067,6 +2087,13 @@ function compileWorkspaceResolved(parsed) {
2067
2087
  else
2068
2088
  group.push(entry);
2069
2089
  }
2090
+ /**
2091
+ * Every wire that wants to mint, keyed by the TRIPLE it names (#460).
2092
+ * Collected across all instances before any is minted, because two wires
2093
+ * belonging to different instances can name one triple and only one claim
2094
+ * may survive.
2095
+ */
2096
+ const wireCandidates = new Map();
2070
2097
  for (const { instance, pattern, bindings, sourceOf } of patternInstances) {
2071
2098
  const boundTo = new Map();
2072
2099
  for (const [slot, target] of bindings) {
@@ -2173,36 +2200,89 @@ function compileWorkspaceResolved(parsed) {
2173
2200
  }
2174
2201
  if (satisfied)
2175
2202
  continue;
2176
- const wireId = [
2203
+ // Collected rather than minted, because whether this wire mints
2204
+ // depends on wires belonging to OTHER instances (#460). Two wires can
2205
+ // name one triple, and before this they both minted: two relationship
2206
+ // claims for one relationship.
2207
+ const key = `${from}\u0000${wire.kindIdentity}\u0000${to}`;
2208
+ const candidate = {
2177
2209
  instance,
2178
- ...(wire.from === 'self' ? [] : [wire.from]),
2179
- wire.coreKind,
2180
- wire.to,
2181
- ].join('-');
2182
- if (declaredIds.has(wireId)) {
2183
- diagnostics.push({
2184
- severity: 'error',
2185
- code: 'YM420',
2186
- message: `The pattern for "${pattern.kindIdentity}" derives the wiring id ` +
2187
- `"${wireId}" for "${instance}", which is already a declared subject`,
2188
- path: where.path,
2189
- pointer: where.pointer,
2190
- line: where.line,
2191
- column: where.column,
2192
- });
2193
- continue;
2194
- }
2195
- declaredIds.add(wireId);
2196
- subjects.push({ id: wireId, type: 'relationship' });
2197
- claims.push({
2198
- id: wireId,
2199
- subject: from,
2200
- predicate: wire.kindIdentity,
2201
- object: { ref: to },
2202
- origin: 'declared',
2203
- source: where,
2210
+ pattern,
2211
+ wire,
2212
+ from,
2213
+ to,
2214
+ where,
2215
+ owner: wire.from === 'self',
2216
+ wireId: [
2217
+ instance,
2218
+ ...(wire.from === 'self' ? [] : [wire.from]),
2219
+ wire.coreKind,
2220
+ wire.to,
2221
+ ].join('-'),
2222
+ };
2223
+ const group = wireCandidates.get(key);
2224
+ if (group === undefined)
2225
+ wireCandidates.set(key, [candidate]);
2226
+ else
2227
+ group.push(candidate);
2228
+ }
2229
+ }
2230
+ // One claim per triple, whichever wires named it (#460, ADR 0141).
2231
+ //
2232
+ // OWNERSHIP decides the id, not walk order. A wire whose `from` is `self`
2233
+ // owns the edge, because the edge leaves that instance; a wire whose
2234
+ // `from` is a slot is a guest naming somebody else's edge, and defers. So
2235
+ // an edge with an owner keeps the ADR 0123 id it has always had, and
2236
+ // nothing moves for a workspace that compiles today.
2237
+ //
2238
+ // Where every wire is a guest the owner's wire is absent — typically its
2239
+ // instance is greenfield, and an unbound slot wires nothing — so there is
2240
+ // no ADR 0123 id to prefer and the id comes from the TRIPLE instead. That
2241
+ // is stable under adding or removing further guests, which sorting a
2242
+ // winner out of the guests would not be.
2243
+ for (const [, group] of [...wireCandidates].sort(([left], [right]) => left.localeCompare(right))) {
2244
+ const owners = group
2245
+ .filter(({ owner }) => owner)
2246
+ // Two owners on one triple cannot happen: `self` differs per instance,
2247
+ // and within one instance YM315 already refuses two slots naming one
2248
+ // subject. Sorted anyway rather than trusting that from a distance.
2249
+ .sort((left, right) => left.wireId.localeCompare(right.wireId));
2250
+ const winner = owners[0] ?? group[0];
2251
+ if (winner === undefined)
2252
+ continue;
2253
+ // A group of ONE is not a collision and keeps its ADR 0123 id, always.
2254
+ // This is the case that matters most, because a wire between two SLOTS
2255
+ // of one pattern (`component --composition--> interface`) has no `self`
2256
+ // endpoint and so no owner, yet nothing is competing with it. Deriving
2257
+ // its id from the triple would rename every such edge in every
2258
+ // workspace, which is exactly the migration ownership exists to avoid;
2259
+ // ADR 0123's own worked example is one of them.
2260
+ const wireId = group.length === 1 || owners.length > 0
2261
+ ? winner.wireId
2262
+ : [winner.from, winner.wire.coreKind, winner.to].join('-');
2263
+ if (declaredIds.has(wireId)) {
2264
+ diagnostics.push({
2265
+ severity: 'error',
2266
+ code: 'YM420',
2267
+ message: `The pattern for "${winner.pattern.kindIdentity}" derives the wiring id ` +
2268
+ `"${wireId}" for "${winner.instance}", which is already a declared subject`,
2269
+ path: winner.where.path,
2270
+ pointer: winner.where.pointer,
2271
+ line: winner.where.line,
2272
+ column: winner.where.column,
2204
2273
  });
2274
+ continue;
2205
2275
  }
2276
+ declaredIds.add(wireId);
2277
+ subjects.push({ id: wireId, type: 'relationship' });
2278
+ claims.push({
2279
+ id: wireId,
2280
+ subject: winner.from,
2281
+ predicate: winner.wire.kindIdentity,
2282
+ object: { ref: winner.to },
2283
+ origin: 'declared',
2284
+ source: winner.where,
2285
+ });
2206
2286
  }
2207
2287
  }
2208
2288
  // ---- macro edges through ports (#268 phase 2, ADR 0124) ------------------
@@ -2349,9 +2429,44 @@ function compileWorkspaceResolved(parsed) {
2349
2429
  left.pattern.localeCompare(right.pattern) ||
2350
2430
  left.instance.localeCompare(right.instance) ||
2351
2431
  left.slot.localeCompare(right.slot));
2432
+ // The mirror (#447): one entry per slot nothing was bound into. Always
2433
+ // emitted, possibly empty — an empty array is a workspace whose instances
2434
+ // are fully bound, while a missing array is a caller that never looked.
2435
+ //
2436
+ // Derived from BOTH lists, and that is the substance of the thing. An
2437
+ // instance that declares some parts is asked about the rest; an instance
2438
+ // that declares none is asked about all of them, because a template nobody
2439
+ // has begun filling in is the one with the most blanks, not the one with
2440
+ // none. Reading only `patternInstances` here would have reported `[]` for
2441
+ // it, and `[]` means "fully bound".
2442
+ const patternVacancies = [
2443
+ ...patternInstances.map(({ instance, pattern, bindings }) => ({
2444
+ instance,
2445
+ pattern,
2446
+ bound: bindings,
2447
+ })),
2448
+ ...unbegunInstances.map(({ instance, pattern }) => ({
2449
+ instance,
2450
+ pattern,
2451
+ bound: new Map(),
2452
+ })),
2453
+ ]
2454
+ .flatMap(({ instance, pattern, bound }) => [...pattern.slots.values()]
2455
+ .filter((slot) => !bound.has(slot.name))
2456
+ .map((slot) => ({
2457
+ instance,
2458
+ pattern: pattern.kindIdentity,
2459
+ slot: slot.name,
2460
+ slotKind: slot.kindIdentity,
2461
+ required: slot.required === true,
2462
+ })))
2463
+ .sort((left, right) => left.instance.localeCompare(right.instance) ||
2464
+ left.pattern.localeCompare(right.pattern) ||
2465
+ left.slot.localeCompare(right.slot));
2352
2466
  return {
2353
2467
  ok: true,
2354
2468
  patternMemberships,
2469
+ patternVacancies,
2355
2470
  profileContext: {
2356
2471
  conceptKindLineages: immutableMap([...conceptKindByIdentity]
2357
2472
  .sort(([left], [right]) => left.localeCompare(right))
@@ -2441,6 +2556,7 @@ export function compileWorkspace(sources) {
2441
2556
  ok: true,
2442
2557
  graph: result.graph,
2443
2558
  patternMemberships: result.patternMemberships,
2559
+ patternVacancies: result.patternVacancies,
2444
2560
  }
2445
2561
  : result;
2446
2562
  }
@@ -294,7 +294,7 @@ export function runDesignCommand(options, cwd) {
294
294
  };
295
295
  }
296
296
  }
297
- const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues, compilation.patternMemberships);
297
+ const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues, compilation.patternMemberships, compilation.patternVacancies);
298
298
  // Keyed by the QUALIFIED id, matching what the report now carries.
299
299
  const askPlainById = new Map(composed.composed.catalogue.questions.flatMap((question) => question.askPlain === undefined
300
300
  ? []
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { constraintExpectsPredicate, reconcileEvidenceReports, type AssertedRela
10
10
  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
- export type { CompilationCache, CompilationResult, ContextualCompilationResult, IncrementalCompilationResult, ParsedWorkspaceSource, PatternMembership, Diagnostic, GraphClaim, GraphSource, SemanticGraph, ResolvedProfileContext, WorkspaceSource, } from './compiler.js';
13
+ export type { CompilationCache, CompilationResult, ContextualCompilationResult, IncrementalCompilationResult, ParsedWorkspaceSource, PatternMembership, PatternVacancy, Diagnostic, GraphClaim, GraphSource, SemanticGraph, ResolvedProfileContext, WorkspaceSource, } from './compiler.js';
14
14
  export { canonicalProjection, evaluateProjection, explainProjection, 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';
@@ -19,4 +19,4 @@ export { applyOperations, landOperations, posixDirectoryOf, type ApplyInput, typ
19
19
  export { connectableKinds, draftRelationship, proposeRelationshipId, stagedSubjectIds, } from './relationship-drafting.js';
20
20
  export { draftConcept, proposeConceptId } from './concept-drafting.js';
21
21
  export { deletionBlockers, describeDeletion, draftDeletion, type DeletionBlocker, } from './deletion-drafting.js';
22
- export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueEvidenceObservation, type CataloguePatternMembership, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
22
+ export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueEvidenceObservation, type CataloguePatternMembership, type CataloguePatternVacancy, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
@@ -174,6 +174,25 @@ export type CatalogueCondition = {
174
174
  readonly condition: 'fills-pattern-slot';
175
175
  readonly patternKinds?: readonly string[];
176
176
  readonly slots?: readonly string[];
177
+ } | {
178
+ /**
179
+ * The subject is a pattern INSTANCE with a slot nothing is bound into
180
+ * (#447). The mirror of `fills-pattern-slot`, and the mechanism by which
181
+ * a pattern becomes a questionnaire: bare, it means any slot of this
182
+ * instance's pattern is unbound; `patternKinds` narrows by the pattern's
183
+ * kind identity and `slots` by part name, exactly as its mirror does.
184
+ *
185
+ * It fires for a REQUIRED slot too, but only where one can survive a
186
+ * compile: an instance that declares `parts` and omits a required one is
187
+ * YM416 and there is no result to read, while an instance that declares
188
+ * no `parts` at all never reaches YM416 and is exactly the greenfield
189
+ * case ADR 0123 left open. The vacancy row's `required` tells the two
190
+ * apart for the host; the condition itself does not read it, because a
191
+ * catalogue that wants only one of them says so with `slots`.
192
+ */
193
+ readonly condition: 'missing-part';
194
+ readonly patternKinds?: readonly string[];
195
+ readonly slots?: readonly string[];
177
196
  };
178
197
  /**
179
198
  * One observation from the workspace's evidence overlay, reduced to what
@@ -207,6 +226,26 @@ export interface CataloguePatternMembership {
207
226
  readonly instance: string;
208
227
  readonly pattern: string;
209
228
  }
229
+ /**
230
+ * One vacant optional slot of one pattern instance, as interrogation reads
231
+ * it. Structural for the same reason {@link CataloguePatternMembership} is:
232
+ * a host passes `compilation.patternVacancies` and the compiler's types are
233
+ * never dragged into the pure engine entry. Only `missing-part` reads it.
234
+ */
235
+ export interface CataloguePatternVacancy {
236
+ readonly instance: string;
237
+ readonly pattern: string;
238
+ readonly slot: string;
239
+ readonly slotKind: string;
240
+ /**
241
+ * The pattern declares this part required. The condition does NOT read it —
242
+ * a vacancy is a vacancy — but it travels because the host derives its
243
+ * answer shape from this row, and "you have not decided this yet" and "this
244
+ * model does not stand up without it" are different questions to put to a
245
+ * person (#447).
246
+ */
247
+ readonly required: boolean;
248
+ }
210
249
  export interface CatalogueQuestion {
211
250
  readonly id: string;
212
251
  readonly wave: string;
@@ -329,7 +368,13 @@ catalogues?: readonly string[],
329
368
  * `catalogues` is a fifth: this signature is published and a consumer
330
369
  * already calls it.
331
370
  */
332
- patternMemberships?: readonly CataloguePatternMembership[]): Omit<InterrogationReport, 'workspace'>;
371
+ patternMemberships?: readonly CataloguePatternMembership[],
372
+ /**
373
+ * Pattern vacancies from the compilation (#447) - pass
374
+ * `compilation.patternVacancies`, or `missing-part` conditions never fire.
375
+ * A seventh optional parameter for the same reason the sixth is one.
376
+ */
377
+ patternVacancies?: readonly CataloguePatternVacancy[]): Omit<InterrogationReport, 'workspace'>;
333
378
  export type CatalogueLoadResult = {
334
379
  readonly ok: true;
335
380
  readonly catalogue: QuestionCatalogue;
@@ -274,10 +274,23 @@ const CONDITION_SCOPE = {
274
274
  'unscoped-succession': 'subject',
275
275
  'unchallenged-evidence': 'workspace',
276
276
  'fills-pattern-slot': 'subject',
277
+ 'missing-part': 'subject',
277
278
  };
278
279
  export const conditionScope = (condition) => CONDITION_SCOPE[condition.condition];
279
- const conditionHolds = (index, condition, subjectId, profileContext, evidence, memberships) => {
280
+ const conditionHolds = (index, condition, subjectId, profileContext, evidence, memberships, vacancies) => {
280
281
  switch (condition.condition) {
282
+ case 'missing-part':
283
+ // Absent vacancies stay quiet, exactly as absent memberships do below:
284
+ // the caller did not derive them, so what is unbound is unknown rather
285
+ // than nothing. An EMPTY array is the opposite and says so - every
286
+ // instance is fully bound (#447, CONTRIBUTING's second rule).
287
+ return (vacancies !== undefined &&
288
+ subjectId !== undefined &&
289
+ vacancies.some((vacancy) => vacancy.instance === subjectId &&
290
+ (condition.patternKinds === undefined ||
291
+ condition.patternKinds.includes(vacancy.pattern)) &&
292
+ (condition.slots === undefined ||
293
+ condition.slots.includes(vacancy.slot))));
281
294
  case 'fills-pattern-slot':
282
295
  // Absent memberships stay quiet: the caller did not derive them, so
283
296
  // participation is unknown, not absent — the same rule
@@ -553,13 +566,19 @@ catalogues,
553
566
  * `catalogues` is a fifth: this signature is published and a consumer
554
567
  * already calls it.
555
568
  */
556
- patternMemberships) {
569
+ patternMemberships,
570
+ /**
571
+ * Pattern vacancies from the compilation (#447) - pass
572
+ * `compilation.patternVacancies`, or `missing-part` conditions never fire.
573
+ * A seventh optional parameter for the same reason the sixth is one.
574
+ */
575
+ patternVacancies) {
557
576
  const index = indexGraph(graph);
558
577
  let open = 0;
559
578
  let openQuestions = 0;
560
579
  const applicableQuestions = catalogue.questions.filter((question) => questionIsApplicable(question, graph.profiles));
561
580
  const waveOpens = (wave) => wave.opensWhen === undefined ||
562
- wave.opensWhen.every((condition) => conditionHolds(index, condition, undefined, profileContext, evidence, patternMemberships));
581
+ wave.opensWhen.every((condition) => conditionHolds(index, condition, undefined, profileContext, evidence, patternMemberships, patternVacancies));
563
582
  const waves = catalogue.waves.map((wave) => ({
564
583
  id: wave.id,
565
584
  name: wave.name,
@@ -584,7 +603,7 @@ patternMemberships) {
584
603
  ...(question.since === undefined ? {} : { since: question.since }),
585
604
  };
586
605
  if (question.scope === 'workspace') {
587
- const isOpen = question.trigger.every((condition) => conditionHolds(index, condition, undefined, profileContext, evidence, patternMemberships));
606
+ const isOpen = question.trigger.every((condition) => conditionHolds(index, condition, undefined, profileContext, evidence, patternMemberships, patternVacancies));
588
607
  if (isOpen) {
589
608
  open += 1;
590
609
  openQuestions += 1;
@@ -600,7 +619,7 @@ patternMemberships) {
600
619
  if (selected.length === 0) {
601
620
  return { ...base, open: false, asked: false };
602
621
  }
603
- const matches = selected.filter((id) => question.trigger.every((condition) => conditionHolds(index, condition, id, profileContext, evidence, patternMemberships)));
622
+ const matches = selected.filter((id) => question.trigger.every((condition) => conditionHolds(index, condition, id, profileContext, evidence, patternMemberships, patternVacancies)));
604
623
  if (matches.length === 0) {
605
624
  return { ...base, open: false };
606
625
  }