yarramate 1.14.1 → 1.15.1

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/compiler.js CHANGED
@@ -362,9 +362,22 @@ function compileWorkspaceResolved(parsed) {
362
362
  }
363
363
  const alreadyDeclaresPolicy = pendingProfiles.some(({ identity }) => identity === shippedPolicyIdentity);
364
364
  if (!alreadyDeclaresPolicy) {
365
+ // This probe runs BEFORE the document gate that rejects a source whose
366
+ // schema check failed, so it has to hold its own precondition: a source
367
+ // that composes to anything but a mapping - an empty file, a comment-only
368
+ // one, a bare scalar - selects no profile at all. It used to read
369
+ // `.profile` through an `as` cast, which is what hid the null from the
370
+ // typechecker, and an empty document crashed the whole compile with a
371
+ // `TypeError` instead of the `YM201 must be object` its schema already
372
+ // produces. Every other consumer of a parsed entry checks its diagnostics
373
+ // first (the profile walk above, the pattern walk below); this one could
374
+ // not, because it runs before that gate exists, so it narrows instead.
365
375
  const selected = documentInputs.some(({ entry }) => {
366
376
  const value = entry.value;
367
- return value.profile === shippedPolicyIdentity;
377
+ return (typeof value === 'object' &&
378
+ value !== null &&
379
+ value.profile ===
380
+ shippedPolicyIdentity);
368
381
  });
369
382
  const extended = pendingProfiles.some(({ value }) => value.extends === shippedPolicyIdentity);
370
383
  if (selected || extended) {
@@ -81,6 +81,43 @@ const localKind = (qualified) => {
81
81
  const hash = qualified.lastIndexOf('#');
82
82
  return hash === -1 ? qualified : qualified.slice(hash + 1);
83
83
  };
84
+ /**
85
+ * The `missing-claim` predicates that name a CONCEPT FIELD, and the field
86
+ * each one is written through (#430).
87
+ *
88
+ * `missing-claim` matches a raw predicate, and a predicate is not an
89
+ * authoring gesture: `yarramate/attestation/adequacy` is written by adding an
90
+ * attestation, `yarramate/reference/refers-to` by adding a reference, and a
91
+ * profile may mint predicates this engine has never heard of. So the
92
+ * condition cannot map to one operation in general, which is why an adopter
93
+ * wiring it up got a card with no affordance and filed #430.
94
+ *
95
+ * It maps for these three, and only these three, because each is a named
96
+ * field on a concept that `update-concept` writes directly. They are Core's
97
+ * own, closed, and they are every `missing-claim` the shipped catalogue uses
98
+ * — `owner-missing`, `information-unowned`, `concept-undescribed` and
99
+ * `status-missing`. Anything else returns no skeleton, which is ADR 0110's
100
+ * rule holding: a wrong skeleton is never offered.
101
+ *
102
+ * This is the "one new mapping case" ADR 0110 anticipated, not a remedy DSL.
103
+ * The engine still takes no position on which predicates a HOST should render
104
+ * as editable; the trigger carries the predicate and the host decides. This
105
+ * is the CLI rendering its own affordance, the same as the other two cases.
106
+ */
107
+ const CONCEPT_FIELD_PREDICATES = {
108
+ 'yarramate/ownership/owner': {
109
+ name: 'owner',
110
+ placeholder: '<owning-subject-id>',
111
+ },
112
+ 'yarramate/concept/description': {
113
+ name: 'description',
114
+ placeholder: '<one line>',
115
+ },
116
+ 'yarramate/lifecycle/status': {
117
+ name: 'status',
118
+ placeholder: '<planned|current|retired>',
119
+ },
120
+ };
84
121
  const skeletonHeader = (documentAddress, op) => [
85
122
  '',
86
123
  'Prefilled skeleton (edit the <placeholders>, save as operations.yaml):',
@@ -125,6 +162,17 @@ const renderSkeleton = (step, documentAddress) => {
125
162
  ` to: ${to}`,
126
163
  ];
127
164
  }
165
+ if (condition.condition === 'missing-claim' && step.subject !== undefined) {
166
+ const field = CONCEPT_FIELD_PREDICATES[condition.predicate];
167
+ if (field === undefined)
168
+ return [];
169
+ return [
170
+ ...skeletonHeader(documentAddress, 'update-concept'),
171
+ ' concept:',
172
+ ` id: ${step.subject.id.split('#').pop()}`,
173
+ ` ${field.name}: ${field.placeholder}`,
174
+ ];
175
+ }
128
176
  return [];
129
177
  };
130
178
  export function runDesignCommand(options, cwd) {
@@ -110,6 +110,35 @@ export type CatalogueCondition = {
110
110
  readonly direction: 'incoming' | 'outgoing' | 'either';
111
111
  readonly counterpartKinds: readonly string[];
112
112
  readonly kindMatching?: 'exact' | 'descendants';
113
+ } | {
114
+ /**
115
+ * The negative twin of `exists-linkage`, workspace-scope like it
116
+ * (#436, ADR 0138). Fires while NO concept in the workspace satisfies
117
+ * the linkage, so a question can ask "nothing anywhere links this way".
118
+ *
119
+ * It exists because a **vocabulary question** was asking the wrong
120
+ * thing. `below-subject-count` measures a population, and a vocabulary
121
+ * question means "did anyone survey this" — a proxy that fails in both
122
+ * directions, measured on a live engagement: two throwaway values close
123
+ * it dishonestly, while a truthful single-value estate can never close
124
+ * it at all.
125
+ *
126
+ * `MODEL-FLOOR.md` prescribes the answer's home: a classification axis
127
+ * is a `grouping` that aggregates its members, so a scheme aggregating
128
+ * its classes IS the statement that these are the classes. Asking "no
129
+ * scheme aggregates any class" needs this condition; asking how many
130
+ * classes exist does not reach it.
131
+ *
132
+ * Deliberately NOT `!exists-linkage` in the evaluator, for the reason
133
+ * `has-subject-of-kind` is not `!no-subject-of-kind`: written as its own
134
+ * check, an empty workspace falls out right rather than by double
135
+ * negative.
136
+ */
137
+ readonly condition: 'no-linkage-exists';
138
+ readonly kinds: readonly string[];
139
+ readonly direction: 'incoming' | 'outgoing' | 'either';
140
+ readonly counterpartKinds: readonly string[];
141
+ readonly kindMatching?: 'exact' | 'descendants';
113
142
  } | {
114
143
  readonly condition: 'missing-constraint';
115
144
  readonly kinds: readonly string[];
@@ -207,6 +207,7 @@ const namedKinds = (question) => {
207
207
  case 'missing-linkage':
208
208
  case 'has-linkage':
209
209
  case 'exists-linkage':
210
+ case 'no-linkage-exists':
210
211
  kinds.push(...condition.kinds, ...condition.counterpartKinds);
211
212
  break;
212
213
  default:
@@ -267,6 +268,7 @@ const CONDITION_SCOPE = {
267
268
  'below-subject-count': 'workspace',
268
269
  'no-state-defined': 'workspace',
269
270
  'exists-linkage': 'workspace',
271
+ 'no-linkage-exists': 'workspace',
270
272
  'missing-claim': 'subject',
271
273
  'missing-relationship': 'subject',
272
274
  isolated: 'subject',
@@ -413,6 +415,11 @@ const conditionHolds = (index, condition, subjectId, profileContext, evidence, m
413
415
  linkageHits(index, condition, subjectId, profileContext));
414
416
  case 'exists-linkage':
415
417
  return [...index.concepts].some((id) => linkageHits(index, condition, id, profileContext));
418
+ case 'no-linkage-exists':
419
+ // Its own check rather than `!exists-linkage`, so an empty workspace
420
+ // falls out right: no concepts means no linkage, which is exactly the
421
+ // model a vocabulary question is loudest about.
422
+ return ![...index.concepts].some((id) => linkageHits(index, condition, id, profileContext));
416
423
  case 'missing-constraint': {
417
424
  const matching = condition.kindMatching ?? 'descendants';
418
425
  return !(index.claimsBySubject.get(subjectId) ?? []).some((claim) => claim.predicate === 'yarramate/constraint/requires' &&
@@ -18,7 +18,13 @@ export type SubjectReferenceGroup = 'document' | 'projection' | 'evidence' | 'ad
18
18
  export type SubjectReferenceForm = 'declaration' | 'reference' | 'qualified';
19
19
  export interface SubjectReferencePosition {
20
20
  readonly group: SubjectReferenceGroup;
21
- /** Key path from the document root; `*` matches every sequence index. */
21
+ /**
22
+ * Key path from the document root; `*` matches every element of a
23
+ * collection, which is every index of a sequence and every value of a
24
+ * mapping whose keys are open. Both spell `*` because the schema, not the
25
+ * position, is what decides which one a path lands on: `items` and
26
+ * `patternProperties` are the same shape of "and now, each of these".
27
+ */
22
28
  readonly path: readonly string[];
23
29
  readonly form: SubjectReferenceForm;
24
30
  }
@@ -25,6 +25,15 @@ export const SUBJECT_REFERENCE_POSITIONS = [
25
25
  path: ['concepts', '*', 'constraints', '*', 'ref'],
26
26
  form: 'reference',
27
27
  },
28
+ // The subjects bound into a pattern instance's slots (ADR 0123). Keyed by
29
+ // slot name rather than indexed, which is why it went missing: this is the
30
+ // only address in any of the four schemas that lives in a mapping, and the
31
+ // completeness walker below derived only sequences.
32
+ {
33
+ group: 'document',
34
+ path: ['concepts', '*', 'parts', '*'],
35
+ form: 'reference',
36
+ },
28
37
  {
29
38
  group: 'document',
30
39
  path: ['concepts', '*', 'references', '*', 'ref'],
@@ -150,10 +159,22 @@ const collect = (node, path, pointer, form, documentId, source, hits, aliases) =
150
159
  }
151
160
  const [segment, ...rest] = path;
152
161
  if (segment === '*') {
153
- if (!isSeq(node))
162
+ if (isSeq(node)) {
163
+ for (const [index, item] of node.items.entries()) {
164
+ collect(item, rest, `${pointer}/${index}`, form, documentId, source, hits, aliases);
165
+ }
154
166
  return;
155
- for (const [index, item] of node.items.entries()) {
156
- collect(item, rest, `${pointer}/${index}`, form, documentId, source, hits, aliases);
167
+ }
168
+ // A mapping with open keys, which today is `parts` alone. The pointer
169
+ // segment is the authored key, so a diagnostic names the SLOT ("/parts/
170
+ // interface") rather than a position the reader would have to count out.
171
+ if (isMap(node)) {
172
+ for (const item of node.items) {
173
+ const key = isScalar(item.key) ? String(item.key.value) : undefined;
174
+ if (key === undefined)
175
+ continue;
176
+ collect(item.value, rest, `${pointer}/${key}`, form, documentId, source, hits, aliases);
177
+ }
157
178
  }
158
179
  return;
159
180
  }