yarramate 1.0.0 → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  format: yarramate/question-catalogue/v1
2
2
  id: core-enrichment
3
- version: "1.0"
3
+ version: "1.1"
4
4
  profile: yarramate/core@0.1
5
5
  presentation:
6
6
  title: Core enrichment interview
@@ -1549,3 +1549,34 @@ questions:
1549
1549
  falsifiable. If nothing in scope is assigned to it, reclassify it
1550
1550
  to a kind that does carry a claim, or retire it with a description
1551
1551
  saying why it sits outside this architecture.
1552
+
1553
+ - id: succession-unscoped
1554
+ wave: hygiene
1555
+ since: "1.1"
1556
+ scope: subject
1557
+ subjects: {}
1558
+ trigger:
1559
+ - condition: unscoped-succession
1560
+ question: >-
1561
+ {subject.name} supersedes a predecessor that is still current. In what
1562
+ respect does it supersede it, and what remains of the predecessor?
1563
+ askPlain: >-
1564
+ {subject.name} takes over from something that is still here. What part
1565
+ does it take over, and what is the old one still needed for?
1566
+ materiality: >-
1567
+ A succession that replaced its predecessor outright says so by the
1568
+ predecessor being gone. One where both are still current is usually
1569
+ partial, and the part it does not cover is exactly what a reader
1570
+ planning against the model needs to know. An unqualified claim reads
1571
+ as a total replacement to every surface that consumes the field, and
1572
+ `ask --compare` reads the field rather than the prose beside it, so a
1573
+ partial succession recorded without its scope turns into a declared
1574
+ removal of something nobody is removing.
1575
+ authority: either
1576
+ resolution: >-
1577
+ Record the respect on the succession entry:
1578
+ `supersedes: [{ subject: <predecessor>, inRespectOf: <the part taken
1579
+ over> }]`. If the succession really is total, retire the predecessor,
1580
+ which says the same thing without a qualifier. If the two subjects
1581
+ simply coexist and neither takes over from the other, the succession
1582
+ is the wrong claim; remove it.
@@ -697,6 +697,7 @@ export function runAskCommand(options, cwd) {
697
697
  format: report.format,
698
698
  workspace: report.workspace,
699
699
  catalogue: report.catalogue,
700
+ semantics: report.semantics,
700
701
  summary: report.summary,
701
702
  waves: report.waves,
702
703
  };
