@sdd-method/author-cli 0.10.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.
Files changed (38) hide show
  1. package/dist/lib/behaviour-store/archive.js +13 -0
  2. package/dist/lib/catalogue/build.js +45 -2
  3. package/dist/lib/catalogue/builders/adr-governs.js +39 -0
  4. package/dist/lib/catalogue/builders/adrs.js +11 -0
  5. package/dist/lib/catalogue/builders/capability-spec.js +1 -0
  6. package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
  7. package/dist/lib/catalogue/builders/data-model.js +316 -0
  8. package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
  9. package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
  10. package/dist/lib/catalogue/builders/features.js +23 -2
  11. package/dist/lib/catalogue/builders/integration-components.js +49 -5
  12. package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
  13. package/dist/lib/catalogue/builders/quality-scores.js +17 -0
  14. package/dist/lib/catalogue/builders/requirements.js +168 -32
  15. package/dist/lib/catalogue/canonical-schema.yaml +845 -28
  16. package/dist/lib/catalogue/manifest-loader.js +13 -0
  17. package/dist/lib/catalogue/retired-catalogues.js +25 -0
  18. package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
  19. package/dist/lib/catalogue/schema-compat.js +1 -1
  20. package/dist/lib/catalogue/schema-loader.js +3 -0
  21. package/dist/lib/catalogue/staleness-verdicts.js +75 -6
  22. package/dist/lib/intents/migrate.js +35 -9
  23. package/dist/lib/metamodel/tql-subset-parser.js +35 -2
  24. package/dist/lib/structure/catalogue.js +183 -0
  25. package/dist/lib/structure/compose.js +174 -0
  26. package/dist/lib/structure/effects.js +36 -0
  27. package/dist/lib/structure/model.js +212 -0
  28. package/dist/lib/structure/render.js +242 -0
  29. package/dist/lib/structure/view.js +301 -0
  30. package/dist/lib/validate/agreement-evidence.js +216 -0
  31. package/dist/lib/validate/behaviour-store.js +516 -28
  32. package/dist/lib/validate/dispatch.js +21 -0
  33. package/dist/lib/validate/feature-spec-pointers.js +175 -0
  34. package/dist/lib/validate/journey-impact.js +193 -0
  35. package/dist/lib/validate/structural-model.js +265 -0
  36. package/dist/maintain-version.json +2 -2
  37. package/dist/verify-version.json +2 -2
  38. package/package.json +2 -2
