yarramate 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/adapters/visual/session-server.js +27 -1
  2. package/dist/adapters/visual/workspace-model.d.ts +16 -3
  3. package/dist/adapters/visual/workspace-model.js +12 -5
  4. package/dist/ask-command.js +25 -16
  5. package/dist/catalogue-sources.d.ts +20 -0
  6. package/dist/catalogue-sources.js +39 -0
  7. package/dist/check-command.js +38 -9
  8. package/dist/cli-support.d.ts +7 -0
  9. package/dist/cli-support.js +4 -0
  10. package/dist/design-command.js +21 -9
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/interrogate-command.d.ts +82 -1
  14. package/dist/interrogate-command.js +195 -29
  15. package/dist/interrogation-entry.d.ts +1 -1
  16. package/dist/interrogation-entry.js +1 -1
  17. package/dist/projection.d.ts +47 -0
  18. package/dist/projection.js +126 -0
  19. package/dist/visual-app-lib/editor.js +26771 -26713
  20. package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
  21. package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
  22. package/dist/visual-app-lib/types/projection.d.ts +47 -0
  23. package/dist/visual-app-lib/types/workspace.d.ts +15 -0
  24. package/dist/workbook-import-entry.d.ts +30 -0
  25. package/dist/workbook-import-entry.js +30 -0
  26. package/dist/workbook.js +15 -0
  27. package/dist/workspace.d.ts +15 -0
  28. package/dist/workspace.js +1 -0
  29. package/docs/CONSUMING-YARRAMATE.md +63 -0
  30. package/package.json +5 -1
  31. package/schema/yarramate-interrogation-report.schema.json +9 -0
  32. package/schema/yarramate-question-catalogue.schema.json +2 -3
  33. package/schema/yarramate-workspace.schema.json +4 -0
@@ -124,6 +124,28 @@ const shippedCatalogue = (() => {
124
124
  return undefined;
125
125
  }
126
126
  })();
127
+ /**
128
+ * The shipped catalogue plus the ones the workspace carries (#345, ADR 0129).
129
+ *
130
+ * A catalogue that cannot be read is SKIPPED rather than failing the session,
131
+ * which is what the shipped one already did: an interrogation overlay is a
132
+ * garnish on a model, and losing a session over it would be worse than losing
133
+ * the questions. `check` is where a broken catalogue is refused.
134
+ */
135
+ const catalogueSetFor = (workspace) => {
136
+ if (shippedCatalogue === undefined)
137
+ return [];
138
+ const carried = [];
139
+ for (const path of workspace.questions ?? []) {
140
+ try {
141
+ carried.push({ path, source: readFileSync(path, "utf8") });
142
+ }
143
+ catch {
144
+ // Skipped, as above.
145
+ }
146
+ }
147
+ return [shippedCatalogue, ...carried];
148
+ };
127
149
  /**
128
150
  * Takes one session terminal, whatever caused it: a reviewer's End, a child
129
151
  * that failed, a browser that never came back, a cancelling main agent, or a
@@ -612,7 +634,11 @@ export const startVisualServer = async (options) => {
612
634
  // that. A staged view operation pins against these (ADR 0103), and a
613
635
  // projection missing from the map is one the commit will create.
614
636
  projectionDigests: projectionDigestsNow(),
615
- }, shippedCatalogue);
637
+ // The shipped catalogue plus whatever this workspace carries (#345),
638
+ // so the pane asks the interview `design` asks over the same files.
639
+ // Read on each recompile rather than once per process, because a
640
+ // consultant authoring a question mid-session is the whole point.
641
+ }, catalogueSetFor(resolvedWorkspace));
616
642
  // Closures below retain this array, so refresh its contents without
617
643
  // replacing the identity the session started with.
618
644
  views.splice(0, views.length, ...workspaceModel.views);
@@ -57,10 +57,20 @@ export interface DismissedQuestion {
57
57
  export declare const interrogationOverlayOf: (compiled: {
58
58
  readonly graph: SemanticGraph;
59
59
  readonly profileContext: ResolvedProfileContext;
60
- }, catalogue: {
60
+ },
61
+ /**
62
+ * The catalogue, or the composed SET a workspace carries (#345, ADR 0129).
63
+ * A set rather than one document so the pane asks the same interview the
64
+ * CLI does over the same workspace: an editor showing fewer questions than
65
+ * `design` does over the same files is a disagreement with no symptom.
66
+ */
67
+ catalogue: {
61
68
  readonly path: string;
62
69
  readonly source: string;
63
- },
70
+ } | readonly {
71
+ readonly path: string;
72
+ readonly source: string;
73
+ }[],
64
74
  /**
65
75
  * What the host has already dealt with (#328). Evaluation is unchanged and
66
76
  * the model is untouched: this decides only what the pane draws, because a
@@ -83,7 +93,10 @@ export declare const renderedWorkspaceOf: (compiled: {
83
93
  }, views: readonly VisualViewSummary[], metadata: Omit<VisualRenderedModel, "graph" | "vocabulary" | "interrogation">, catalogue?: {
84
94
  readonly path: string;
85
95
  readonly source: string;
86
- }, dismissed?: readonly DismissedQuestion[]) => {
96
+ } | readonly {
97
+ readonly path: string;
98
+ readonly source: string;
99
+ }[], dismissed?: readonly DismissedQuestion[]) => {
87
100
  readonly model: VisualRenderedModel;
88
101
  readonly views: readonly VisualViewSummary[];
89
102
  };
@@ -6,7 +6,7 @@ import { projectGraphForCanvas } from "../../graph-projection.js";
6
6
  import { DEFAULT_PROJECTION_DIRECTORY } from "./view-identity.js";
7
7
  import { evaluateProjection, explainProjection } from "../../projection.js";
8
8
  import { kindLabelOf } from "../../kind-label.js";
9
- import { evaluateCatalogue, loadQuestionCatalogue, } from "../../interrogate-command.js";
9
+ import { evaluateCatalogue, composeCatalogues, } from "../../interrogate-command.js";
10
10
  /**
11
11
  * What a workspace looks like to the editor, however the editor is being run
12
12
  * (#252).
@@ -46,7 +46,14 @@ export const kindOptionsOf = (lineages) => [...lineages.keys()].map((id) => ({
46
46
  * them would read as five, and the reviewer counting boxes would find three.
47
47
  */