@@ -135,6 +135,23 @@ export type IncrementalCompilationResult = ({
135
135
  readonly incremental: boolean;
136
136
  readonly cache: CompilationCache;
137
137
  };
138
+ /**
139
+ * A succession entry: a bare predecessor id, or one with the respect in which
140
+ * it was superseded.
141
+ *
142
+ * The scope is load-bearing rather than decorative. A model claimed that Zoekt
143
+ * superseded the Elasticsearch indexer, unqualified, while the source it was
144
+ * built from says Zoekt "handles only code search and does not replace
145
+ * Elasticsearch". The prose carried the qualifier and the field could not, and
146
+ * `ask --compare` reads the field, so the declared target architecture became
147
+ * the deletion of a component that is not being deleted (ADR 0109).
148
+ */
149
+ export type NativeSuccession = string | {
150
+ readonly subject: string;
151
+ readonly inRespectOf: string;
152
+ };
153
+ export declare const successionSubject: (entry: NativeSuccession) => string;
154
+ export declare const successionScope: (entry: NativeSuccession) => string | undefined;
138
155
  export { ATTESTATION_PREDICATE_PREFIX, attestationClaimValue, parseAttestationClaimValue, parseConstraintExpectsValue, type AttestationClaimParts, type ConstraintExpectsParts, } from './graph-claims.js';
139
156
  interface ResolvedPosition {
140
157
  readonly line: number;
package/dist/compiler.js CHANGED
@@ -48,6 +48,8 @@ const presenceClaimId = (subject, state) => `${subject}~present-in-${utf8Hex(sta
48
48
  // position, so reordering the YAML leaves the graph byte-identical.
49
49
  const aliasClaimId = (subject, alias) => `${subject}~alias-${utf8Hex(alias)}`;
50
50
  const distinctFromClaimId = (subject, other) => `${subject}~distinct-from-${utf8Hex(other)}`;
51
+ export const successionSubject = (entry) => typeof entry === 'string' ? entry : entry.subject;
52
+ export const successionScope = (entry) => typeof entry === 'string' ? undefined : entry.inRespectOf;
51
53
  const supersedesClaimId = (subject, predecessor) => `${subject}~supersedes-${utf8Hex(predecessor)}`;
52
54
  export { ATTESTATION_PREDICATE_PREFIX, attestationClaimValue, parseAttestationClaimValue, parseConstraintExpectsValue, } from './graph-claims.js';
53
55
  const localKindId = (identity) => identity.slice(identity.indexOf('#') + 1);
@@ -605,6 +607,7 @@ function compileWorkspaceResolved(parsed) {
605
607
  }
606
608
  seenDocumentIds.add(value.id);
607
609
  }
610
+ const forbidRules = [];
608
611
  const conceptByQualifiedId = new Map(documents.flatMap(({ value }) => value.concepts.map((concept) => [
609
612
  concept.id,
610
613
  {
@@ -613,6 +616,20 @@ function compileWorkspaceResolved(parsed) {
613
616
  document: value.id,
614
617
  },
615
618
  ])));
619
+ for (const { value } of documents) {
620
+ for (const concept of value.concepts) {
621
+ for (const rule of concept.forbids ?? []) {
622
+ forbidRules.push({
623
+ declaredBy: concept.id,
624
+ relationship: rule.relationship,
625
+ ...(rule.from === undefined ? {} : { from: rule.from }),
626
+ ...(rule.to === undefined ? {} : { to: rule.to }),
627
+ exceptFrom: new Set(rule.exceptFrom ?? []),
628
+ exceptTo: new Set(rule.exceptTo ?? []),
629
+ });
630
+ }
631
+ }
632
+ }
616
633
  // Subject identity is the authored id, unique across the workspace, so a
617
634
  // reference resolves as written. Kept as a named step rather than inlined
618
635
  // because every reference in the model passes through here, and that is
@@ -653,7 +670,7 @@ function compileWorkspaceResolved(parsed) {
653
670
  : [
654
671
  [
655
672
  concept.id,
656
- concept.supersedes.map((predecessor) => qualifyReference(value.id, predecessor)),
673
+ concept.supersedes.map((predecessor) => qualifyReference(value.id, successionSubject(predecessor))),
657
674
  ],
658
675
  ])));
659
676
  // Self-succession is a cycle of length one, but it has its own diagnostic
@@ -885,14 +902,14 @@ function compileWorkspaceResolved(parsed) {
885
902
  }
886
903
  for (const [supersedesIndex, predecessor] of (concept.supersedes ?? []).entries()) {
887
904
  const pointer = `/concepts/${index}/supersedes/${supersedesIndex}`;
888
- const predecessorIdentity = qualifyReference(value.id, predecessor);
905
+ const predecessorIdentity = qualifyReference(value.id, successionSubject(predecessor));
889
906
  const subjectIdentity = concept.id;
890
907
  if (!conceptByQualifiedId.has(predecessorIdentity)) {
891
908
  const source = location(['concepts', index, 'supersedes', supersedesIndex], pointer);
892
909
  diagnostics.push({
893
910
  severity: 'error',
894
911
  code: 'YM312',
895
- message: `Unresolved succession reference "${predecessor}"`,
912
+ message: `Unresolved succession reference "${successionSubject(predecessor)}"`,
896
913
  path: input.path,
897
914
  pointer,
898
915
  line: source.line,
@@ -1186,6 +1203,32 @@ function compileWorkspaceResolved(parsed) {
1186
1203
  const tail = candidates.length === 0
1187
1204
  ? ''
1188
1205
  : `; ArchiMate 3.2 permits: ${candidates.join(', ')}`;
1206
+ // A declared rule about the graph, checked against the graph. The
1207
+ // field is new, so no existing model can violate one: this can only
1208
+ // fire on a rule someone deliberately wrote (ADR 0108).
1209
+ for (const rule of forbidRules) {
1210
+ const kindMatches = rule.relationship === relationship.kind ||
1211
+ rule.relationship === policy.coreKind;
1212
+ if (!kindMatches)
1213
+ continue;
1214
+ if (rule.from !== undefined && rule.from !== relationship.from) {
1215
+ continue;
1216
+ }
1217
+ if (rule.to !== undefined && rule.to !== relationship.to)
1218
+ continue;
1219
+ if (rule.exceptFrom.has(relationship.from))
1220
+ continue;
1221
+ if (rule.exceptTo.has(relationship.to))
1222
+ continue;
1223
+ const pointer = `/relationships/${index}/kind`;
1224
+ diagnostics.push({
1225
+ severity: 'error',
1226
+ code: 'YM415',
1227
+ message: `Relationship "${relationship.kind}" from "${relationship.from}" ` +
1228
+ `to "${relationship.to}" is forbidden by "${rule.declaredBy}"`,
1229
+ ...location(['relationships', index, 'kind'], pointer),
1230
+ });
1231
+ }
1189
1232
  if (!permitted.has(policy.coreKind)) {
1190
1233
  const pointer = `/relationships/${index}/kind`;
1191
1234
  const source = location(['relationships', index, 'kind'], pointer);
@@ -1304,15 +1347,31 @@ function compileWorkspaceResolved(parsed) {
1304
1347
  // predecessor is not required to be retired: the transition period
1305
1348
  // during which both are current is real.
1306
1349
  for (const [supersedesIndex, predecessor] of (concept.supersedes ?? []).entries()) {
1307
- const predecessorIdentity = qualifyReference(value.id, predecessor);
1350
+ const predecessorIdentity = qualifyReference(value.id, successionSubject(predecessor));
1351
+ const scope = successionScope(predecessor);
1352
+ const successionSource = location(['concepts', index, 'supersedes', supersedesIndex], `/concepts/${index}/supersedes/${supersedesIndex}`);
1308
1353
  claims.push({
1309
1354
  id: supersedesClaimId(subject, predecessorIdentity),
1310
1355
  subject,
1311
1356
  predicate: 'yarramate/lineage/supersedes',
1312
1357
  object: { ref: predecessorIdentity },
1313
1358
  origin: 'declared',
1314
- source: location(['concepts', index, 'supersedes', supersedesIndex], `/concepts/${index}/supersedes/${supersedesIndex}`),
1359
+ source: successionSource,
1315
1360
  });
1361
+ // The respect is a claim of its own rather than a field on the
1362
+ // succession claim: `GraphClaim` is a triple, and widening it would
1363
+ // widen the published graph schema for one optional string. Its id is
1364
+ // the succession claim's, suffixed, so the two correlate.
1365
+ if (scope !== undefined) {
1366
+ claims.push({
1367
+ id: `${supersedesClaimId(subject, predecessorIdentity)}~respect`,
1368
+ subject,
1369
+ predicate: 'yarramate/lineage/supersedes-respect',
1370
+ object: { value: scope },
1371
+ origin: 'declared',
1372
+ source: successionSource,
1373
+ });
1374
+ }
1316
1375
  }
1317
1376
  if (concept.owner !== undefined) {
1318
1377
  claims.push({
@@ -37,6 +37,7 @@ const selectStep = (report, subjectFilter, askPlainById) => {
37
37
  : { askPlain: askPlainTemplate.trim() }),
38
38
  materiality: question.materiality,
39
39
  resolution: question.resolution,
40
+ trigger: question.trigger,
40
41
  ...(question.since === undefined ? {} : { since: question.since }),
41
42
  };
42
43
  }
@@ -57,6 +58,7 @@ const selectStep = (report, subjectFilter, askPlainById) => {
57
58
  : { askPlain: renderQuestion(askPlainTemplate, first.id, first.name) }),
58
59
  materiality: question.materiality,
59
60
  resolution: question.resolution,
61
+ trigger: question.trigger,
60
62
  ...(question.since === undefined ? {} : { since: question.since }),
61
63
  subject: {
62
64
  id: first.id,
@@ -74,6 +76,56 @@ const selectStep = (report, subjectFilter, askPlainById) => {
74
76
  }
75
77
  return null;
76
78
  };
79
+ const localKind = (qualified) => {
80
+ const hash = qualified.lastIndexOf('#');
81
+ return hash === -1 ? qualified : qualified.slice(hash + 1);
82
+ };
83
+ const skeletonHeader = (documentAddress, op) => [
84
+ '',
85
+ 'Prefilled skeleton (edit the <placeholders>, save as operations.yaml):',
86
+ ' format: yarramate/operations/v1',
87
+ ' operations:',
88
+ ` - op: ${op}`,
89
+ ` document: ${documentAddress}`,
90
+ ];
91
+ // The skeleton is a rendering of the step's trigger (#289), printed only
92
+ // when a single condition maps unambiguously onto one operation, so a
93
+ // wrong skeleton is never offered; every other trigger leaves the output
94
+ // exactly as before. Kinds print as local names: that is the form a
95
+ // native document declares.
96
+ const renderSkeleton = (step, documentAddress) => {
97
+ if (documentAddress === undefined || step.trigger.length !== 1)
98
+ return [];
99
+ const condition = step.trigger[0];
100
+ if (condition.condition === 'no-subject-of-kind') {
101
+ const kinds = condition.kinds.map(localKind);
102
+ const alternatives = kinds.length > 1 ? ` # or: ${kinds.slice(1).join(', ')}` : '';
103
+ return [
104
+ ...skeletonHeader(documentAddress, 'add-concept'),
105
+ ' concept:',
106
+ ' id: <kebab-case-id>',
107
+ ` kind: ${kinds[0]}${alternatives}`,
108
+ ' name: <one line>',
109
+ ];
110
+ }
111
+ if (condition.condition === 'missing-relationship' &&
112
+ step.subject !== undefined) {
113
+ const kinds = condition.kinds.map(localKind);
114
+ const alternatives = kinds.length > 1 ? ` # or: ${kinds.slice(1).join(', ')}` : '';
115
+ const swap = condition.direction === 'any' ? ' # or swap the endpoints' : '';
116
+ const from = condition.direction === 'incoming' ? '<counterpart-id>' : step.subject.id;
117
+ const to = condition.direction === 'incoming' ? step.subject.id : '<counterpart-id>';
118
+ return [
119
+ ...skeletonHeader(documentAddress, 'add-relationship'),
120
+ ' relationship:',
121
+ ' id: <kebab-case-id>',
122
+ ` kind: ${kinds[0]}${alternatives}`,
123
+ ` from: ${from}${swap}`,
124
+ ` to: ${to}`,
125
+ ];
126
+ }
127
+ return [];
128
+ };
77
129
  export function runDesignCommand(options, cwd) {
78
130
  const json = options.includes('--json');
79
131
  // Facilitation is a rendering preference, not an interview mode: the
@@ -232,7 +284,21 @@ export function runDesignCommand(options, cwd) {
232
284
  if (slice !== undefined) {
233
285
  lines.push('', 'Subject slice:', '', slice.trimEnd());
234
286
  }
235
- lines.push('', 'Answer by updating the model (one atomic batch):', ` yarramate apply <operations.yaml> ${workspacePath}`, `Then re-run: yarramate design ${workspacePath}`);
287
+ // The skeleton's document address is the manifest-relative form
288
+ // when the first document sits under the manifest directory - the
289
+ // address an author naturally writes and apply accepts (#216) -
290
+ // falling back to the workspace path, which apply also accepts.
291
+ const manifestDirectory = workspacePath.includes('/')
292
+ ? workspacePath.slice(0, workspacePath.lastIndexOf('/'))
293
+ : '';
294
+ const firstDocument = workspace.documents[0];
295
+ const documentAddress = firstDocument === undefined
296
+ ? undefined
297
+ : manifestDirectory !== '' &&
298
+ firstDocument.startsWith(`${manifestDirectory}/`)
299
+ ? firstDocument.slice(manifestDirectory.length + 1)
300
+ : firstDocument;
301
+ lines.push('', 'Answer by updating the model (one atomic batch):', ` yarramate apply <operations.yaml> ${workspacePath}`, ...renderSkeleton(step, documentAddress), `Then re-run: yarramate design ${workspacePath}`);
236
302
  }
237
303
  return { exitCode: 0, stdout: `${lines.join('\n')}\n`, stderr: '' };
238
304
  }
