yarramate 1.5.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.
- package/dist/adapters/visual/session-server.js +27 -1
- package/dist/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/adapters/visual/workspace-model.js +12 -5
- package/dist/ask-command.js +25 -16
- package/dist/catalogue-sources.d.ts +20 -0
- package/dist/catalogue-sources.js +39 -0
- package/dist/check-command.js +38 -9
- package/dist/cli-support.d.ts +8 -1
- package/dist/cli-support.js +5 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +17 -1
- package/dist/design-command.js +21 -9
- package/dist/export-command.js +41 -5
- package/dist/import-command.d.ts +16 -0
- package/dist/import-command.js +156 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/interrogate-command.d.ts +82 -1
- package/dist/interrogate-command.js +195 -29
- package/dist/interrogation-entry.d.ts +1 -1
- package/dist/interrogation-entry.js +1 -1
- package/dist/projection.d.ts +47 -0
- package/dist/projection.js +126 -0
- package/dist/visual-app-lib/editor.js +26771 -26713
- package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
- package/dist/visual-app-lib/types/projection.d.ts +47 -0
- package/dist/visual-app-lib/types/workspace.d.ts +15 -0
- package/dist/workbook-entry.d.ts +16 -0
- package/dist/workbook-entry.js +16 -0
- package/dist/workbook-import-entry.d.ts +30 -0
- package/dist/workbook-import-entry.js +30 -0
- package/dist/workbook-merge.d.ts +77 -0
- package/dist/workbook-merge.js +108 -0
- package/dist/workbook-operations.d.ts +9 -0
- package/dist/workbook-operations.js +139 -0
- package/dist/workbook-read.d.ts +43 -0
- package/dist/workbook-read.js +314 -0
- package/dist/workbook-xlsx.d.ts +54 -0
- package/dist/workbook-xlsx.js +228 -0
- package/dist/workbook.d.ts +32 -0
- package/dist/workbook.js +308 -0
- package/dist/workspace.d.ts +15 -0
- package/dist/workspace.js +1 -0
- package/docs/CONSUMING-YARRAMATE.md +127 -0
- package/package.json +9 -1
- package/schema/yarramate-interrogation-report.schema.json +9 -0
- package/schema/yarramate-question-catalogue.schema.json +2 -3
- package/schema/yarramate-workspace.schema.json +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CliResult } from './cli-support.js';
|
|
2
|
+
/**
|
|
3
|
+
* `yarramate import xlsx <file> <workspace.yaml>` (#355, ADR 0127).
|
|
4
|
+
*
|
|
5
|
+
* The workbook carries its own ancestor, so this is a three-way merge rather
|
|
6
|
+
* than an overwrite: the author's edits are measured against `~Baseline`, the
|
|
7
|
+
* repository's drift is measured against the same, and only a field both moved
|
|
8
|
+
* is refused. Everything the author changed that the repository left alone
|
|
9
|
+
* merges cleanly, which is what makes a week-long workbook cycle usable.
|
|
10
|
+
*
|
|
11
|
+
* Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
|
|
12
|
+
* keeps its comments, key order and formatting, and the whole import passes
|
|
13
|
+
* the atomic compile gate. A workbook that would produce an uncompilable model
|
|
14
|
+
* is refused whole rather than half written.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runImportCommand(options: readonly string[], cwd: string): Promise<CliResult>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runApplyCommand } from './apply-cli.js';
|
|
4
|
+
import { humanDiagnostics, packageVersion, usage } from './cli-support.js';
|
|
5
|
+
import { compileWorkspaceWithProfileContext } from './compiler.js';
|
|
6
|
+
import { evaluateProjection } from './projection.js';
|
|
7
|
+
import { loadWorkspaceManifest } from './workspace.js';
|
|
8
|
+
import { buildWorkbookSheets } from './workbook.js';
|
|
9
|
+
import { baselineSheets, mergeWorkbook } from './workbook-merge.js';
|
|
10
|
+
import { operationsFrom, operationsDocument } from './workbook-operations.js';
|
|
11
|
+
import { readWorkbook } from './workbook-read.js';
|
|
12
|
+
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { stringify } from 'yaml';
|
|
16
|
+
/**
|
|
17
|
+
* `yarramate import xlsx <file> <workspace.yaml>` (#355, ADR 0127).
|
|
18
|
+
*
|
|
19
|
+
* The workbook carries its own ancestor, so this is a three-way merge rather
|
|
20
|
+
* than an overwrite: the author's edits are measured against `~Baseline`, the
|
|
21
|
+
* repository's drift is measured against the same, and only a field both moved
|
|
22
|
+
* is refused. Everything the author changed that the repository left alone
|
|
23
|
+
* merges cleanly, which is what makes a week-long workbook cycle usable.
|
|
24
|
+
*
|
|
25
|
+
* Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
|
|
26
|
+
* keeps its comments, key order and formatting, and the whole import passes
|
|
27
|
+
* the atomic compile gate. A workbook that would produce an uncompilable model
|
|
28
|
+
* is refused whole rather than half written.
|
|
29
|
+
*/
|
|
30
|
+
export async function runImportCommand(options, cwd) {
|
|
31
|
+
const [kind, ...rest] = options;
|
|
32
|
+
if (kind !== 'xlsx')
|
|
33
|
+
return { exitCode: 2, stdout: '', stderr: usage };
|
|
34
|
+
const json = rest.includes('--json');
|
|
35
|
+
const positionals = rest.filter((option) => option !== '--json');
|
|
36
|
+
const [workbookPath, workspacePath] = positionals;
|
|
37
|
+
if (positionals.length !== 2 ||
|
|
38
|
+
workbookPath === undefined ||
|
|
39
|
+
workspacePath === undefined) {
|
|
40
|
+
return { exitCode: 2, stdout: '', stderr: usage };
|
|
41
|
+
}
|
|
42
|
+
const manifestPath = resolve(cwd, workspacePath);
|
|
43
|
+
let manifestSource;
|
|
44
|
+
let workbookBytes;
|
|
45
|
+
try {
|
|
46
|
+
manifestSource = readFileSync(manifestPath, 'utf8');
|
|
47
|
+
workbookBytes = new Uint8Array(readFileSync(resolve(cwd, workbookPath)));
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
return {
|
|
51
|
+
exitCode: 1,
|
|
52
|
+
stdout: '',
|
|
53
|
+
stderr: `${cause instanceof Error ? cause.message : String(cause)}\n`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const loaded = loadWorkspaceManifest({ path: workspacePath, source: manifestSource }, cwd);
|
|
57
|
+
if (!loaded.ok) {
|
|
58
|
+
return {
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
stdout: '',
|
|
61
|
+
stderr: humanDiagnostics(loaded.diagnostics),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const workspace = loaded.workspace;
|
|
65
|
+
const sources = [
|
|
66
|
+
...workspace.profiles,
|
|
67
|
+
...workspace.patterns,
|
|
68
|
+
...workspace.documents,
|
|
69
|
+
].map((path) => ({ path, source: readFileSync(resolve(cwd, path), 'utf8') }));
|
|
70
|
+
const compilation = compileWorkspaceWithProfileContext(sources);
|
|
71
|
+
if (!compilation.ok) {
|
|
72
|
+
return {
|
|
73
|
+
exitCode: 1,
|
|
74
|
+
stdout: '',
|
|
75
|
+
stderr: humanDiagnostics(compilation.diagnostics),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const read = await readWorkbook(workbookBytes);
|
|
79
|
+
if (!read.ok) {
|
|
80
|
+
return { exitCode: 1, stdout: '', stderr: `${read.reason}\n` };
|
|
81
|
+
}
|
|
82
|
+
const baselineRows = read.sheets.get('~Baseline');
|
|
83
|
+
if (baselineRows === undefined) {
|
|
84
|
+
// Without the ancestor there is no way to tell the author's edit from the
|
|
85
|
+
// repository's drift, and guessing would silently discard one of them.
|
|
86
|
+
return {
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
stdout: '',
|
|
89
|
+
stderr: 'This workbook carries no ~Baseline sheet, so it was not produced by ' +
|
|
90
|
+
'`yarramate export xlsx` and there is no ancestor to merge against.\n',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const projectionId = read.sheets.get('~Meta')?.find((row) => row[0] === 'Projection')?.[1];
|
|
94
|
+
const result = evaluateProjection(compilation.graph, {
|
|
95
|
+
format: 'yarramate/projection/v1',
|
|
96
|
+
id: projectionId ?? 'imported',
|
|
97
|
+
version: '1.0',
|
|
98
|
+
query: {},
|
|
99
|
+
}, compilation.profileContext);
|
|
100
|
+
const current = buildWorkbookSheets(result, {
|
|
101
|
+
workspace: workspace.id,
|
|
102
|
+
yarramateVersion: packageVersion,
|
|
103
|
+
sourceDigests: {},
|
|
104
|
+
conceptKinds: [],
|
|
105
|
+
relationshipKinds: [],
|
|
106
|
+
statuses: [],
|
|
107
|
+
});
|
|
108
|
+
const report = mergeWorkbook(read.sheets, baselineSheets(baselineRows), current);
|
|
109
|
+
const planned = operationsFrom(report, read.sheets);
|
|
110
|
+
if (report.conflicts.length > 0) {
|
|
111
|
+
const lines = report.conflicts.map(({ sheet, id, column, from, to, theirs }) => ` ${sheet} "${id}" ${column}: you wrote ${JSON.stringify(to)}, ` +
|
|
112
|
+
`the workspace now has ${JSON.stringify(theirs)}, both from ${JSON.stringify(from)}`);
|
|
113
|
+
return {
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
stdout: '',
|
|
116
|
+
stderr: `${report.conflicts.length} field${report.conflicts.length === 1 ? '' : 's'} changed in the workbook and in the workspace since the workbook was made. ` +
|
|
117
|
+
'Nothing was written.\n' +
|
|
118
|
+
`${lines.join('\n')}\n`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const notes = [
|
|
122
|
+
...planned.refusals.map((reason) => ` not imported: ${reason}`),
|
|
123
|
+
...report.missing.map(({ sheet, id }) => ` not imported: ${sheet} "${id}" is absent from the workbook; ` +
|
|
124
|
+
'a missing row is never a deletion'),
|
|
125
|
+
];
|
|
126
|
+
if (planned.operations.length === 0) {
|
|
127
|
+
const summary = `Nothing to import: the workbook matches the workspace.\n`;
|
|
128
|
+
return {
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
stdout: json
|
|
131
|
+
? `${JSON.stringify({ format: 'yarramate/import-result/v1', operations: 0, notes }, null, 2)}\n`
|
|
132
|
+
: summary + (notes.length > 0 ? `${notes.join('\n')}\n` : ''),
|
|
133
|
+
stderr: '',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Handed to `apply` rather than written directly: one mutation path, one
|
|
137
|
+
// atomic gate, and the surgical YAML editing that keeps comments.
|
|
138
|
+
const scratch = mkdtempSync(join(tmpdir(), 'yarramate-import-'));
|
|
139
|
+
const operationsPath = join(scratch, 'operations.yaml');
|
|
140
|
+
writeFileSync(operationsPath, stringify(operationsDocument(planned.operations)), 'utf8');
|
|
141
|
+
const applied = runApplyCommand([operationsPath, workspacePath], cwd);
|
|
142
|
+
if (applied.exitCode !== 0)
|
|
143
|
+
return applied;
|
|
144
|
+
const summary = `Imported ${planned.operations.length} change${planned.operations.length === 1 ? '' : 's'} from ${workbookPath}\n`;
|
|
145
|
+
return {
|
|
146
|
+
exitCode: 0,
|
|
147
|
+
stdout: json
|
|
148
|
+
? `${JSON.stringify({
|
|
149
|
+
format: 'yarramate/import-result/v1',
|
|
150
|
+
operations: planned.operations.length,
|
|
151
|
+
notes,
|
|
152
|
+
}, null, 2)}\n`
|
|
153
|
+
: summary + (notes.length > 0 ? `${notes.join('\n')}\n` : ''),
|
|
154
|
+
stderr: '',
|
|
155
|
+
};
|
|
156
|
+
}
|
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[]
|
|
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;
|
|
@@ -451,7 +451,15 @@ const describeCounterparts = (index, question, subjectId) => {
|
|
|
451
451
|
return name === undefined ? id : `${name} (${id})`;
|
|
452
452
|
});
|
|
453
453
|
};
|
|
454
|
-
export function evaluateCatalogue(catalogue, graph, profileContext, evidence
|
|
454
|
+
export function evaluateCatalogue(catalogue, graph, profileContext, evidence,
|
|
455
|
+
/**
|
|
456
|
+
* Contributing catalogues, from {@link composeCatalogues}. Composition
|
|
457
|
+
* happens BEFORE evaluation and hands this function an ordinary catalogue,
|
|
458
|
+
* so the only thing evaluation learns about composition is what to name in
|
|
459
|
+
* the report. A fifth optional parameter rather than an options object,
|
|
460
|
+
* because this signature is published and a consumer already calls it.
|
|
461
|
+
*/
|
|
462
|
+
catalogues) {
|
|
455
463
|
const index = indexGraph(graph);
|
|
456
464
|
let open = 0;
|
|
457
465
|
let openQuestions = 0;
|
|
@@ -512,6 +520,11 @@ export function evaluateCatalogue(catalogue, graph, profileContext, evidence) {
|
|
|
512
520
|
return {
|
|
513
521
|
format: 'yarramate/interrogation-report/v1',
|
|
514
522
|
catalogue: `${catalogue.id}@${catalogue.version}`,
|
|
523
|
+
// Only when composition actually happened. A single-catalogue report is
|
|
524
|
+
// byte-identical to what it was before this existed.
|
|
525
|
+
...(catalogues === undefined || catalogues.length < 2
|
|
526
|
+
? {}
|
|
527
|
+
: { catalogues }),
|
|
515
528
|
semantics: INTERROGATION_SEMANTICS_VERSION,
|
|
516
529
|
summary: {
|
|
517
530
|
// Questions in OPENED waves only (#334, ADR 0125). A closed wave's
|
|
@@ -615,43 +628,196 @@ const unresolvableKinds = (catalogue, profileContext) => {
|
|
|
615
628
|
return hash > 0 && loadedProfiles.has(kind.slice(0, hash));
|
|
616
629
|
});
|
|
617
630
|
};
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
631
|
+
const loadCatalogueDocument = (catalogueSource) => {
|
|
632
|
+
const loaded = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
|
|
633
|
+
if (!loaded.ok)
|
|
634
|
+
return { ok: false, diagnostics: loaded.diagnostics };
|
|
635
|
+
return {
|
|
636
|
+
ok: true,
|
|
637
|
+
document: {
|
|
638
|
+
source: catalogueSource,
|
|
639
|
+
catalogue: loaded.document.value,
|
|
640
|
+
locate: (path) => locateSourcePath(catalogueSource.path, loaded.document.yaml, loaded.document.lineCounter, path, `/${path.join('/')}`),
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
/**
|
|
645
|
+
* The YM911 undeclared-wave check, against a set of wave ids that may be
|
|
646
|
+
* WIDER than the catalogue's own.
|
|
647
|
+
*
|
|
648
|
+
* That width is the whole of #345. A project catalogue's reason to exist is
|
|
649
|
+
* adding "one more Assurance question for this client" to a wave the domain
|
|
650
|
+
* catalogue declared, so checking each file against only its own waves would
|
|
651
|
+
* refuse precisely the case the feature enables. Composed, the set is the
|
|
652
|
+
* union; alone, it is the catalogue's own and the check is what it always was.
|
|
653
|
+
*/
|
|
654
|
+
const undeclaredWaveDiagnostics = ({ catalogue, locate }, declaredWaves) => catalogue.questions.flatMap((question, questionIndex) => declaredWaves.has(question.wave)
|
|
655
|
+
? []
|
|
656
|
+
: [
|
|
657
|
+
{
|
|
658
|
+
severity: 'error',
|
|
659
|
+
code: 'YM911',
|
|
660
|
+
message: `Question "${question.id}" references undeclared wave "${question.wave}"`,
|
|
661
|
+
...locate(['questions', questionIndex, 'wave']),
|
|
662
|
+
},
|
|
663
|
+
]);
|
|
664
|
+
const unresolvableKindDiagnostics = ({ catalogue, locate }, profileContext) =>
|
|
665
|
+
// Only when a caller has a compiled workspace to check against. Without one
|
|
666
|
+
// there is no way to tell a typo from a kind whose profile simply is not
|
|
667
|
+
// here, and guessing would be the false positive this check exists to avoid.
|
|
668
|
+
profileContext === undefined
|
|
669
|
+
? []
|
|
670
|
+
: unresolvableKinds(catalogue, profileContext).map((reference) => ({
|
|
671
|
+
severity: 'error',
|
|
672
|
+
code: 'YM914',
|
|
673
|
+
message: `Kind "${reference.kind}" is not declared by profile "${reference.kind.slice(0, reference.kind.indexOf('#'))}", which this workspace loads, so the question can never fire`,
|
|
674
|
+
...locate(reference.path),
|
|
675
|
+
}));
|
|
676
|
+
/**
|
|
677
|
+
* A catalogue is qualified on the way OUT, never in what an author writes.
|
|
678
|
+
*
|
|
679
|
+
* The authored schema keeps ids local (`^[a-z][a-z0-9-]*$`) and that does not
|
|
680
|
+
* change; a consultant writes `regulator-signoff`, not
|
|
681
|
+
* `consulting#regulator-signoff`. The engine qualifies when it composes, which
|
|
682
|
+
* is the only moment two catalogues can be confused for each other.
|
|
683
|
+
*
|
|
684
|
+
* NO VERSION, and that is the decision rather than an omission (ADR 0129).
|
|
685
|
+
* `core-enrichment` went 1.0 to 1.3 in a single day renaming nothing; a
|
|
686
|
+
* versioned identity would have stranded every stored dismissal in every
|
|
687
|
+
* adopter's database three times that day, for changes that removed no
|
|
688
|
+
* question. Versioned identity is safe for things that are AUTHORED - a
|
|
689
|
+
* document keeps naming the version it was written against and an author
|
|
690
|
+
* updates it deliberately - and unsafe for things that are STORED, because a
|
|
691
|
+
* row in someone's database has no author to update it.
|
|
692
|
+
*/
|
|
693
|
+
export const qualifiedQuestionId = (catalogueId, questionId) => `${catalogueId}#${questionId}`;
|
|
694
|
+
/**
|
|
695
|
+
* Compose a base catalogue with the ones a workspace carries (#345, ADR 0129).
|
|
696
|
+
*
|
|
697
|
+
* ADDITIVE. `--catalogue` and `MountOptions.catalogue` replace the base; this
|
|
698
|
+
* adds to it, which is what lets a consultant author a question at any point
|
|
699
|
+
* in an engagement with no product release.
|
|
700
|
+
*
|
|
701
|
+
* A WAVE IS DECLARED EXACTLY ONCE across the resolved set, and any catalogue
|
|
702
|
+
* may contribute questions to a wave it did not declare. That one rule settles
|
|
703
|
+
* three questions composition would otherwise raise. Wave identity: a project
|
|
704
|
+
* catalogue joins a declared wave rather than colliding with it. Ordering:
|
|
705
|
+
* only a declaration places a wave, so the base's order is untouched and new
|
|
706
|
+
* waves append. And the `opensWhen` precedence ADR 0125 made load-bearing does
|
|
707
|
+
* not arise at all, because there is only ever one declarer to ask.
|
|
708
|
+
*
|
|
709
|
+
* QUALIFICATION IS A PROPERTY OF COMPOSITION, NOT OF EVALUATION. A caller
|
|
710
|
+
* that hands `evaluateCatalogue` a catalogue directly still gets local ids,
|
|
711
|
+
* exactly as before this existed. Route through here even for ONE catalogue -
|
|
712
|
+
* which is what every CLI verb does - and ids are qualified from the start, so
|
|
713
|
+
* they do not change later when a workspace first carries a question of its
|
|
714
|
+
* own. That transition is the one thing an adopter keying stored judgments on
|
|
715
|
+
* a question id must not experience, and composing unconditionally is how it
|
|
716
|
+
* is avoided.
|
|
717
|
+
*
|
|
718
|
+
* ID COLLISIONS DISSOLVE rather than being resolved. Two catalogues may both
|
|
719
|
+
* carry `outcome-missing`; qualified, they are two different questions. There
|
|
720
|
+
* is no merge rule, no last-wins and no refusal, because there is nothing to
|
|
721
|
+
* merge.
|
|
722
|
+
*/
|
|
723
|
+
export function composeCatalogues(sources, profileContext) {
|
|
724
|
+
const documents = [];
|
|
725
|
+
const loadDiagnostics = [];
|
|
726
|
+
for (const source of sources) {
|
|
727
|
+
const loaded = loadCatalogueDocument(source);
|
|
728
|
+
if (loaded.ok)
|
|
729
|
+
documents.push(loaded.document);
|
|
730
|
+
else
|
|
731
|
+
loadDiagnostics.push(...loaded.diagnostics);
|
|
732
|
+
}
|
|
733
|
+
if (loadDiagnostics.length > 0) {
|
|
734
|
+
return { ok: false, diagnostics: loadDiagnostics };
|
|
624
735
|
}
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
736
|
+
const base = documents[0];
|
|
737
|
+
if (base === undefined) {
|
|
738
|
+
// Never reached through the CLI, which always has the shipped catalogue,
|
|
739
|
+
// but an empty set must not compose into an empty catalogue that reports
|
|
740
|
+
// a finished interview.
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
diagnostics: [
|
|
744
|
+
{
|
|
745
|
+
severity: 'error',
|
|
746
|
+
code: 'YM915',
|
|
747
|
+
message: 'No question catalogue to compose',
|
|
748
|
+
path: '',
|
|
749
|
+
pointer: '',
|
|
750
|
+
line: 1,
|
|
751
|
+
column: 1,
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
// Declared exactly once. A second declaration is refused rather than merged,
|
|
757
|
+
// because the two carry independent `opensWhen` gates and silently picking
|
|
758
|
+
// one would decide when a wave opens by file order.
|
|
759
|
+
const declaredBy = new Map();
|
|
760
|
+
const duplicates = [];
|
|
761
|
+
for (const document of documents) {
|
|
762
|
+
document.catalogue.waves.forEach((wave, waveIndex) => {
|
|
763
|
+
const first = declaredBy.get(wave.id);
|
|
764
|
+
if (first === undefined) {
|
|
765
|
+
declaredBy.set(wave.id, document);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
duplicates.push({
|
|
631
769
|
severity: 'error',
|
|
632
|
-
code: '
|
|
633
|
-
message: `
|
|
634
|
-
|
|
770
|
+
code: 'YM915',
|
|
771
|
+
message: `Wave "${wave.id}" is already declared by catalogue "${first.catalogue.id}" ` +
|
|
772
|
+
`(${first.source.path}). A wave is declared once and contributed to freely: ` +
|
|
773
|
+
'drop the declaration here and questions in this catalogue will join it.',
|
|
774
|
+
...document.locate(['waves', waveIndex, 'id']),
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (duplicates.length > 0)
|
|
779
|
+
return { ok: false, diagnostics: duplicates };
|
|
780
|
+
const declaredWaves = new Set(declaredBy.keys());
|
|
781
|
+
const crossDiagnostics = documents.flatMap((document) => [
|
|
782
|
+
...undeclaredWaveDiagnostics(document, declaredWaves),
|
|
783
|
+
...unresolvableKindDiagnostics(document, profileContext),
|
|
784
|
+
]);
|
|
785
|
+
if (crossDiagnostics.length > 0) {
|
|
786
|
+
return { ok: false, diagnostics: crossDiagnostics };
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
ok: true,
|
|
790
|
+
composed: {
|
|
791
|
+
catalogue: {
|
|
792
|
+
...base.catalogue,
|
|
793
|
+
// The base names the composition, which is why the report's
|
|
794
|
+
// `catalogue` field keeps its value shape while `catalogues` lists
|
|
795
|
+
// every contributor.
|
|
796
|
+
waves: documents.flatMap(({ catalogue }) => catalogue.waves),
|
|
797
|
+
questions: documents.flatMap(({ catalogue }) => catalogue.questions.map((question) => ({
|
|
798
|
+
...question,
|
|
799
|
+
id: qualifiedQuestionId(catalogue.id, question.id),
|
|
800
|
+
}))),
|
|
635
801
|
},
|
|
636
|
-
|
|
802
|
+
catalogues: documents.map(({ catalogue }) => `${catalogue.id}@${catalogue.version}`),
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
// Shared by interrogate and design: schema validation plus the YM911
|
|
807
|
+
// undeclared-wave check, both source-located against the catalogue file.
|
|
808
|
+
export function loadQuestionCatalogue(catalogueSource, profileContext) {
|
|
809
|
+
const loaded = loadCatalogueDocument(catalogueSource);
|
|
810
|
+
if (!loaded.ok)
|
|
811
|
+
return { ok: false, diagnostics: loaded.diagnostics };
|
|
812
|
+
const waveDiagnostics = undeclaredWaveDiagnostics(loaded.document, new Set(loaded.document.catalogue.waves.map(({ id }) => id)));
|
|
637
813
|
if (waveDiagnostics.length > 0) {
|
|
638
814
|
return { ok: false, diagnostics: waveDiagnostics };
|
|
639
815
|
}
|
|
640
|
-
|
|
641
|
-
// there is no way to tell a typo from a kind whose profile simply is not
|
|
642
|
-
// here, and guessing would be the false positive this check exists to avoid.
|
|
643
|
-
const kindDiagnostics = profileContext === undefined
|
|
644
|
-
? []
|
|
645
|
-
: unresolvableKinds(catalogue, profileContext).map(({ kind, path }) => ({
|
|
646
|
-
severity: 'error',
|
|
647
|
-
code: 'YM914',
|
|
648
|
-
message: `Kind "${kind}" is not declared by profile "${kind.slice(0, kind.indexOf('#'))}", which this workspace loads, so the question can never fire`,
|
|
649
|
-
...locateSourcePath(catalogueSource.path, loadedCatalogue.document.yaml, loadedCatalogue.document.lineCounter, path, `/${path.join('/')}`),
|
|
650
|
-
}));
|
|
816
|
+
const kindDiagnostics = unresolvableKindDiagnostics(loaded.document, profileContext);
|
|
651
817
|
if (kindDiagnostics.length > 0) {
|
|
652
818
|
return { ok: false, diagnostics: kindDiagnostics };
|
|
653
819
|
}
|
|
654
|
-
return { ok: true, catalogue };
|
|
820
|
+
return { ok: true, catalogue: loaded.document.catalogue };
|
|
655
821
|
}
|
|
656
822
|
// Shared by interrogate and `ask --open`: the wave-by-wave human report.
|
|
657
823
|
export function renderInterrogationReport(report) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
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';
|
|
1
|
+
export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, type CatalogueCompositionResult, type ComposedCatalogue, 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';
|
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
// alone: catalogue loading takes a WorkspaceSource, evaluation takes an
|
|
8
8
|
// in-memory graph, and a test pins the import graph free of Node builtins.
|
|
9
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';
|
|
10
|
+
export { INTERROGATION_SEMANTICS_VERSION, composeCatalogues, qualifiedQuestionId, evaluateCatalogue, loadQuestionCatalogue, renderInterrogationReport, renderQuestion, } from './interrogate-command.js';
|
package/dist/projection.d.ts
CHANGED
|
@@ -108,6 +108,53 @@ export interface ProjectionExclusion {
|
|
|
108
108
|
readonly id: string;
|
|
109
109
|
readonly facet: ConceptFacet;
|
|
110
110
|
}
|
|
111
|
+
/** A query facet naming something the model does not have. */
|
|
112
|
+
export interface UnmatchedSelector {
|
|
113
|
+
readonly facet: string;
|
|
114
|
+
readonly value: string;
|
|
115
|
+
/** The closest name the facet does offer, when one is close enough to be a likely typo. */
|
|
116
|
+
readonly nearest?: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Every value in a projection query that names nothing.
|
|
120
|
+
*
|
|
121
|
+
* A projection is a DOCUMENT, and a query holds references the same way a
|
|
122
|
+
* relationship does. YarraMate refuses a relationship pointing at a concept
|
|
123
|
+
* that does not exist; it did not refuse a query naming a state that does not
|
|
124
|
+
* exist, and the symptom is silent. `states: [target-stat]` selects no state,
|
|
125
|
+
* which selects no subject, which exports a clean empty artifact with exit 0.
|
|
126
|
+
* Someone hands that to a client.
|
|
127
|
+
*
|
|
128
|
+
* Checked at `check`, not at `export`, because the typo is in a file rather
|
|
129
|
+
* than in an invocation: CI catches it, and every verb over the same
|
|
130
|
+
* projection inherits the guard instead of each growing its own.
|
|
131
|
+
*
|
|
132
|
+
* ONLY FACETS WITH A CLOSED NAMESPACE ARE CHECKED. `statuses` and
|
|
133
|
+
* `excludeStatuses` are schema enums, refused upstream before this runs.
|
|
134
|
+
* Everything else names something: `owners` and `constraints` are REFS to
|
|
135
|
+
* concepts, which the compiler itself proves by refusing an unresolved owner
|
|
136
|
+
* with YM304, so their namespace is the subject list like `subjects`.
|
|
137
|
+
*
|
|
138
|
+
* Each namespace is derived the way the FILTER derives it, so the check cannot
|
|
139
|
+
* drift from what it guards: `documents` reads the same provenance the
|
|
140
|
+
* `documents` facet compares against, and `states` is the same
|
|
141
|
+
* `yarramate/state/type` scan `conceptSelector` runs.
|
|
142
|
+
*
|
|
143
|
+
* A kind whose profile is not loaded is DORMANT rather than wrong, the same
|
|
144
|
+
* distinction #351 drew for question catalogues, so the kind facets are
|
|
145
|
+
* checked only when a profile context is present.
|
|
146
|
+
*
|
|
147
|
+
* An empty RESULT is not reported here and must not be. A query whose every
|
|
148
|
+
* name resolves and which selects nothing is a real answer to a real question:
|
|
149
|
+
* a target state nobody has populated yet is empty, correctly.
|
|
150
|
+
*/
|
|
151
|
+
export declare function unmatchedSelectors(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext): readonly UnmatchedSelector[];
|
|
152
|
+
/**
|
|
153
|
+
* {@link unmatchedSelectors} as diagnostics, built HERE rather than at each
|
|
154
|
+
* caller so `check`, and anything that adopts this later, refuse in identical
|
|
155
|
+
* words with an identical code.
|
|
156
|
+
*/
|
|
157
|
+
export declare function projectionReferenceDiagnostics(source: WorkspaceSource, projection: ProjectionDefinition, graph: SemanticGraph, profileContext?: ResolvedProfileContext): readonly Diagnostic[];
|
|
111
158
|
/**
|
|
112
159
|
* Every concept a query leaves out, and the facet that left it out.
|
|
113
160
|
*
|