@sdd-method/author-cli 0.11.0 → 0.12.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/lib/catalogue/build.js +33 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +132 -23
- package/dist/lib/catalogue/canonical-schema.yaml +767 -17
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +90 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +20 -8
- package/dist/lib/validate/behaviour-store.js +54 -13
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Catalogues a schema major has retired. A build writes the catalogues the schema declares, so the file of one
|
|
2
|
+
// it no longer declares would otherwise stay on disk beside its replacement, and `catalogue build --check`
|
|
3
|
+
// would report it committed and no longer produced in every estate after the wave. The build and the aggregate
|
|
4
|
+
// remove it from the directory they write, and say so. Only a catalogue named here is removed: a CSV the method
|
|
5
|
+
// never declared is left alone.
|
|
6
|
+
import { rm, stat } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
/** name -> why it went, and what replaced it. */
|
|
9
|
+
export const RETIRED_CATALOGUES = new Map([
|
|
10
|
+
["requirement_status", "retired in catalogue schema 6.0.0: evidence about one revision of a requirement is requirement_assertions (M-ADR 0230 §4.7, Proposed)"],
|
|
11
|
+
]);
|
|
12
|
+
/** Remove each retired catalogue's file from `dir`. Returns the file names removed. */
|
|
13
|
+
export async function removeRetiredCatalogues(dir, findings, label) {
|
|
14
|
+
const removed = [];
|
|
15
|
+
for (const [name, why] of RETIRED_CATALOGUES) {
|
|
16
|
+
const file = join(dir, `${name}.csv`);
|
|
17
|
+
if (!(await stat(file).then((s) => s.isFile(), () => false)))
|
|
18
|
+
continue;
|
|
19
|
+
await rm(file);
|
|
20
|
+
removed.push(`${name}.csv`);
|
|
21
|
+
findings.add("convention", `${label}/${name}.csv: removed, ${why}; commit its removal with the rebuilt catalogues`, "warn", `${label}/${name}.csv`);
|
|
22
|
+
}
|
|
23
|
+
return removed;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=retired-catalogues.js.map
|
|
@@ -883,7 +883,7 @@ coverage:
|
|
|
883
883
|
class: lineage
|
|
884
884
|
impact_flow: none
|
|
885
885
|
reason: derived from the archived changes scored by their own judge
|
|
886
|
-
|
|
886
|
+
requirement_assertions:
|
|
887
887
|
kind: edge
|
|
888
888
|
scoring: derived-edge
|
|
889
889
|
class: realisation
|
|
@@ -901,7 +901,7 @@ coverage:
|
|
|
901
901
|
kind: enum
|
|
902
902
|
scoring: not-applicable
|
|
903
903
|
reason: closed vocabulary, not an artefact
|
|
904
|
-
|
|
904
|
+
requirement_assertion_kind:
|
|
905
905
|
kind: enum
|
|
906
906
|
scoring: not-applicable
|
|
907
907
|
reason: closed vocabulary, not an artefact
|
|
@@ -951,3 +951,91 @@ coverage:
|
|
|
951
951
|
kind: enum
|
|
952
952
|
scoring: not-applicable
|
|
953
953
|
reason: closed vocabulary, not an artefact
|
|
954
|
+
entity_attributes:
|
|
955
|
+
kind: entity
|
|
956
|
+
scoring: scored-at-source
|
|
957
|
+
instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
958
|
+
relationships:
|
|
959
|
+
entity_id:
|
|
960
|
+
class: membership
|
|
961
|
+
impact_flow: toward-target
|
|
962
|
+
entity_relationships:
|
|
963
|
+
kind: entity
|
|
964
|
+
scoring: scored-at-source
|
|
965
|
+
instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
966
|
+
relationships:
|
|
967
|
+
from_entity_id:
|
|
968
|
+
class: membership
|
|
969
|
+
impact_flow: toward-target
|
|
970
|
+
to_entity_id:
|
|
971
|
+
class: dependency
|
|
972
|
+
impact_flow: toward-source
|
|
973
|
+
domain_invariants:
|
|
974
|
+
kind: entity
|
|
975
|
+
scoring: scored-at-source
|
|
976
|
+
instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
|
|
977
|
+
relationships:
|
|
978
|
+
entity_id:
|
|
979
|
+
class: membership
|
|
980
|
+
impact_flow: toward-target
|
|
981
|
+
relationship_id:
|
|
982
|
+
class: dependency
|
|
983
|
+
impact_flow: toward-source
|
|
984
|
+
source_of_truth:
|
|
985
|
+
kind: enum
|
|
986
|
+
scoring: not-applicable
|
|
987
|
+
reason: closed vocabulary, not an artefact
|
|
988
|
+
relationship_end_cardinality:
|
|
989
|
+
kind: enum
|
|
990
|
+
scoring: not-applicable
|
|
991
|
+
reason: closed vocabulary, not an artefact
|
|
992
|
+
domain_invariant_kind:
|
|
993
|
+
kind: enum
|
|
994
|
+
scoring: not-applicable
|
|
995
|
+
reason: closed vocabulary, not an artefact
|
|
996
|
+
requirement_revisions:
|
|
997
|
+
kind: entity
|
|
998
|
+
scoring: scored-at-source
|
|
999
|
+
instrument: a revision of a requirement; scored with its living spec by the behaviour-store check
|
|
1000
|
+
relationships:
|
|
1001
|
+
requirement_id:
|
|
1002
|
+
class: membership
|
|
1003
|
+
impact_flow: toward-target
|
|
1004
|
+
change_id:
|
|
1005
|
+
class: lineage
|
|
1006
|
+
impact_flow: toward-source
|
|
1007
|
+
revision_state:
|
|
1008
|
+
kind: enum
|
|
1009
|
+
scoring: not-applicable
|
|
1010
|
+
reason: closed vocabulary, not an artefact
|
|
1011
|
+
change_effects:
|
|
1012
|
+
kind: entity
|
|
1013
|
+
scoring: scored-at-source
|
|
1014
|
+
instrument: a change's effect; scored with the change by the behaviour-store and structural-model checks
|
|
1015
|
+
relationships:
|
|
1016
|
+
change_id:
|
|
1017
|
+
class: membership
|
|
1018
|
+
impact_flow: toward-target
|
|
1019
|
+
change_effect_element_kind:
|
|
1020
|
+
kind: enum
|
|
1021
|
+
scoring: not-applicable
|
|
1022
|
+
reason: closed vocabulary, not an artefact
|
|
1023
|
+
adr_governs:
|
|
1024
|
+
kind: enum
|
|
1025
|
+
scoring: not-applicable
|
|
1026
|
+
reason: closed vocabulary, not an artefact
|
|
1027
|
+
contract_revisions:
|
|
1028
|
+
kind: entity
|
|
1029
|
+
scoring: scored-at-source
|
|
1030
|
+
instrument: a revision of a contract; scored with its contract by the contract rubrics
|
|
1031
|
+
relationships:
|
|
1032
|
+
contract_id:
|
|
1033
|
+
class: membership
|
|
1034
|
+
impact_flow: toward-target
|
|
1035
|
+
change_id:
|
|
1036
|
+
class: lineage
|
|
1037
|
+
impact_flow: toward-source
|
|
1038
|
+
contract_revision_state:
|
|
1039
|
+
kind: enum
|
|
1040
|
+
scoring: not-applicable
|
|
1041
|
+
reason: closed vocabulary, not an artefact
|
|
@@ -36,6 +36,7 @@ function normalise(raw) {
|
|
|
36
36
|
type: c.type,
|
|
37
37
|
nullable: c.nullable ?? false,
|
|
38
38
|
...(c.description !== undefined ? { description: c.description } : {}),
|
|
39
|
+
...((c.source_of_truth ?? def.source_of_truth) !== undefined ? { sourceOfTruth: c.source_of_truth ?? def.source_of_truth } : {}),
|
|
39
40
|
})),
|
|
40
41
|
foreignKeys: (def.foreign_keys ?? []).map((fk) => ({
|
|
41
42
|
column: fk.column,
|
|
@@ -45,6 +46,8 @@ function normalise(raw) {
|
|
|
45
46
|
externalForProfiles: fk.external_for_profiles ?? [],
|
|
46
47
|
})),
|
|
47
48
|
columnEnums: def.column_enums ?? {},
|
|
49
|
+
...(def.source_of_truth !== undefined ? { sourceOfTruth: def.source_of_truth } : {}),
|
|
50
|
+
...(def.natural_key !== undefined ? { naturalKey: def.natural_key } : {}),
|
|
48
51
|
}));
|
|
49
52
|
const enums = {};
|
|
50
53
|
for (const [name, def] of Object.entries(raw.enums ?? {})) {
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
// none — already reported by scores-coverage as unknown currency; not
|
|
41
41
|
// re-reported here.
|
|
42
42
|
//
|
|
43
|
+
// ASSESSED INPUTS (M-ADR 0230 §4.9, Proposed): a review that names its other inputs (`input_paths`) is read with
|
|
44
|
+
// each of them, once its target is current. An input that moved stales the score, with the input named. So does an
|
|
45
|
+
// input gone from HEAD, and one the corpus did not hold at the anchor: unlike a missing target, the score can still
|
|
46
|
+
// be re-reviewed against its target, and a review cannot have read an input that was not there, so both prescribe a
|
|
47
|
+
// re-review rather than a withdrawal. An input present but uncommitted is UNKNOWN, as the target is, unless another
|
|
48
|
+
// input moved, which decides it whatever order the inputs are listed in. On the
|
|
49
|
+
// scored_at basis an input changed after the review stales it.
|
|
50
|
+
//
|
|
43
51
|
// MEMBER TIER ONLY, stated not silent: the aggregate tier reads sibling
|
|
44
52
|
// catalogues whose target files and git histories are not local, so a verdict
|
|
45
53
|
// there would be a guess. Reporting-only (`coverage` class) like the rest of
|
|
@@ -163,9 +171,12 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
163
171
|
};
|
|
164
172
|
}
|
|
165
173
|
const asStr = (v) => v === null || v === undefined ? "" : String(v).trim();
|
|
166
|
-
|
|
174
|
+
// input_paths (schema 6.0.0, M-ADR 0230 §4.9, Proposed): a catalogue built before it has no such column, and its
|
|
175
|
+
// scores are read as naming no other input.
|
|
176
|
+
const query = (inputs) => `SELECT rubric_id, target_ref, target_path, corpus_ref, scored_at, staleness_basis, ${inputs}
|
|
167
177
|
FROM "quality_scores"
|
|
168
|
-
WHERE staleness_basis IN ('corpus_ref', 'scored_at')
|
|
178
|
+
WHERE staleness_basis IN ('corpus_ref', 'scored_at');`;
|
|
179
|
+
const reader = await session.conn.runAndReadAll(query("input_paths")).catch(() => session.conn.runAndReadAll(query("NULL")));
|
|
169
180
|
const rows = reader.getRows();
|
|
170
181
|
const stale = [];
|
|
171
182
|
let current = 0;
|
|
@@ -176,10 +187,13 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
176
187
|
const targetMissing = [];
|
|
177
188
|
const anchorKnown = new Map();
|
|
178
189
|
const renamesByAnchor = new Map();
|
|
179
|
-
for (const [rubricIdRaw, targetRefRaw, targetPathRaw, corpusRefRaw, scoredAtRaw, basisRaw] of rows) {
|
|
190
|
+
for (const [rubricIdRaw, targetRefRaw, targetPathRaw, corpusRefRaw, scoredAtRaw, basisRaw, inputPathsRaw] of rows) {
|
|
180
191
|
const rubricId = asStr(rubricIdRaw);
|
|
181
192
|
const targetRef = asStr(targetRefRaw);
|
|
182
193
|
const basis = asStr(basisRaw);
|
|
194
|
+
// The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): read with the target, so an edit to any of
|
|
195
|
+
// them makes the score stale as an edit to the target does.
|
|
196
|
+
const inputs = asStr(inputPathsRaw).split(";").map((x) => x.trim()).filter((x) => x !== "");
|
|
183
197
|
// Counted for BOTH bases, before the branch. The first cut of this counter
|
|
184
198
|
// sat inside the corpus_ref branch only, so it was blind to every
|
|
185
199
|
// scored_at row — and the estate that ASKED for this count has all 18 of
|
|
@@ -242,7 +256,42 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
242
256
|
if (anchorBlob === headBlob) {
|
|
243
257
|
// Byte-identical to its assessed state — including across a rename,
|
|
244
258
|
// which is the whole point of testing content instead of touches.
|
|
245
|
-
|
|
259
|
+
let moved = null;
|
|
260
|
+
let unknown = false;
|
|
261
|
+
for (const input of inputs) {
|
|
262
|
+
const now = await blobAt(options.sddRoot, "HEAD", input);
|
|
263
|
+
if (now === null && (await existsOnDisk(options.sddRoot, input))) {
|
|
264
|
+
unknown = true; // present but uncommitted: no blob to compare, as for the target
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let then = await blobAt(options.sddRoot, anchor, input);
|
|
268
|
+
if (then === null) {
|
|
269
|
+
let renames = renamesByAnchor.get(anchor);
|
|
270
|
+
if (renames === undefined) {
|
|
271
|
+
renames = await renameMap(options.sddRoot, anchor);
|
|
272
|
+
renamesByAnchor.set(anchor, renames);
|
|
273
|
+
}
|
|
274
|
+
const oldPath = renames.get(input);
|
|
275
|
+
if (oldPath !== undefined)
|
|
276
|
+
then = await blobAt(options.sddRoot, anchor, oldPath);
|
|
277
|
+
}
|
|
278
|
+
if (now === null)
|
|
279
|
+
moved = `assessed input ${input} no longer exists at HEAD`;
|
|
280
|
+
else if (then === null)
|
|
281
|
+
moved = `assessed input ${input} was not in the corpus at the anchor`;
|
|
282
|
+
else if (then !== now)
|
|
283
|
+
moved = `assessed input ${input} CONTENT differs from its state at the anchor`;
|
|
284
|
+
if (moved !== null)
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
// An input that moved decides it, whatever the others are; with none moved, an uncommitted input leaves the
|
|
288
|
+
// score undecided.
|
|
289
|
+
if (moved !== null)
|
|
290
|
+
stale.push({ rubricId, targetRef, basis: "corpus_ref", detail: moved });
|
|
291
|
+
else if (unknown)
|
|
292
|
+
unknownAnchor += 1;
|
|
293
|
+
else
|
|
294
|
+
current += 1;
|
|
246
295
|
}
|
|
247
296
|
else {
|
|
248
297
|
stale.push({
|
|
@@ -284,10 +333,30 @@ export async function runStalenessVerdicts(session, options) {
|
|
|
284
333
|
basis: "scored_at",
|
|
285
334
|
detail: `target last changed ${last.stdout.slice(0, 10)}, after the review of ${at.slice(0, 10)}`,
|
|
286
335
|
});
|
|
336
|
+
continue;
|
|
287
337
|
}
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
let moved = null;
|
|
339
|
+
let unknown = false;
|
|
340
|
+
for (const input of inputs) {
|
|
341
|
+
const changed = await git(options.sddRoot, ["log", "-1", "--format=%cI", "--", input]);
|
|
342
|
+
if ((await blobAt(options.sddRoot, "HEAD", input)) === null) {
|
|
343
|
+
if (await existsOnDisk(options.sddRoot, input)) {
|
|
344
|
+
unknown = true; // present but uncommitted: git has no history for it
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
moved = `assessed input ${input} no longer exists at HEAD`;
|
|
348
|
+
}
|
|
349
|
+
else if (changed.ok && changed.stdout !== "" && Date.parse(changed.stdout) > reviewedMs)
|
|
350
|
+
moved = `assessed input ${input} last changed ${changed.stdout.slice(0, 10)}, after the review of ${at.slice(0, 10)}`;
|
|
351
|
+
if (moved !== null)
|
|
352
|
+
break;
|
|
290
353
|
}
|
|
354
|
+
if (moved !== null)
|
|
355
|
+
stale.push({ rubricId, targetRef, basis: "scored_at", detail: moved });
|
|
356
|
+
else if (unknown)
|
|
357
|
+
unknownAnchor += 1;
|
|
358
|
+
else
|
|
359
|
+
current += 1;
|
|
291
360
|
}
|
|
292
361
|
}
|
|
293
362
|
return {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The catalogue from the structural model, and the model a built catalogue implies (M-ADR 0230 §4.3, §5.3,
|
|
2
|
+
// Proposed). Where an SDD has a model, it is the record of structure, so every structure catalogue is built from it whole:
|
|
3
|
+
// a row per element of the current view, with the model's structure columns;
|
|
4
|
+
// its other columns (every class but structure: derived, declaration, evidence, intent, execution) from the
|
|
5
|
+
// source-built row with the same id;
|
|
6
|
+
// for an element only the model holds, the derived columns the model itself settles (MODEL_DERIVED).
|
|
7
|
+
// What the sources hold and the model does not, and a structure column where they disagree, is reported once per
|
|
8
|
+
// catalogue. The model wins: either finding means the sources moved outside a change. Pure: no file is read or written.
|
|
9
|
+
import { stringify } from "yaml";
|
|
10
|
+
import { FORMAT_VERSION } from "./model.js";
|
|
11
|
+
import { STRUCTURE } from "./view.js";
|
|
12
|
+
/** The view each generated file renders, for a derived path column of an element only the model holds. */
|
|
13
|
+
export const VIEW_PATHS = {
|
|
14
|
+
c4Context: "docs/structure/c4-context.md",
|
|
15
|
+
c4Containers: "docs/structure/c4-containers.md",
|
|
16
|
+
services: "docs/structure/services.md",
|
|
17
|
+
featureTree: "docs/structure/feature-tree.md",
|
|
18
|
+
dataModel: "docs/structure/data-model.md",
|
|
19
|
+
};
|
|
20
|
+
const EXAMPLES = 5;
|
|
21
|
+
const cell = (v) => (v === null || v === undefined ? "" : typeof v === "boolean" ? (v ? "true" : "false") : String(v));
|
|
22
|
+
/** The derived columns of a row built from an element the sources do not hold. */
|
|
23
|
+
function modelDerived(catalogue, e, view) {
|
|
24
|
+
switch (catalogue) {
|
|
25
|
+
case "features":
|
|
26
|
+
case "capabilities":
|
|
27
|
+
return { source: "structural-model" };
|
|
28
|
+
case "domain_models":
|
|
29
|
+
return { doc_path: VIEW_PATHS.dataModel };
|
|
30
|
+
case "c4_systems":
|
|
31
|
+
return { diagram_path: VIEW_PATHS.c4Context };
|
|
32
|
+
case "domains":
|
|
33
|
+
return { repo_count: [...(view.get("repositories")?.values() ?? [])].filter((r) => r.columns["domain_id"] === e.id).length };
|
|
34
|
+
default:
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function structureColumns(cat) {
|
|
39
|
+
return cat.columns.filter((c) => c.name !== cat.primaryKey && c.sourceOfTruth === STRUCTURE).map((c) => c.name);
|
|
40
|
+
}
|
|
41
|
+
function sample(ids) {
|
|
42
|
+
return ids.slice(0, EXAMPLES).join(", ") + (ids.length > EXAMPLES ? `, and ${ids.length - EXAMPLES} more` : "");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Replace every structure catalogue's rows with the view's. Rows of other catalogues are returned as they are. Source
|
|
46
|
+
* rows sharing an id are merged first, later values filling earlier gaps, as the keyed-table dedupe would.
|
|
47
|
+
*/
|
|
48
|
+
export function overlayModel(schema, view, rows, findings) {
|
|
49
|
+
const out = new Map(rows);
|
|
50
|
+
for (const cat of schema.catalogues) {
|
|
51
|
+
if (cat.sourceOfTruth !== STRUCTURE)
|
|
52
|
+
continue;
|
|
53
|
+
const pk = cat.primaryKey;
|
|
54
|
+
const structural = structureColumns(cat);
|
|
55
|
+
const source = new Map();
|
|
56
|
+
for (const r of rows.get(cat.name) ?? []) {
|
|
57
|
+
const id = cell(r[pk]);
|
|
58
|
+
const prior = source.get(id);
|
|
59
|
+
if (!prior)
|
|
60
|
+
source.set(id, { ...r });
|
|
61
|
+
else
|
|
62
|
+
for (const [k, v] of Object.entries(r))
|
|
63
|
+
if (cell(prior[k]) === "" && cell(v) !== "")
|
|
64
|
+
prior[k] = v;
|
|
65
|
+
}
|
|
66
|
+
const elements = view.get(cat.name) ?? new Map();
|
|
67
|
+
const built = [];
|
|
68
|
+
const differ = [];
|
|
69
|
+
for (const e of elements.values()) {
|
|
70
|
+
const from = source.get(e.id);
|
|
71
|
+
const row = {};
|
|
72
|
+
if (from) {
|
|
73
|
+
for (const [k, v] of Object.entries(from))
|
|
74
|
+
if (!structural.includes(k))
|
|
75
|
+
row[k] = v;
|
|
76
|
+
const cols = structural.filter((c) => cell(from[c]) !== "" && cell(from[c]) !== cell(e.columns[c]));
|
|
77
|
+
if (cols.length > 0)
|
|
78
|
+
differ.push(`${e.id} (${cols.join(", ")})`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
Object.assign(row, modelDerived(cat.name, e, view));
|
|
82
|
+
}
|
|
83
|
+
row[pk] = e.id;
|
|
84
|
+
for (const c of structural)
|
|
85
|
+
row[c] = e.columns[c];
|
|
86
|
+
built.push(row);
|
|
87
|
+
}
|
|
88
|
+
const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id)).sort();
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
findings.add("divergence", `${cat.name}: the sources hold ${missing.length} row(s) the structural model does not (${sample(missing)}); the model is the record of structure, so they are not catalogued. Add them through a change's structural deltas, or remove them from the sources (M-ADR 0230 §4.3, Proposed)`, "warn", `catalogue:${cat.name}`);
|
|
91
|
+
}
|
|
92
|
+
if (differ.length > 0) {
|
|
93
|
+
findings.add("divergence", `${cat.name}: ${differ.length} element(s) where the sources and the structural model disagree, catalogued as the model states them: ${sample(differ.sort())}. A source that is a generated view is rendered again; any other is brought into line through a change (M-ADR 0230 §4.3, Proposed)`, "warn", `catalogue:${cat.name}`);
|
|
94
|
+
}
|
|
95
|
+
out.set(cat.name, built);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function typed(type, v) {
|
|
100
|
+
switch (type) {
|
|
101
|
+
case "int":
|
|
102
|
+
case "integer":
|
|
103
|
+
case "number": {
|
|
104
|
+
const n = Number(v);
|
|
105
|
+
return Number.isFinite(n) ? n : null;
|
|
106
|
+
}
|
|
107
|
+
case "boolean":
|
|
108
|
+
return v === "true" ? true : v === "false" ? false : null;
|
|
109
|
+
default:
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The model a built catalogue implies: each structure row as an element of its non-empty structure columns, typed by
|
|
115
|
+
* column. A platform's element goes to its domain's partition (its own `domain_id`, or the first element it references
|
|
116
|
+
* that has one; a domain is its own), else to `platform`; an application or integration SDD's to its sdd_id. A cell
|
|
117
|
+
* that does not parse as its column's type is reported and left out, which is the projection's only loss.
|
|
118
|
+
*/
|
|
119
|
+
export function modelFromRows(schema, rows, partitioning, loss) {
|
|
120
|
+
const byKey = new Map();
|
|
121
|
+
for (const cat of schema.catalogues) {
|
|
122
|
+
if (cat.sourceOfTruth !== STRUCTURE)
|
|
123
|
+
continue;
|
|
124
|
+
for (const r of rows.get(cat.name) ?? [])
|
|
125
|
+
byKey.set(`${cat.name}/${cell(r[cat.primaryKey])}`, r);
|
|
126
|
+
}
|
|
127
|
+
const memo = new Map();
|
|
128
|
+
const domainOf = (catName, row, seen) => {
|
|
129
|
+
const cat = schema.catalogues.find((c) => c.name === catName);
|
|
130
|
+
const key = `${catName}/${cell(row[cat.primaryKey])}`;
|
|
131
|
+
if (memo.has(key))
|
|
132
|
+
return memo.get(key);
|
|
133
|
+
if (seen.has(key))
|
|
134
|
+
return null;
|
|
135
|
+
seen.add(key);
|
|
136
|
+
let d = catName === "domains" ? cell(row[cat.primaryKey]) : cell(row["domain_id"]) || null;
|
|
137
|
+
for (const fk of d === null ? cat.foreignKeys : []) {
|
|
138
|
+
const [target, column] = fk.references.split(".");
|
|
139
|
+
if (fk.external || column !== "id" || cell(row[fk.column]) === "")
|
|
140
|
+
continue;
|
|
141
|
+
const next = byKey.get(`${target}/${cell(row[fk.column])}`);
|
|
142
|
+
if (next && (d = domainOf(target, next, seen)) !== null)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
memo.set(key, d);
|
|
146
|
+
return d;
|
|
147
|
+
};
|
|
148
|
+
const out = new Map();
|
|
149
|
+
for (const cat of schema.catalogues) {
|
|
150
|
+
if (cat.sourceOfTruth !== STRUCTURE)
|
|
151
|
+
continue;
|
|
152
|
+
const cols = cat.columns.filter((c) => c.name !== cat.primaryKey && c.sourceOfTruth === STRUCTURE);
|
|
153
|
+
const sorted = [...(rows.get(cat.name) ?? [])].sort((a, b) => cell(a[cat.primaryKey]).localeCompare(cell(b[cat.primaryKey])));
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const r of sorted) {
|
|
156
|
+
const id = cell(r[cat.primaryKey]);
|
|
157
|
+
if (id === "" || seen.has(id))
|
|
158
|
+
continue;
|
|
159
|
+
seen.add(id);
|
|
160
|
+
const element = { id };
|
|
161
|
+
for (const c of cols) {
|
|
162
|
+
const v = cell(r[c.name]);
|
|
163
|
+
if (v === "")
|
|
164
|
+
continue;
|
|
165
|
+
const t = typed(c.type, v);
|
|
166
|
+
if (t === null)
|
|
167
|
+
loss.push(`${cat.name}/${id}.${c.name}: ${JSON.stringify(v)} is not of type ${c.type}`);
|
|
168
|
+
else
|
|
169
|
+
element[c.name] = t;
|
|
170
|
+
}
|
|
171
|
+
const partition = partitioning.platform ? (domainOf(cat.name, r, new Set()) ?? "platform") : partitioning.sddId;
|
|
172
|
+
const file = out.get(partition) ?? {};
|
|
173
|
+
(file[cat.name] ??= []).push(element);
|
|
174
|
+
out.set(partition, file);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** One partition's model file, in the format loadModel reads. */
|
|
180
|
+
export function modelFileText(partition, elements) {
|
|
181
|
+
return stringify({ schema_version: FORMAT_VERSION, partition, elements });
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=catalogue.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7, Proposed).
|
|
2
|
+
// current the model as published
|
|
3
|
+
// at a change current with that change's deltas and those of its dependency closure, dependencies first
|
|
4
|
+
// intended current with every agreed change's deltas
|
|
5
|
+
// Distinct ids do not prove independence, so three checks run:
|
|
6
|
+
// 1 write overlap two agreed changes never MODIFY or REMOVE one element, and never ADD one id; a change
|
|
7
|
+
// not yet agreed that overlaps an agreed one cannot be agreed while that one is open
|
|
8
|
+
// 2 composed validation each view is validated on its own; what fails in a view and not in current is reported
|
|
9
|
+
// against the changes that write the elements it names
|
|
10
|
+
// 3 read dependencies base revisions still current (validateDeltas); a dependency withdrawn invalidates its
|
|
11
|
+
// dependants; an agreed change depending on one not yet agreed; a cycle of dependencies
|
|
12
|
+
// Pure: reads nothing; the structural-model validator uses it.
|
|
13
|
+
import { elementKey } from "./model.js";
|
|
14
|
+
/** Element key -> the write a change makes to it. */
|
|
15
|
+
export function writesOf(s) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
for (const d of s.deltas) {
|
|
18
|
+
for (const e of d.added)
|
|
19
|
+
out.set(elementKey(e.catalogue, e.id), "ADDED");
|
|
20
|
+
for (const e of d.modified)
|
|
21
|
+
out.set(elementKey(e.catalogue, e.id), "MODIFIED");
|
|
22
|
+
for (const r of d.removed)
|
|
23
|
+
out.set(elementKey(r.catalogue, r.id), "REMOVED");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function copy(view) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
for (const [c, m] of view)
|
|
30
|
+
out.set(c, new Map(m));
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** The view with one change's deltas applied: an addition or modification replaces whole, a removal deletes. */
|
|
34
|
+
export function apply(view, s) {
|
|
35
|
+
const out = copy(view);
|
|
36
|
+
const put = (e) => {
|
|
37
|
+
const m = out.get(e.catalogue) ?? new Map();
|
|
38
|
+
m.set(e.id, e);
|
|
39
|
+
out.set(e.catalogue, m);
|
|
40
|
+
};
|
|
41
|
+
for (const d of s.deltas) {
|
|
42
|
+
for (const e of d.added)
|
|
43
|
+
put(e);
|
|
44
|
+
for (const e of d.modified)
|
|
45
|
+
put(e);
|
|
46
|
+
for (const r of d.removed)
|
|
47
|
+
out.get(r.catalogue)?.delete(r.id);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A change's dependency closure, dependencies first and the change last. A dependency that is not open is skipped
|
|
53
|
+
* here (archived: its additions are current; otherwise reported as withdrawn by checkDependencies). A cycle stops the
|
|
54
|
+
* walk and is reported there too.
|
|
55
|
+
*/
|
|
56
|
+
export function closure(change, open) {
|
|
57
|
+
const order = [];
|
|
58
|
+
const visiting = new Set();
|
|
59
|
+
const visit = (c) => {
|
|
60
|
+
if (order.includes(c) || visiting.has(c))
|
|
61
|
+
return;
|
|
62
|
+
const oc = open.get(c);
|
|
63
|
+
if (!oc)
|
|
64
|
+
return;
|
|
65
|
+
visiting.add(c);
|
|
66
|
+
for (const d of oc.structure.deltas)
|
|
67
|
+
for (const dep of d.dependsOn)
|
|
68
|
+
visit(dep);
|
|
69
|
+
visiting.delete(c);
|
|
70
|
+
order.push(c);
|
|
71
|
+
};
|
|
72
|
+
visit(change);
|
|
73
|
+
return order;
|
|
74
|
+
}
|
|
75
|
+
export function viewAt(current, change, open) {
|
|
76
|
+
let v = current;
|
|
77
|
+
for (const c of closure(change, open))
|
|
78
|
+
v = apply(v, open.get(c).structure);
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
/** Agreed changes in an order that applies each after its dependencies. */
|
|
82
|
+
export function agreedOrder(open) {
|
|
83
|
+
const order = [];
|
|
84
|
+
for (const [name, oc] of [...open].sort(([a], [b]) => a.localeCompare(b))) {
|
|
85
|
+
if (oc.status !== "agreed")
|
|
86
|
+
continue;
|
|
87
|
+
for (const c of closure(name, open))
|
|
88
|
+
if (!order.includes(c) && open.get(c).status === "agreed")
|
|
89
|
+
order.push(c);
|
|
90
|
+
}
|
|
91
|
+
return order;
|
|
92
|
+
}
|
|
93
|
+
export function intended(current, open) {
|
|
94
|
+
let v = current;
|
|
95
|
+
for (const c of agreedOrder(open))
|
|
96
|
+
v = apply(v, open.get(c).structure);
|
|
97
|
+
return v;
|
|
98
|
+
}
|
|
99
|
+
/** Check 1: write overlap. */
|
|
100
|
+
export function checkOverlap(open, problems) {
|
|
101
|
+
const names = [...open.keys()].sort();
|
|
102
|
+
const writes = new Map(names.map((n) => [n, writesOf(open.get(n).structure)]));
|
|
103
|
+
for (let i = 0; i < names.length; i++) {
|
|
104
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
105
|
+
const a = names[i];
|
|
106
|
+
const b = names[j];
|
|
107
|
+
const sa = open.get(a).status;
|
|
108
|
+
const sb = open.get(b).status;
|
|
109
|
+
if (sa !== "agreed" && sb !== "agreed")
|
|
110
|
+
continue;
|
|
111
|
+
for (const [key, wa] of writes.get(a)) {
|
|
112
|
+
const wb = writes.get(b).get(key);
|
|
113
|
+
if (wb === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
// One adding what the other modifies or removes is reported by the other's own deltas, whose base cannot exist.
|
|
116
|
+
// Two additions of one id are one id claimed twice, whatever their content.
|
|
117
|
+
if ((wa === "ADDED") !== (wb === "ADDED"))
|
|
118
|
+
continue;
|
|
119
|
+
const verb = wa === "ADDED" ? "both ADD" : wa === wb ? `both ${wa}` : `${wa} and ${wb}`;
|
|
120
|
+
if (sa === "agreed" && sb === "agreed")
|
|
121
|
+
problems.push(`write overlap: agreed changes ${a} and ${b} ${verb} ${key}; reorder or merge them`);
|
|
122
|
+
else {
|
|
123
|
+
const [held, waiting] = sa === "agreed" ? [a, b] : [b, a];
|
|
124
|
+
problems.push(`write overlap: ${waiting} ${verb === "both ADD" ? "adds" : "writes"} ${key}, which agreed change ${held} also ${verb === "both ADD" ? "adds" : "writes"}; it cannot be agreed while ${held} is open`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Check 3, beyond base revisions: withdrawn dependencies, agreement before a dependency, cycles. */
|
|
131
|
+
export function checkDependencies(open, archived, problems) {
|
|
132
|
+
for (const [name, oc] of [...open].sort(([a], [b]) => a.localeCompare(b))) {
|
|
133
|
+
const deps = new Set(oc.structure.deltas.flatMap((d) => [...d.dependsOn]));
|
|
134
|
+
for (const dep of deps) {
|
|
135
|
+
const target = open.get(dep);
|
|
136
|
+
if (target === undefined) {
|
|
137
|
+
if (!archived.has(dep))
|
|
138
|
+
problems.push(`read dependency: ${name} depends on ${dep}, which is neither open nor archived: withdrawn, so ${name} is invalidated until it no longer needs it`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (target.status === "withdrawn" || target.status === "superseded") {
|
|
142
|
+
problems.push(`read dependency: ${name} depends on ${dep}, whose agreement record is ${target.status}: ${name} is invalidated until it no longer needs it`);
|
|
143
|
+
}
|
|
144
|
+
else if (oc.status === "agreed" && target.status !== "agreed") {
|
|
145
|
+
problems.push(`read dependency: ${name} is agreed and depends on ${dep}, which is not: a change is agreed with its dependency closure`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A cycle through this change.
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const stack = [...deps];
|
|
151
|
+
while (stack.length > 0) {
|
|
152
|
+
const c = stack.pop();
|
|
153
|
+
if (c === name) {
|
|
154
|
+
problems.push(`read dependency: ${name} depends on itself through depends_on; a cycle can never archive`);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (seen.has(c))
|
|
158
|
+
continue;
|
|
159
|
+
seen.add(c);
|
|
160
|
+
for (const d of open.get(c)?.structure.deltas ?? [])
|
|
161
|
+
stack.push(...d.dependsOn);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const KEY_RE = /\b([a-z][a-z0-9_]*)\/([^\s,;:()"]+)/g;
|
|
166
|
+
/** The changes that write any element a finding names. */
|
|
167
|
+
export function attribute(problem, open, among) {
|
|
168
|
+
const keys = new Set([...problem.matchAll(KEY_RE)].map((m) => `${m[1]}/${m[2]}`));
|
|
169
|
+
return among.filter((c) => {
|
|
170
|
+
const w = writesOf(open.get(c).structure);
|
|
171
|
+
return [...keys].some((k) => w.has(k));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=compose.js.map
|