@@ -8,15 +8,39 @@ export interface EvidenceObservedValue {
8
8
  readonly key: string;
9
9
  readonly value: string;
10
10
  }
11
+ /**
12
+ * One search a provider ran and found nothing at, recorded so a reader can
13
+ * re-run it. yarramate never executes it: the engine has no access to the
14
+ * subject tree, and gaining one would be a different decision (ADR 0107).
15
+ */
16
+ export type SearchProbe = {
17
+ readonly glob: string;
18
+ } | {
19
+ readonly grep: string;
20
+ readonly paths?: readonly string[];
21
+ };
22
+ /**
23
+ * A figure quoted in an evidence message, with how it was produced, so a
24
+ * reader can tell a measured number from a remembered one and re-derive it at
25
+ * a later commit.
26
+ */
27
+ export interface Measurement {
28
+ readonly value: string;
29
+ readonly method: string;
30
+ }
31
+ interface ObservationProvenance {
32
+ readonly searched?: readonly SearchProbe[];
33
+ readonly measured?: readonly Measurement[];
34
+ }
11
35
  export type EvidenceObservation = ({
12
36
  readonly subject: string;
13
37
  readonly result: EvidenceResult;
14
38
  readonly evidence: EvidenceLocator;
15
- } & Partial<EvidenceObservedValue>) | ({
39
+ } & ObservationProvenance & Partial<EvidenceObservedValue>) | ({
16
40
  readonly claim: string;
17
41
  readonly result: EvidenceResult;
18
42
  readonly evidence: EvidenceLocator;
19
- } & Partial<EvidenceObservedValue>);
43
+ } & ObservationProvenance & Partial<EvidenceObservedValue>);
20
44
  export interface EvidenceDocument {
21
45
  readonly format: 'yarramate/evidence/v1';
22
46
  readonly id: string;
@@ -60,3 +84,4 @@ export type EvidenceWorkspaceEvaluationResult = {
60
84
  export declare function loadEvidence(source: WorkspaceSource): EvidenceLoadResult;
61
85
  export declare function evaluateEvidence(graph: SemanticGraph, evidence: EvidenceDocument): EvidenceEvaluationResult;
62
86
  export declare function evaluateEvidenceWorkspace(graph: SemanticGraph, evidenceDocuments: readonly EvidenceDocument[]): EvidenceWorkspaceEvaluationResult;
87
+ export {};
package/dist/index.d.ts CHANGED
@@ -18,3 +18,4 @@ export { applyOperations, landOperations, posixDirectoryOf, type ApplyInput, typ
18
18
  export { connectableKinds, draftRelationship, proposeRelationshipId, } from './relationship-drafting.js';
19
19
  export { draftConcept, proposeConceptId } from './concept-drafting.js';
20
20
  export { deletionBlockers, describeDeletion, draftDeletion, type DeletionBlocker, } from './deletion-drafting.js';
21
+ export { INTERROGATION_SEMANTICS_VERSION, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
package/dist/index.js CHANGED
@@ -16,3 +16,4 @@ export { applyOperations, landOperations, posixDirectoryOf, } from './apply-comm
16
16
  export { connectableKinds, draftRelationship, proposeRelationshipId, } from './relationship-drafting.js';
17
17
  export { draftConcept, proposeConceptId } from './concept-drafting.js';
18
18
  export { deletionBlockers, describeDeletion, draftDeletion, } from './deletion-drafting.js';
19
+ export { INTERROGATION_SEMANTICS_VERSION, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, } from './interrogate-command.js';
@@ -1,11 +1,38 @@
1
- import { type Diagnostic, type ResolvedProfileContext, type SemanticGraph, type WorkspaceSource } from './compiler.js';
2
- interface CatalogueSelector {
3
- readonly kinds: readonly string[];
1
+ import type { Diagnostic, ResolvedProfileContext, SemanticGraph, WorkspaceSource } from './compiler.js';
2
+ /**
3
+ * The version of condition evaluation itself, not of the package.
4
+ *
5
+ * A report says which catalogue asked its questions. It could not say which
6
+ * engine answered them, so a consumer holding stored answers could tell a
7
+ * model change from a catalogue deepening (via `since`) but not from a change
8
+ * in what a condition means. ADR 0097 replaced four aspect rules with the
9
+ * ArchiMate 3.2 table and flipped `missing-relationship` answers for unchanged
10
+ * models and unchanged questions; ADR 0083's `unconstrained-kind` goes
11
+ * near-empty under that same table. Neither was visible in any report.
12
+ *
13
+ * **Bump this when an existing question's answer can change for an unchanged
14
+ * model.** Do not bump it for anything else: not a release, not a new
15
+ * condition, not a catalogue edit, not a rendering change. A version that
16
+ * moves when answers did not is a version consumers learn to ignore.
17
+ *
18
+ * `test/interrogation-semantics.test.ts` fingerprints every condition against
19
+ * a fixture and fails if evaluation moves without this bumping, so the rule is
20
+ * enforced rather than remembered.
21
+ */
22
+ export declare const INTERROGATION_SEMANTICS_VERSION = "1";
23
+ export interface CatalogueSelector {
24
+ /**
25
+ * Kinds to select. Absent selects every concept, which is what a
26
+ * kind-agnostic condition wants: succession can be declared on any subject,
27
+ * so enumerating the kinds that may carry it would be a list nobody can keep
28
+ * right rather than a constraint (ADR 0109).
29
+ */
30
+ readonly kinds?: readonly string[];
4
31
  readonly kindMatching?: 'exact' | 'descendants';
5
32
  readonly statuses?: readonly string[];
6
33
  readonly documents?: readonly string[];
7
34
  }
8
- type CatalogueCondition = {
35
+ export type CatalogueCondition = {
9
36
  readonly condition: 'missing-claim';
10
37
  readonly predicate: string;
11
38
  } | {
@@ -56,6 +83,8 @@ type CatalogueCondition = {
56
83
  readonly condition: 'near-duplicate';
57
84
  } | {
58
85
  readonly condition: 'unconstrained-kind';
86
+ } | {
87
+ readonly condition: 'unscoped-succession';
59
88
  };
60
89
  export interface CatalogueQuestion {
61
90
  readonly id: string;
@@ -86,12 +115,12 @@ export interface QuestionCatalogue {
86
115
  }[];
87
116
  readonly questions: readonly CatalogueQuestion[];
88
117
  }
89
- interface OpenSubject {
118
+ export interface OpenSubject {
90
119
  readonly id: string;
91
120
  readonly name?: string;
92
121
  readonly question: string;
93
122
  }
94
- interface ReportQuestion {
123
+ export interface ReportQuestion {
95
124
  readonly id: string;
96
125
  readonly scope: 'workspace' | 'subject';
97
126
  readonly authority: 'human' | 'agent' | 'either';
@@ -99,23 +128,35 @@ interface ReportQuestion {
99
128
  readonly question: string;
100
129
  readonly materiality: string;
101
130
  readonly resolution: string;
131
+ /**
132
+ * The catalogue trigger, verbatim (#289). The conditions that opened a
133
+ * question are its machine-readable answer shape: a host builds the
134
+ * matching affordance (a prefilled form, an operations skeleton) from
135
+ * them instead of re-deriving the shape from its own catalogue copy and
136
+ * drifting from engine semantics.
137
+ */
138
+ readonly trigger: readonly CatalogueCondition[];
102
139
  readonly since?: string;
103
140
  readonly subjects?: readonly OpenSubject[];
104
141
  }
142
+ export interface ReportWave {
143
+ readonly id: string;
144
+ readonly name: string;
145
+ readonly questions: readonly ReportQuestion[];
146
+ }
147
+ export interface InterrogationSummary {
148
+ readonly questions: number;
149
+ readonly openQuestions: number;
150
+ readonly open: number;
151
+ }
105
152
  export interface InterrogationReport {
106
153
  readonly format: 'yarramate/interrogation-report/v1';
107
154
  readonly workspace: string;
108
155
  readonly catalogue: string;
109
- readonly summary: {
110
- readonly questions: number;
111
- readonly openQuestions: number;
112
- readonly open: number;
113
- };
114
- readonly waves: readonly {
115
- readonly id: string;
116
- readonly name: string;
117
- readonly questions: readonly ReportQuestion[];
118
- }[];
156
+ /** {@link INTERROGATION_SEMANTICS_VERSION} at the time of evaluation. */
157
+ readonly semantics: string;
158
+ readonly summary: InterrogationSummary;
159
+ readonly waves: readonly ReportWave[];
119
160
  }
120
161
  export declare const renderQuestion: (template: string, subjectId: string, subjectName: string | undefined, counterparts?: readonly string[]) => string;
121
162
  export declare function evaluateCatalogue(catalogue: QuestionCatalogue, graph: SemanticGraph, profileContext?: ResolvedProfileContext): Omit<InterrogationReport, 'workspace'>;
@@ -128,4 +169,3 @@ export type CatalogueLoadResult = {
128
169
  };
129
170
  export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource): CatalogueLoadResult;
130
171
  export declare function renderInterrogationReport(report: InterrogationReport): string;
131
- export {};
@@ -10,6 +10,27 @@ import catalogueSchema from '../schema/yarramate-question-catalogue.schema.json'
10
10
  const ajv2020Module = Ajv2020Module;
11
11
  const Ajv2020 = ajv2020Module.default ?? ajv2020Module;
12
12
  const validateCatalogue = new Ajv2020({ allErrors: true }).compile(catalogueSchema);
13
+ /**
14
+ * The version of condition evaluation itself, not of the package.
15
+ *
16
+ * A report says which catalogue asked its questions. It could not say which
17
+ * engine answered them, so a consumer holding stored answers could tell a
18
+ * model change from a catalogue deepening (via `since`) but not from a change
19
+ * in what a condition means. ADR 0097 replaced four aspect rules with the
20
+ * ArchiMate 3.2 table and flipped `missing-relationship` answers for unchanged
21
+ * models and unchanged questions; ADR 0083's `unconstrained-kind` goes
22
+ * near-empty under that same table. Neither was visible in any report.
23
+ *
24
+ * **Bump this when an existing question's answer can change for an unchanged
25
+ * model.** Do not bump it for anything else: not a release, not a new
26
+ * condition, not a catalogue edit, not a rendering change. A version that
27
+ * moves when answers did not is a version consumers learn to ignore.
28
+ *
29
+ * `test/interrogation-semantics.test.ts` fingerprints every condition against
30
+ * a fixture and fails if evaluation moves without this bumping, so the rule is
31
+ * enforced rather than remembered.
32
+ */
33
+ export const INTERROGATION_SEMANTICS_VERSION = '1';
13
34
  const indexGraph = (graph) => {
14
35
  const relationshipIds = new Set(graph.subjects
15
36
  .filter(({ type }) => type === 'relationship')
@@ -146,7 +167,9 @@ const selectSubjects = (index, selector, profileContext) => {
146
167
  // The schema's declared default for kindMatching is descendants, so a
147
168
  // profile-derived kind satisfies a catalogue written against its parent.
148
169
  const matching = selector.kindMatching ?? 'descendants';
149
- let ids = [...index.concepts].filter((id) => kindMatches(index.kindOf.get(id), selector.kinds, matching, profileContext));
170
+ let ids = selector.kinds === undefined
171
+ ? [...index.concepts]
172
+ : [...index.concepts].filter((id) => kindMatches(index.kindOf.get(id), selector.kinds, matching, profileContext));
150
173
  if (selector.statuses !== undefined) {
151
174
  const statuses = new Set(selector.statuses);
152
175
  ids = ids.filter((id) => {
@@ -217,6 +240,28 @@ const conditionHolds = (index, condition, subjectId, profileContext) => {
217
240
  switch (condition.condition) {
218
241
  case 'missing-claim':
219
242
  return !(index.claimsBySubject.get(subjectId) ?? []).some(({ predicate }) => predicate === condition.predicate);
243
+ case 'unscoped-succession': {
244
+ // A succession that replaced its predecessor outright says so by the
245
+ // predecessor being gone. One where both subjects are still current is
246
+ // usually partial, and the respect is the part a reader needs: a model
247
+ // claimed Zoekt superseded the Elasticsearch indexer while the source
248
+ // said Zoekt "does not replace" it for any scope but code search
249
+ // (ADR 0109). Fires only where the qualifier is missing AND the
250
+ // predecessor is still current, so a completed replacement stays quiet.
251
+ const claims = index.claimsBySubject.get(subjectId) ?? [];
252
+ return claims.some((claim) => {
253
+ if (claim.predicate !== 'yarramate/lineage/supersedes')
254
+ return false;
255
+ if (!('ref' in claim.object))
256
+ return false;
257
+ const scoped = claims.some((other) => other.predicate === 'yarramate/lineage/supersedes-respect' &&
258
+ other.id === `${claim.id}~respect`);
259
+ if (scoped)
260
+ return false;
261
+ const predecessorStatus = index.statusOf.get(claim.object.ref);
262
+ return predecessorStatus !== 'retired';
263
+ });
264
+ }
220
265
  case 'missing-relationship': {
221
266
  // Relationship kinds resolve through profile lineage by default, the
222
267
  // same rule as selectors: a catalogue written against core kinds must
@@ -398,6 +443,7 @@ export function evaluateCatalogue(catalogue, graph, profileContext) {
398
443
  question: question.question.trim(),
399
444
  materiality: question.materiality.trim(),
400
445
  resolution: question.resolution.trim(),
446
+ trigger: question.trigger,
401
447
  ...(question.since === undefined ? {} : { since: question.since }),
402
448
  };
403
449
  if (question.scope === 'workspace') {
@@ -431,6 +477,7 @@ export function evaluateCatalogue(catalogue, graph, profileContext) {
431
477
  return {
432
478
  format: 'yarramate/interrogation-report/v1',
433
479
  catalogue: `${catalogue.id}@${catalogue.version}`,
480
+ semantics: INTERROGATION_SEMANTICS_VERSION,
434
481
  summary: {
435
482
  questions: applicableQuestions.length,
436
483
  openQuestions,
@@ -0,0 +1 @@
1
+ export { INTERROGATION_SEMANTICS_VERSION, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
@@ -0,0 +1,10 @@
1
+ // The interrogation engine as a runtime-neutral entry point.
2
+ //
3
+ // The `.` barrel reaches node:fs, node:path and node:child_process through
4
+ // workspace, source-store and attestation-staleness, so a consumer running
5
+ // inside a Worker or a Durable Object cannot take the engine from there
6
+ // without dragging Node in behind it. This subpath carries the pure engine
7
+ // alone: catalogue loading takes a WorkspaceSource, evaluation takes an
8
+ // in-memory graph, and a test pins the import graph free of Node builtins.
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';
@@ -86,6 +86,13 @@ export interface ReconciliationReport {
86
86
  readonly contradicted: number;
87
87
  readonly unknown: number;
88
88
  readonly notObserved: number;
89
+ /**
90
+ * `not-observed` observations naming no search. A negative claim about a
91
+ * tree nobody read exhaustively is the one result whose message nothing
92
+ * can check, so the ones offering a reader nothing to re-run are counted
93
+ * rather than left to read like any other finding (ADR 0107).
94
+ */
95
+ readonly unsupportedAbsences?: number;
89
96
  readonly subjectsWithoutEvidence: number;
90
97
  readonly staleAttestations?: number;
91
98
  readonly unconfirmedAttestations?: number;
@@ -199,6 +199,7 @@ export function reconcileEvidenceReports(workspace, reports, graph, staleness) {
199
199
  contradicted: 0,
200
200
  unknown: 0,
201
201
  notObserved: 0,
202
+ unsupportedAbsences: 0,
202
203
  subjectsWithoutEvidence: unobservedSubjects.length,
203
204
  // Attestation staleness is assessed only when the caller derived it
204
205
  // (the reconcile command); the counter appears exactly then, so a
@@ -219,6 +220,7 @@ export function reconcileEvidenceReports(workspace, reports, graph, staleness) {
219
220
  ...(staleness?.findings ?? []),
220
221
  ...unconfirmed,
221
222
  ];
223
+ const absenceNotes = [];
222
224
  for (const report of reports) {
223
225
  summary.observations += report.observations.length;
224
226
  for (const observation of report.observations) {
@@ -228,6 +230,18 @@ export function reconcileEvidenceReports(workspace, reports, graph, staleness) {
228
230
  }
229
231
  if (observation.result === 'not-observed') {
230
232
  summary.notObserved += 1;
233
+ // A not-observed asserts a negative, and is the only result whose
234
+ // message nothing else in the pipeline can check: the locator it
235
+ // carries points at what the author looked at, not at the absence
236
+ // they claim. One naming no search offers a reader nothing to
237
+ // re-run, so it is counted and named rather than left to read like
238
+ // a checked finding (ADR 0107).
239
+ if ((observation.searched ?? []).length === 0) {
240
+ summary.unsupportedAbsences += 1;
241
+ const named = 'subject' in observation ? observation.subject : observation.claim;
242
+ absenceNotes.push(`The not-observed observation for ${named} names no search, ` +
243
+ `so nothing here can be re-run to test the absence it asserts.`);
244
+ }
231
245
  }
232
246
  else {
233
247
  summary[observation.result] += 1;
@@ -260,7 +274,7 @@ export function reconcileEvidenceReports(workspace, reports, graph, staleness) {
260
274
  (expectationOf(left)?.key ?? '').localeCompare(expectationOf(right)?.key ?? '') ||
261
275
  (expectationOf(left)?.observed ?? '').localeCompare(expectationOf(right)?.observed ?? ''));
262
276
  summary.findings = findings.length;
263
- const notes = staleness?.notes ?? [];
277
+ const notes = [...(staleness?.notes ?? []), ...absenceNotes];
264
278
  return {
265
279
  format: 'yarramate/reconciliation-report/v1',
266
280
  workspace,
@@ -12,6 +12,14 @@ export const SUBJECT_REFERENCE_POSITIONS = [
12
12
  path: ['concepts', '*', 'supersedes', '*'],
13
13
  form: 'reference',
14
14
  },
15
+ // The scoped succession form, `{ subject, inRespectOf }` (ADR 0109). A
16
+ // rename has to move this one too, or a scoped succession would silently
17
+ // keep pointing at the old address while the bare form beside it moved.
18
+ {
19
+ group: 'document',
20
+ path: ['concepts', '*', 'supersedes', '*', 'subject'],
21
+ form: 'reference',
22
+ },
15
23
  {
16
24
  group: 'document',
17
25
  path: ['concepts', '*', 'constraints', '*', 'ref'],