@@ -218,6 +218,16 @@ const ManifestSchema = z.object({
218
218
  // Shared-library trait (sdd-manifest-schema 1.12.0, M-ADR 0217 §4.4). Profile-agnostic,
219
219
  // like `exemplar`; absent is the unmarked norm, no finding.
220
220
  shared_library: z.boolean().optional(),
221
+ // Where this estate's journey behaviour is mastered (sdd-manifest-schema 1.13.0, M-ADR 0229 §4.2, Proposed).
222
+ // `master` is declared at the root, and journey-impact treats a member naming its platform as inheriting it.
223
+ // `test_paths` is never inherited: it is relative to this SDD, so an SDD that holds end-to-end files declares
224
+ // its own block, with the master and its paths. Unknown values fail at load.
225
+ journeys: z
226
+ .object({
227
+ master: z.enum(["test-file", "store"]),
228
+ test_paths: z.array(z.string()).optional(),
229
+ })
230
+ .optional(),
221
231
  // Application-product archetype trait (M-ADR 0191, sdd-manifest-schema
222
232
  // 1.2.0). RESOLVED enum, not fixed: the loader validates only the slug
223
233
  // shape here. Whether the value resolves against the active vocabulary
@@ -378,6 +388,9 @@ export async function loadSddManifest(sddPath, options) {
378
388
  ...(parsed.data.packaging !== undefined ? { packaging: parsed.data.packaging } : {}),
379
389
  ...(parsed.data.exemplar !== undefined ? { exemplar: parsed.data.exemplar } : {}),
380
390
  ...(parsed.data.shared_library !== undefined ? { sharedLibrary: parsed.data.shared_library } : {}),
391
+ ...(parsed.data.journeys !== undefined
392
+ ? { journeys: { master: parsed.data.journeys.master, testPaths: parsed.data.journeys.test_paths ?? [] } }
393
+ : {}),
381
394
  ...(parsed.data.application_archetype !== undefined
382
395
  ? { applicationArchetype: parsed.data.application_archetype }
383
396
  : {}),
@@ -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
- requirement_status:
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
- requirement_status_value:
904
+ requirement_assertion_kind:
905
905
  kind: enum
906
906
  scoring: not-applicable
907
907
  reason: closed vocabulary, not an artefact
@@ -933,3 +933,109 @@ coverage:
933
933
  kind: enum
934
934
  scoring: not-applicable
935
935
  reason: closed vocabulary, not an artefact
936
+ feature_status:
937
+ kind: enum
938
+ scoring: not-applicable
939
+ reason: closed vocabulary, not an artefact
940
+ journey_requirement:
941
+ kind: edge
942
+ scoring: derived-edge
943
+ class: dependency
944
+ impact_flow: toward-source
945
+ reason: derived from end-to-end test tags or journey features' living specs
946
+ feature_kind:
947
+ kind: enum
948
+ scoring: not-applicable
949
+ reason: closed vocabulary, not an artefact
950
+ journey_requirement_basis:
951
+ kind: enum
952
+ scoring: not-applicable
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
@@ -27,7 +27,7 @@
27
27
  * M-ADR 0134 §4.6 (canonical builder + aggregator).
28
28
  */
29
29
  export const SCHEMA_COMPAT = {
30
- catalogue_schema: "^4.0.0",
30
+ catalogue_schema: "^6.0.0",
31
31
  sdd_manifest: "^1.0.0",
32
32
  aggregation_manifest: "^1.0.0",
33
33
  };
@@ -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
- const reader = await session.conn.runAndReadAll(`SELECT rubric_id, target_ref, target_path, corpus_ref, scored_at, staleness_basis
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
- current += 1;
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
- else {
289
- current += 1;
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 {
@@ -10,6 +10,9 @@
10
10
  // relative path, or a blob URL whose repository is one of the SDDs);
11
11
  // mentioned a tracked file in an SDD that names the request id, unless the file names many request ids
12
12
  // (an index lists requests; it is not where one went);
13
+ // titled a tracked file whose title names the request id (an application's own Epic Brief): it is
14
+ // about that request whatever else it cites, so it counts even when it names many ids, and it
15
+ // weighs more than any other evidence in choosing the intent's home;
13
16
  // commit a commit in an SDD whose message names the id, unless its subject moves, renames, archives or
14
17
  // marks the Ideas and Briefs themselves. When it names at most a few ids (a plan
15
18
  // bundle delivers several), the files it changed that the SDD still owns are evidence too
@@ -17,7 +20,8 @@
17
20
  // plan a plan file (from --plans) that names the id. A plan says what was planned, not what shipped,
18
21
  // so an id whose only evidence is a plan is flagged for a check that the work landed. A plan
19
22
  // naming at most a few ids also contributes the paths it cites that an SDD owns now (planned).
20
- // A path that moved since it was cited or committed is followed through the SDD's git renames to where it is
23
+ // With no evidence, an intent goes to the platform among the SDDs (the first one named when none is a
24
+ // platform), and is flagged. A path that moved since it was cited or committed is followed through the SDD's git renames to where it is
21
25
  // now. A reviewer's decisions are given back as --home ID=<sdd-id> and --status ID=open|closed.
22
26
  // Planning state (status, priority, relations) goes to a tool-neutral work-item import file, never into the
23
27
  // intent (M-ADR 0227 §4.3, M-ADR 0035). A dry run writes the report, the import file, the citation table and
@@ -39,6 +43,8 @@ const NOT_OWN = /^(docs\/method\/|catalogues|\.sdd\/|openspec\/schemas\/|content
39
43
  const SWEEP = 25;
40
44
  /** A commit or plan naming more request ids than this is not evidence for any one of them. */
41
45
  const SHARED = 3;
46
+ /** How much a file titled with the id weighs in choosing a home, against one for any other evidence line. */
47
+ const TITLED_WEIGHT = 100;
42
48
  const HOUSEKEEPING = /\b(move[sd]?|moving|rename[sd]?|renaming|archive[sd]?|mark(s|ed)?|retire[sd]?|close[sd]?|complete[sd]?)\b.*\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b|\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b.*\b(moved|renamed|archived|marked|retired|closed|completed)\b/i;
43
49
  async function walk(dir, accept) {
44
50
  const out = [];
@@ -109,7 +115,14 @@ async function loadSdd(dir) {
109
115
  catch {
110
116
  // not a git repository
111
117
  }
112
- return { dir, id, repoNames, managed: await managedPaths(dir), renames };
118
+ let kind = "";
119
+ try {
120
+ kind = (await readFile(join(dir, ".sdd-repo-kind"), "utf8")).trim();
121
+ }
122
+ catch {
123
+ // no marker
124
+ }
125
+ return { dir, id, repoNames, managed: await managedPaths(dir), renames, kind };
113
126
  }
114
127
  function readSource(path, text) {
115
128
  const doc = parseMarkdown(text);
@@ -306,6 +319,7 @@ export async function migrateIntents(opts) {
306
319
  const sdds = await Promise.all(opts.sdds.map((d) => loadSdd(resolve(d))));
307
320
  if (sdds.length === 0)
308
321
  throw new Error("name at least one SDD (--sdd)");
322
+ const defaultHome = sdds.find((s) => s.kind === "platform-product") ?? sdds[0];
309
323
  // 1. Sources, grouped by request id.
310
324
  const byId = new Map();
311
325
  const unrecognised = [];
@@ -332,7 +346,7 @@ export async function migrateIntents(opts) {
332
346
  const ids = new Set(byId.keys());
333
347
  const map = opts.ideaMap ? readIdeaMap(await readFile(opts.ideaMap, "utf8")) : new Map();
334
348
  // 2. Evidence.
335
- const ev = new Map([...ids].map((i) => [i, { cited: [], mentioned: [], committed: [], planned: [], commits: [], plans: [] }]));
349
+ const ev = new Map([...ids].map((i) => [i, { cited: [], mentioned: [], titled: [], committed: [], planned: [], commits: [], plans: [] }]));
336
350
  const indexFiles = [];
337
351
  for (const [id, e] of byId) {
338
352
  const text = [e.idea, e.brief].filter(Boolean).map((f) => readFileSyncSafe(f.path)).join("\n");
@@ -357,11 +371,18 @@ export async function migrateIntents(opts) {
357
371
  const hits = [...new Set((text.match(ID_TOKEN) ?? []).filter((t) => ids.has(t)))];
358
372
  if (hits.length === 0)
359
373
  continue;
374
+ // A file titled with a request id is about that request, however many others it cites.
375
+ const title = /^#\s+(.+)$/m.exec(text)?.[1] ?? "";
376
+ const titled = (title.match(ID_TOKEN) ?? []).filter((t) => ids.has(t));
377
+ for (const id of titled)
378
+ ev.get(id).titled.push({ sdd: s.id, path: rel });
360
379
  if (hits.length > opts.indexThreshold) {
361
380
  indexFiles.push(`${s.id}: ${rel} (${hits.length} ids)`);
362
381
  continue;
363
382
  }
364
383
  for (const id of hits) {
384
+ if (titled.includes(id))
385
+ continue;
365
386
  const e = ev.get(id);
366
387
  if (!e.cited.some((c) => c.sdd === s.id && c.path === rel))
367
388
  e.mentioned.push({ sdd: s.id, path: rel });
@@ -442,15 +463,19 @@ export async function migrateIntents(opts) {
442
463
  }
443
464
  // Mentioned and committed paths already cited are listed once, as cited.
444
465
  const has = (l, x) => l.some((c) => c.sdd === x.sdd && c.path === x.path);
445
- e.mentioned = e.mentioned.filter((x) => !has(e.cited, x));
446
- e.committed = e.committed.filter((x) => !has(e.cited, x) && !has(e.mentioned, x));
447
- e.planned = e.planned.filter((x) => !has(e.cited, x) && !has(e.mentioned, x) && !has(e.committed, x));
466
+ e.titled = e.titled.filter((x) => !has(e.cited, x));
467
+ e.mentioned = e.mentioned.filter((x) => !has(e.cited, x) && !has(e.titled, x));
468
+ e.committed = e.committed.filter((x) => !has(e.cited, x) && !has(e.titled, x) && !has(e.mentioned, x));
469
+ e.planned = e.planned.filter((x) => !has(e.cited, x) && !has(e.titled, x) && !has(e.mentioned, x) && !has(e.committed, x));
448
470
  // Home: the SDD most of the evidence is in, unless a reviewer set it.
449
471
  const count = new Map();
450
472
  for (const x of [...e.cited, ...e.mentioned, ...e.committed, ...e.planned])
451
473
  count.set(x.sdd, (count.get(x.sdd) ?? 0) + 1);
474
+ // A file titled with the id outweighs the rest: an application's own Epic Brief places its request there.
475
+ for (const x of e.titled)
476
+ count.set(x.sdd, (count.get(x.sdd) ?? 0) + TITLED_WEIGHT);
452
477
  const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]);
453
- let home = sdds[0];
478
+ let home = defaultHome;
454
479
  const set = opts.homes?.get(id);
455
480
  if (set) {
456
481
  const s = sdds.find((x) => x.id === set);
@@ -466,7 +491,7 @@ export async function migrateIntents(opts) {
466
491
  if (ranked.length > 1 && ranked[0][1] === ranked[1][1])
467
492
  flag(id, `home ambiguous: ${ranked.map(([s, n]) => `${s} ${n}`).join(", ")}`);
468
493
  home = sdds.find((s) => s.id === ranked[0][0]);
469
- if (e.cited.length + e.mentioned.length + e.committed.length === 0)
494
+ if (e.cited.length + e.titled.length + e.mentioned.length + e.committed.length === 0)
470
495
  flag(id, "planned paths only: check that the work landed");
471
496
  }
472
497
  const classification = (fields("Classification") || "").toLowerCase();
@@ -475,6 +500,7 @@ export async function migrateIntents(opts) {
475
500
  const path = `${dir}/${slug(primary.path, id)}`;
476
501
  const where = [
477
502
  ...e.cited.map((c) => `- \`${c.path}\` (${c.sdd}; cited)`),
503
+ ...e.titled.map((c) => `- \`${c.path}\` (${c.sdd}; titled)`),
478
504
  ...e.mentioned.map((c) => `- \`${c.path}\` (${c.sdd}; mentioned)`),
479
505
  ...e.committed.map((c) => `- \`${c.path}\` (${c.sdd}; committed in ${c.sha}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
480
506
  ...e.planned.map((c) => `- \`${c.path}\` (${c.sdd}; planned in ${c.plan}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
@@ -593,7 +619,7 @@ export async function migrateIntents(opts) {
593
619
  `Request ids: ${ids.size} (${pairs.filter((p) => p.idea && p.brief).length} with an Idea and an Epic Brief, ${pairs.filter((p) => p.idea && !p.brief).length} with an Idea only, ${pairs.filter((p) => !p.idea && p.brief).length} with a Brief only).`,
594
620
  `Work items: ${items.filter((i) => i.status === "closed").length} closed, ${items.filter((i) => i.status === "open").length} open.`,
595
621
  `Homes: ${[...homes.entries()].map(([s, n]) => `${s} ${n}`).join(", ")}.`,
596
- `Evidence, ids supported by each source: cited ${supported("cited")}, mentioned ${supported("mentioned")}, committed ${supported("committed")}, planned ${supported("planned")}, commit ${supported("commits")}, plan ${supported("plans")}.`,
622
+ `Evidence, ids supported by each source: cited ${supported("cited")}, titled ${supported("titled")}, mentioned ${supported("mentioned")}, committed ${supported("committed")}, planned ${supported("planned")}, commit ${supported("commits")}, plan ${supported("plans")}.`,
597
623
  "",
598
624
  `## Flagged (${flags.size})`,
599
625
  "",
@@ -174,7 +174,13 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
174
174
  throw new ParseError(`${where}: plays needs <relation>:<role>`);
175
175
  if (decl.plays.has(w[1]))
176
176
  throw new ParseError(`${where}: ${lab} plays ${w[1]} twice`);
177
- decl.plays.set(w[1], parseAnnotations(w.slice(2), where));
177
+ const ann = parseAnnotations(w.slice(2), where);
178
+ // A plays clause's cardinality constrains participation, which the relational projection has no place for:
179
+ // refused, so a constraint the model gained can never be dropped by the projection with the gate green.
180
+ if (ann.key || ann.required || ann.multi || ann.optional) {
181
+ throw new ParseError(`${where}: ${lab} plays ${w[1]} with a cardinality annotation; participation cardinality cannot be projected onto the catalogue schema, so it is not admitted`);
182
+ }
183
+ decl.plays.set(w[1], ann);
178
184
  }
179
185
  else if (head === "relates") {
180
186
  if (kind !== "relation")
@@ -225,6 +231,16 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
225
231
  }
226
232
  const ROW_RE = /^insert \$m isa ([a-z][a-z0-9-]*), (.*);$/;
227
233
  const TOK_RE = /has ([a-z][a-z0-9-]*) ("(?:[^"\\]|\\.)*"|-?\d+|true|false)/y;
234
+ function valueFamily(v) {
235
+ return typeof v === "boolean" ? "boolean" : typeof v === "number" ? "integer" : "string";
236
+ }
237
+ function attrFamily(valueType) {
238
+ if (valueType === undefined)
239
+ return undefined;
240
+ if (valueType === "long" || valueType === "integer")
241
+ return "integer";
242
+ return valueType;
243
+ }
228
244
  export function parseMeta(text, fileLabel = "catalogue-schema-meta.tql", options = {}) {
229
245
  const metaTypes = options.metaTypes ?? META_TYPES;
230
246
  const rows = new Map();
@@ -268,9 +284,26 @@ export function parseMeta(text, fileLabel = "catalogue-schema-meta.tql", options
268
284
  else
269
285
  throw new ParseError(`${where}: expected ', has ...' at offset ${i}: ${JSON.stringify(body.slice(i, i + 40))}`);
270
286
  }
287
+ const decl = options.types?.entities.get(typ);
288
+ if (options.types && !decl)
289
+ throw new ParseError(`${where}: meta type ${typ} is not declared in the type file`);
290
+ if (decl) {
291
+ for (const [k, vs] of acc) {
292
+ const own = decl.owns.get(k);
293
+ if (!own)
294
+ throw new ParseError(`${where}: ${typ} does not own ${k}; an undeclared meta property is refused, not ignored`);
295
+ if (!own.multi && vs.length > 1)
296
+ throw new ParseError(`${where}: ${typ}.${k} is a single value and appears ${vs.length} times; a repeated scalar is refused, not reduced to its first value`);
297
+ const fam = attrFamily(options.types.attrs.get(k));
298
+ for (const v of vs) {
299
+ if (fam !== undefined && valueFamily(v) !== fam)
300
+ throw new ParseError(`${where}: ${typ}.${k} holds ${JSON.stringify(v)}, but ${k} is of value type ${options.types.attrs.get(k)}`);
301
+ }
302
+ }
303
+ }
271
304
  const row = {};
272
305
  for (const [k, vs] of acc)
273
- row[k] = LIST_KEYS.has(k) ? vs : vs[0];
306
+ row[k] = (decl ? decl.owns.get(k).multi : LIST_KEYS.has(k)) ? vs : vs[0];
274
307
  const list = rows.get(typ) ?? [];
275
308
  list.push(row);
276
309
  rows.set(typ, list);