48
48
  export const conceptCountOf = (graph, query, profileContext) => evaluateProjection(graph, adHoc(query), profileContext).subjects.filter(({ type }) => type === "concept").length;
49
- export const interrogationOverlayOf = (compiled, catalogue,
49
+ export const interrogationOverlayOf = (compiled,
50
+ /**
51
+ * The catalogue, or the composed SET a workspace carries (#345, ADR 0129).
52
+ * A set rather than one document so the pane asks the same interview the
53
+ * CLI does over the same workspace: an editor showing fewer questions than
54
+ * `design` does over the same files is a disagreement with no symptom.
55
+ */
56
+ catalogue,
50
57
  /**
51
58
  * What the host has already dealt with (#328). Evaluation is unchanged and
52
59
  * the model is untouched: this decides only what the pane draws, because a
@@ -54,10 +61,10 @@ export const interrogationOverlayOf = (compiled, catalogue,
54
61
  * a pane embedded in it.
55
62
  */
56
63
  dismissed = []) => {
57
- const loaded = loadQuestionCatalogue(catalogue);
58
- if (!loaded.ok)
64
+ const composed = composeCatalogues(Array.isArray(catalogue) ? catalogue : [catalogue]);
65
+ if (!composed.ok)
59
66
  return undefined;
60
- const report = evaluateCatalogue(loaded.catalogue, compiled.graph, compiled.profileContext);
67
+ const report = evaluateCatalogue(composed.composed.catalogue, compiled.graph, compiled.profileContext, undefined, composed.composed.catalogues);
61
68
  const dismissedEverywhere = new Set(dismissed
62
69
  .filter(({ subject }) => subject === undefined)
63
70
  .map(({ questionId }) => questionId));
@@ -9,7 +9,8 @@ import { runCheckCommand } from './check-command.js';
9
9
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
10
10
  import { compileWorkspaceWithProfileContext, } from './compiler.js';
11
11
  import { evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
12
- import { evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, } from './interrogate-command.js';
12
+ import { catalogueSources } from './catalogue-sources.js';
13
+ import { evaluateCatalogue, composeCatalogues, renderInterrogationReport, } from './interrogate-command.js';
13
14
  import { buildNextSubjects, coverageClause, } from './next-command.js';
14
15
  import { conceptKinds, relationshipPolicies, } from './profile.js';
15
16
  import { ARCHIMATE_RELATIONSHIPS_VERSION, CORE_CONCEPT_KIND_ORDER, PERMITTED_RELATIONSHIP_LETTERS, RELATIONSHIP_LETTERS, } from './archimate-relationships.generated.js';
@@ -468,13 +469,13 @@ export function runAskCommand(options, cwd) {
468
469
  const planned = buildNextSubjects(wholeWorkspace, compilation.graph, compilation.profileContext, evaluation.reports);
469
470
  const current = entries.filter(({ status }) => status === 'current');
470
471
  const retired = entries.filter(({ status }) => status === 'retired');
471
- const loadedCatalogue = loadQuestionCatalogue({
472
+ const composed = composeCatalogues(catalogueSources({
472
473
  path: shippedCataloguePath,
473
474
  source: readFileSync(shippedCataloguePath, 'utf8'),
474
- }, compilation.profileContext);
475
- if (!loadedCatalogue.ok)
476
- return failed(loadedCatalogue.diagnostics);
477
- const report = evaluateCatalogue(loadedCatalogue.catalogue, compilation.graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations));
475
+ }, workspace, cwd), compilation.profileContext);
476
+ if (!composed.ok)
477
+ return failed(composed.diagnostics);
478
+ const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues);
478
479
  const result = {
479
480
  format: 'yarramate/ask-result/v1',
480
481
  workspace: workspace.id,
@@ -698,12 +699,12 @@ export function runAskCommand(options, cwd) {
698
699
  const resolvedCataloguePath = cataloguePath === undefined
699
700
  ? shippedCataloguePath
700
701
  : resolve(cwd, cataloguePath);
701
- const loadedCatalogue = loadQuestionCatalogue({
702
+ const composed = composeCatalogues(catalogueSources({
702
703
  path: cataloguePath ?? resolvedCataloguePath,
703
704
  source: readFileSync(resolvedCataloguePath, 'utf8'),
704
- }, compilation.profileContext);
705
- if (!loadedCatalogue.ok)
706
- return failed(loadedCatalogue.diagnostics);
705
+ }, workspace, cwd), compilation.profileContext);
706
+ if (!composed.ok)
707
+ return failed(composed.diagnostics);
707
708
  // The evidence overlay rides along for the one condition that
708
709
  // reads it (unchallenged-evidence); a workspace declaring no
709
710
  // evidence passes an overlay known to be empty.
@@ -718,13 +719,21 @@ export function runAskCommand(options, cwd) {
718
719
  evidenceObservations.push(...loaded.evidence.observations);
719
720
  }
720
721
  const report = {
721
- ...evaluateCatalogue(loadedCatalogue.catalogue, graph, compilation.profileContext, evidenceObservations),
722
+ ...evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues),
722
723
  workspace: workspace.id,
723
724
  };
725
+ // Field by field, to fix key ORDER in the emitted JSON. Every optional
726
+ // field has to be threaded through explicitly, which is why `catalogues`
727
+ // is here: a copier like this drops a new field silently and the only
728
+ // symptom is an absent one, which reads as "did not apply" rather than
729
+ // as "was lost".
724
730
  const ordered = {
725
731
  format: report.format,
726
732
  workspace: report.workspace,
727
733
  catalogue: report.catalogue,
734
+ ...(report.catalogues === undefined
735
+ ? {}
736
+ : { catalogues: report.catalogues }),
728
737
  semantics: report.semantics,
729
738
  summary: report.summary,
730
739
  waves: report.waves,
@@ -993,12 +1002,12 @@ export function runAskCommand(options, cwd) {
993
1002
  const resolvedCataloguePath = cataloguePath === undefined
994
1003
  ? shippedCataloguePath
995
1004
  : resolve(cwd, cataloguePath);
996
- const loadedCatalogue = loadQuestionCatalogue({
1005
+ const composed = composeCatalogues(catalogueSources({
997
1006
  path: cataloguePath ?? resolvedCataloguePath,
998
1007
  source: readFileSync(resolvedCataloguePath, 'utf8'),
999
- }, compilation.profileContext);
1000
- if (!loadedCatalogue.ok)
1001
- return failed(loadedCatalogue.diagnostics);
1008
+ }, workspace, cwd), compilation.profileContext);
1009
+ if (!composed.ok)
1010
+ return failed(composed.diagnostics);
1002
1011
  // Loaded ahead of evaluation so the overlay feeds the one condition
1003
1012
  // that reads it (unchallenged-evidence), then reused for the
1004
1013
  // reconciliation summary below.
@@ -1012,7 +1021,7 @@ export function runAskCommand(options, cwd) {
1012
1021
  return failed(loaded.diagnostics);
1013
1022
  evidenceDocuments.push(loaded.evidence);
1014
1023
  }
1015
- const report = evaluateCatalogue(loadedCatalogue.catalogue, graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations));
1024
+ const report = evaluateCatalogue(composed.composed.catalogue, graph, compilation.profileContext, evidenceDocuments.flatMap(({ observations }) => observations), composed.composed.catalogues);
1016
1025
  const openQuestions = [];
1017
1026
  for (const wave of report.waves) {
1018
1027
  for (const question of wave.questions) {
@@ -0,0 +1,20 @@
1
+ import type { WorkspaceSource } from './compiler.js';
2
+ import type { ResolvedWorkspace } from './workspace.js';
3
+ export declare const shippedCataloguePath: string;
4
+ /** The base catalogue as a source, read from disk. */
5
+ export declare const shippedCatalogueSource: () => WorkspaceSource;
6
+ /**
7
+ * The catalogues a verb should compose: the base, then whatever the workspace
8
+ * carries (#345, ADR 0129).
9
+ *
10
+ * Built in ONE place because every verb that interviews has to make the same
11
+ * choice, and a verb that forgot the workspace half would silently ask fewer
12
+ * questions than the workspace declares - a failure with no symptom, which is
13
+ * the shape this repository keeps being bitten by.
14
+ *
15
+ * The base is REPLACED by `--catalogue` and ADDED TO by `questions:`. Those
16
+ * are different powers on purpose: a host controls the catalogue that is not
17
+ * in the workspace (#328), and a consultant adds to it mid-engagement without
18
+ * a product release.
19
+ */
20
+ export declare const catalogueSources: (base: WorkspaceSource, workspace: Pick<ResolvedWorkspace, 'questions'>, cwd: string) => readonly WorkspaceSource[];
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // The catalogue ships inside the package, versioned with it, and harnesses
5
+ // never pass catalogue paths. The relative hop works from both src/ (dev) and
6
+ // dist/ (shipped). Defined ONCE: `design`, `ask` and `check` all need it, and
7
+ // three copies of a path constant is three chances for them to disagree about
8
+ // which catalogue is the base.
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ export const shippedCataloguePath = join(here, '..', 'catalogues', 'core-enrichment.yaml');
11
+ /** The base catalogue as a source, read from disk. */
12
+ export const shippedCatalogueSource = () => ({
13
+ path: shippedCataloguePath,
14
+ source: readFileSync(shippedCataloguePath, 'utf8'),
15
+ });
16
+ /**
17
+ * The catalogues a verb should compose: the base, then whatever the workspace
18
+ * carries (#345, ADR 0129).
19
+ *
20
+ * Built in ONE place because every verb that interviews has to make the same
21
+ * choice, and a verb that forgot the workspace half would silently ask fewer
22
+ * questions than the workspace declares - a failure with no symptom, which is
23
+ * the shape this repository keeps being bitten by.
24
+ *
25
+ * The base is REPLACED by `--catalogue` and ADDED TO by `questions:`. Those
26
+ * are different powers on purpose: a host controls the catalogue that is not
27
+ * in the workspace (#328), and a consultant adds to it mid-engagement without
28
+ * a product release.
29
+ */
30
+ export const catalogueSources = (base, workspace, cwd) => [
31
+ base,
32
+ // `?? []` because `questions` is optional on the published type: adding a
33
+ // required field to `ResolvedWorkspace` broke a consumer's production module
34
+ // once already.
35
+ ...(workspace.questions ?? []).map((path) => ({
36
+ path,
37
+ source: readFileSync(resolve(cwd, path), 'utf8'),
38
+ })),
39
+ ];
@@ -1,13 +1,15 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { catalogueSources, shippedCatalogueSource, } from './catalogue-sources.js';
3
+ import { composeCatalogues } from './interrogate-command.js';
2
4
  import { resolve } from 'node:path';
3
5
  import Ajv2020Module from 'ajv/dist/2020.js';
4
6
  import { parseDocument } from 'yaml';
5
7
  import { loadAdapterMapping, validateAdapterMappings, } from './adapter-mapping.js';
6
8
  import { checkResultJson, humanDiagnostics, resolveCliWorkspaceSources, sortDiagnostics, usage, } from './cli-support.js';
7
- import { compileWorkspace, withDiagnosticSubjects } from './compiler.js';
9
+ import { compileWorkspaceWithProfileContext, withDiagnosticSubjects, } from './compiler.js';
8
10
  import { checkCoreContract, loadCoreContract, } from './core-contract.js';
9
11
  import { evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
10
- import { loadProjection } from './projection.js';
12
+ import { loadProjection, projectionReferenceDiagnostics, } from './projection.js';
11
13
  import { reconcileEvidenceReports, } from './reconciliation.js';
12
14
  // `.default ?? module`, not a bare `.default`: NodeNext sees the raw CJS
13
15
  // `module.exports` and a bundler the unwrapped class. One shape for all of
@@ -138,13 +140,15 @@ export function runCheckCommand(options, cwd) {
138
140
  : humanDiagnostics(contractDiagnostics);
139
141
  return { exitCode: 1, stdout: output, stderr: '' };
140
142
  }
141
- const projectionDiagnostics = sortDiagnostics(resolved.projections.flatMap((path) => {
142
- const loaded = loadProjection({
143
- path,
144
- source: readFileSync(resolve(cwd, path), 'utf8'),
145
- });
146
- return loaded.ok ? [] : loaded.diagnostics;
143
+ const projectionSources = resolved.projections.map((path) => ({
144
+ path,
145
+ source: readFileSync(resolve(cwd, path), 'utf8'),
146
+ }));
147
+ const loadedProjections = projectionSources.map((source) => ({
148
+ source,
149
+ loaded: loadProjection(source),
147
150
  }));
151
+ const projectionDiagnostics = sortDiagnostics(loadedProjections.flatMap(({ loaded }) => loaded.ok ? [] : loaded.diagnostics));
148
152
  if (projectionDiagnostics.length > 0) {
149
153
  const output = json
150
154
  ? checkResultJson(false, projectionDiagnostics)
@@ -177,7 +181,7 @@ export function runCheckCommand(options, cwd) {
177
181
  : humanDiagnostics(mappingLoadDiagnostics);
178
182
  return { exitCode: 1, stdout: output, stderr: '' };
179
183
  }
180
- const result = compileWorkspace(coreSources);
184
+ const result = compileWorkspaceWithProfileContext(coreSources);
181
185
  const mappingValidation = result.ok
182
186
  ? validateAdapterMappings(result.graph, loadedMappings.flatMap((loaded) => loaded.ok ? [loaded.mapping] : []))
183
187
  : undefined;
@@ -190,9 +194,34 @@ export function runCheckCommand(options, cwd) {
190
194
  const evidenceDiagnostics = evidenceEvaluation === undefined || evidenceEvaluation.ok
191
195
  ? []
192
196
  : evidenceEvaluation.diagnostics;
197
+ // A catalogue the manifest declares is workspace content, so `check`
198
+ // refuses a broken one (#345, ADR 0129). Composed rather than checked one
199
+ // by one, because the refusals that matter are CROSS-catalogue: a wave
200
+ // declared twice, and a question naming a wave nothing in the set
201
+ // declares. Checking each file alone would miss both and would refuse the
202
+ // one thing the feature exists to allow, a question joining a wave another
203
+ // catalogue declared.
204
+ const catalogueDiagnostics = result.ok && resolved.questions.length > 0
205
+ ? (() => {
206
+ const composed = composeCatalogues(catalogueSources(shippedCatalogueSource(), resolved, cwd), result.profileContext);
207
+ return composed.ok ? [] : composed.diagnostics;
208
+ })()
209
+ : [];
210
+ // A projection is a document, and a query holds references the same way a
211
+ // relationship does. Checked HERE rather than with the projection's own
212
+ // schema load above, because a reference can only be resolved against a
213
+ // model that compiled: reporting dangling names out of a workspace that
214
+ // does not build would bury the real failure under its consequences.
215
+ const referenceDiagnostics = result.ok
216
+ ? loadedProjections.flatMap(({ source, loaded }) => loaded.ok
217
+ ? projectionReferenceDiagnostics(source, loaded.projection, result.graph, result.profileContext)
218
+ : [])
219
+ : [];
193
220
  const optionalDiagnostics = sortDiagnostics([
194
221
  ...mappingDiagnostics,
195
222
  ...evidenceDiagnostics,
223
+ ...referenceDiagnostics,
224
+ ...catalogueDiagnostics,
196
225
  ]);
197
226
  const ok = result.ok && optionalDiagnostics.length === 0;
198
227
  // Published results name the subject a diagnostic is about wherever its
@@ -30,6 +30,13 @@ export declare const resolveCliWorkspaceSources: (paths: readonly string[], cwd:
30
30
  readonly contracts: readonly string[];
31
31
  /** Pattern documents, which ride in `paths` and are not documents. */
32
32
  readonly patterns: readonly string[];
33
+ /**
34
+ * Question catalogues the workspace carries (#345). Handed over rather
35
+ * than resolved-and-dropped, which is the #268 failure recorded below:
36
+ * a category a manifest declares and no verb receives is ignored with
37
+ * no symptom.
38
+ */
39
+ readonly questions: readonly string[];
33
40
  } | {
34
41
  readonly ok: false;
35
42
  readonly diagnostics: readonly Diagnostic[];
@@ -51,6 +51,7 @@ export const resolveCliWorkspaceSources = (paths, cwd, options = {}) => {
51
51
  evidence: [],
52
52
  contracts: [],
53
53
  patterns: [],
54
+ questions: [],
54
55
  };
55
56
  }
56
57
  const manifestPath = paths[0];
@@ -62,6 +63,7 @@ export const resolveCliWorkspaceSources = (paths, cwd, options = {}) => {
62
63
  evidence: [],
63
64
  contracts: [],
64
65
  patterns: [],
66
+ questions: [],
65
67
  };
66
68
  }
67
69
  const source = readFileSync(resolve(cwd, manifestPath), 'utf8');
@@ -73,6 +75,7 @@ export const resolveCliWorkspaceSources = (paths, cwd, options = {}) => {
73
75
  evidence: [],
74
76
  contracts: [],
75
77
  patterns: [],
78
+ questions: [],
76
79
  };
77
80
  }
78
81
  const loaded = loadWorkspaceManifest({ path: manifestPath, source }, cwd);
@@ -99,6 +102,7 @@ export const resolveCliWorkspaceSources = (paths, cwd, options = {}) => {
99
102
  evidence: loaded.workspace.evidence,
100
103
  contracts: loaded.workspace.contracts,
101
104
  patterns: loaded.workspace.patterns,
105
+ questions: loaded.workspace.questions ?? [],
102
106
  }
103
107
  : { ok: false, diagnostics: loaded.diagnostics };
104
108
  };
@@ -5,7 +5,7 @@ import { parseDocument } from 'yaml';
5
5
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
6
6
  import { compileWorkspaceWithProfileContext, } from './compiler.js';
7
7
  import { loadEvidence } from './evidence.js';
8
- import { evaluateCatalogue, loadQuestionCatalogue, renderQuestion, } from './interrogate-command.js';
8
+ import { composeCatalogues, evaluateCatalogue, renderQuestion, } from './interrogate-command.js';
9
9
  import { evaluateProjection } from './projection.js';
10
10
  import { renderBrief } from './brief.js';
11
11
  import { loadWorkspaceManifest } from './workspace.js';
@@ -205,12 +205,23 @@ export function runDesignCommand(options, cwd) {
205
205
  })));
206
206
  if (!compilation.ok)
207
207
  return failed(compilation.diagnostics);
208
- const loadedCatalogue = loadQuestionCatalogue({
209
- path: cataloguePath ?? resolvedCataloguePath,
210
- source: readFileSync(resolvedCataloguePath, 'utf8'),
211
- }, compilation.profileContext);
212
- if (!loadedCatalogue.ok)
213
- return failed(loadedCatalogue.diagnostics);
208
+ // The base, then whatever the workspace carries (#345, ADR 0129). The
209
+ // base is REPLACED by `--catalogue` and ADDED TO by `questions:`, which is
210
+ // what lets a consultant author a question mid-engagement with no product
211
+ // release while a host still controls the catalogue that is not in the
212
+ // workspace.
213
+ const composed = composeCatalogues([
214
+ {
215
+ path: cataloguePath ?? resolvedCataloguePath,
216
+ source: readFileSync(resolvedCataloguePath, 'utf8'),
217
+ },
218
+ ...(workspace.questions ?? []).map((path) => ({
219
+ path,
220
+ source: readFileSync(resolve(cwd, path), 'utf8'),
221
+ })),
222
+ ], compilation.profileContext);
223
+ if (!composed.ok)
224
+ return failed(composed.diagnostics);
214
225
  // The evidence overlay rides along for the one condition that reads
215
226
  // it (unchallenged-evidence). A workspace declaring no evidence
216
227
  // passes an empty overlay — known to be empty, which keeps that
@@ -235,8 +246,9 @@ export function runDesignCommand(options, cwd) {
235
246
  };
236
247
  }
237
248
  }
238
- const report = evaluateCatalogue(loadedCatalogue.catalogue, compilation.graph, compilation.profileContext, evidenceObservations);
239
- const askPlainById = new Map(loadedCatalogue.catalogue.questions.flatMap((question) => question.askPlain === undefined
249
+ const report = evaluateCatalogue(composed.composed.catalogue, compilation.graph, compilation.profileContext, evidenceObservations, composed.composed.catalogues);
250
+ // Keyed by the QUALIFIED id, matching what the report now carries.
251
+ const askPlainById = new Map(composed.composed.catalogue.questions.flatMap((question) => question.askPlain === undefined
240
252
  ? []
241
253
  : [[question.id, question.askPlain]]));
242
254
  const step = selectStep(report, subjectFilter, askPlainById);
package/dist/index.d.ts CHANGED
@@ -18,4 +18,4 @@ export { applyOperations, landOperations, posixDirectoryOf, type ApplyInput, typ
18
18
  export { connectableKinds, draftRelationship, proposeRelationshipId, stagedSubjectIds, } 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 CatalogueEvidenceObservation, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
21
+ export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCondition, type CatalogueEvidenceObservation, type CatalogueLoadResult, type CatalogueQuestion, type CatalogueSelector, type InterrogationReport, type InterrogationSummary, type OpenSubject, type QuestionCatalogue, type ReportQuestion, type ReportWave, } from './interrogate-command.js';
package/dist/index.js CHANGED
@@ -16,4 +16,4 @@ export { applyOperations, landOperations, posixDirectoryOf, } from './apply-comm
16
16
  export { connectableKinds, draftRelationship, proposeRelationshipId, stagedSubjectIds, } 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';
19
+ export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, } from './interrogate-command.js';
@@ -188,14 +188,29 @@ export interface InterrogationSummary {
188
188
  export interface InterrogationReport {
189
189
  readonly format: 'yarramate/interrogation-report/v1';
190
190
  readonly workspace: string;
191
+ /** The BASE catalogue, `id@version`. Unchanged in shape by composition. */
191
192
  readonly catalogue: string;
193
+ /**
194
+ * Every contributing catalogue as `id@version`, base first, when more than
195
+ * one contributed (#345, ADR 0129). Optional so that adding it breaks no
196
+ * constructor, and `catalogue` keeps its value shape so it breaks no reader.
197
+ */
198
+ readonly catalogues?: readonly string[];
192
199
  /** {@link INTERROGATION_SEMANTICS_VERSION} at the time of evaluation. */
193
200
  readonly semantics: string;
194
201
  readonly summary: InterrogationSummary;
195
202
  readonly waves: readonly ReportWave[];
196
203
  }
197
204
  export declare const renderQuestion: (template: string, subjectId: string, subjectName: string | undefined, counterparts?: readonly string[]) => string;
198
- export declare function evaluateCatalogue(catalogue: QuestionCatalogue, graph: SemanticGraph, profileContext?: ResolvedProfileContext, evidence?: readonly CatalogueEvidenceObservation[]): Omit<InterrogationReport, 'workspace'>;
205
+ export declare function evaluateCatalogue(catalogue: QuestionCatalogue, graph: SemanticGraph, profileContext?: ResolvedProfileContext, evidence?: readonly CatalogueEvidenceObservation[],
206
+ /**
207
+ * Contributing catalogues, from {@link composeCatalogues}. Composition
208
+ * happens BEFORE evaluation and hands this function an ordinary catalogue,
209
+ * so the only thing evaluation learns about composition is what to name in
210
+ * the report. A fifth optional parameter rather than an options object,
211
+ * because this signature is published and a consumer already calls it.
212
+ */
213
+ catalogues?: readonly string[]): Omit<InterrogationReport, 'workspace'>;
199
214
  export type CatalogueLoadResult = {
200
215
  readonly ok: true;
201
216
  readonly catalogue: QuestionCatalogue;
@@ -203,5 +218,71 @@ export type CatalogueLoadResult = {
203
218
  readonly ok: false;
204
219
  readonly diagnostics: readonly Diagnostic[];
205
220
  };
221
+ /**
222
+ * A catalogue is qualified on the way OUT, never in what an author writes.
223
+ *
224
+ * The authored schema keeps ids local (`^[a-z][a-z0-9-]*$`) and that does not
225
+ * change; a consultant writes `regulator-signoff`, not
226
+ * `consulting#regulator-signoff`. The engine qualifies when it composes, which
227
+ * is the only moment two catalogues can be confused for each other.
228
+ *
229
+ * NO VERSION, and that is the decision rather than an omission (ADR 0129).
230
+ * `core-enrichment` went 1.0 to 1.3 in a single day renaming nothing; a
231
+ * versioned identity would have stranded every stored dismissal in every
232
+ * adopter's database three times that day, for changes that removed no
233
+ * question. Versioned identity is safe for things that are AUTHORED - a
234
+ * document keeps naming the version it was written against and an author
235
+ * updates it deliberately - and unsafe for things that are STORED, because a
236
+ * row in someone's database has no author to update it.
237
+ */
238
+ export declare const qualifiedQuestionId: (catalogueId: string, questionId: string) => string;
239
+ export interface ComposedCatalogue {
240
+ /**
241
+ * The composed catalogue, ready for `evaluateCatalogue`. Structurally a
242
+ * `QuestionCatalogue`, but its question ids are QUALIFIED, so it is a
243
+ * composition result rather than something an author could have written and
244
+ * must never be validated against the authored schema again.
245
+ */
246
+ readonly catalogue: QuestionCatalogue;
247
+ /** Every contributing catalogue as `id@version`, base first. */
248
+ readonly catalogues: readonly string[];
249
+ }
250
+ export type CatalogueCompositionResult = {
251
+ readonly ok: true;
252
+ readonly composed: ComposedCatalogue;
253
+ } | {
254
+ readonly ok: false;
255
+ readonly diagnostics: readonly Diagnostic[];
256
+ };
257
+ /**
258
+ * Compose a base catalogue with the ones a workspace carries (#345, ADR 0129).
259
+ *
260
+ * ADDITIVE. `--catalogue` and `MountOptions.catalogue` replace the base; this
261
+ * adds to it, which is what lets a consultant author a question at any point
262
+ * in an engagement with no product release.
263
+ *
264
+ * A WAVE IS DECLARED EXACTLY ONCE across the resolved set, and any catalogue
265
+ * may contribute questions to a wave it did not declare. That one rule settles
266
+ * three questions composition would otherwise raise. Wave identity: a project
267
+ * catalogue joins a declared wave rather than colliding with it. Ordering:
268
+ * only a declaration places a wave, so the base's order is untouched and new
269
+ * waves append. And the `opensWhen` precedence ADR 0125 made load-bearing does
270
+ * not arise at all, because there is only ever one declarer to ask.
271
+ *
272
+ * QUALIFICATION IS A PROPERTY OF COMPOSITION, NOT OF EVALUATION. A caller
273
+ * that hands `evaluateCatalogue` a catalogue directly still gets local ids,
274
+ * exactly as before this existed. Route through here even for ONE catalogue -
275
+ * which is what every CLI verb does - and ids are qualified from the start, so
276
+ * they do not change later when a workspace first carries a question of its
277
+ * own. That transition is the one thing an adopter keying stored judgments on
278
+ * a question id must not experience, and composing unconditionally is how it
279
+ * is avoided.
280
+ *
281
+ * ID COLLISIONS DISSOLVE rather than being resolved. Two catalogues may both
282
+ * carry `outcome-missing`; qualified, they are two different questions. There
283
+ * is no merge rule, no last-wins and no refusal, because there is nothing to
284
+ * merge.
285
+ */
286
+ export declare function composeCatalogues(sources: readonly WorkspaceSource[], profileContext?: ResolvedProfileContext): CatalogueCompositionResult;
206
287
  export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource, profileContext?: ResolvedProfileContext): CatalogueLoadResult;
207
288
  export declare function renderInterrogationReport(report: InterrogationReport): string;