@sdd-method/author-cli 0.14.1 → 0.15.1

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.
@@ -90,7 +90,7 @@ export async function runCatalogueBuild(inputs, write) {
90
90
  const intendedRows = [];
91
91
  const rowsByCatalogue = modelFiles === null
92
92
  ? collected
93
- : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows);
93
+ : withC4Rollups(overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows));
94
94
  for (const p of modelProblems)
95
95
  findings.add("convention", `${p} (M-ADR 0230)`);
96
96
  // 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
@@ -1517,6 +1517,18 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1517
1517
  });
1518
1518
  return out;
1519
1519
  }
1520
+ /**
1521
+ * The C4 roll-ups joined again over the catalogues the structural model settles (M-ADR 0159 §4.8, M-ADR 0230 §4.3).
1522
+ * collectRows joins them over the sources, which can state an element an agreed open change adds; the overlay holds
1523
+ * that element back until the change archives, and a roll-up row reaching it would be a current row referencing it.
1524
+ */
1525
+ function withC4Rollups(rows) {
1526
+ const c4Containers = rows.get("c4_containers") ?? [];
1527
+ const containerEntityRows = buildC4ContainerEntity({ serviceEntityRows: rows.get("service_entity") ?? [], c4Containers });
1528
+ rows.set("c4_container_entity", containerEntityRows);
1529
+ rows.set("c4_system_entity", buildC4SystemEntity({ containerEntityRows, c4Containers }));
1530
+ return rows;
1531
+ }
1520
1532
  const CONTEXT_CROSS_CUTTING = "cross-cutting";
1521
1533
  /**
1522
1534
  * Convention check (schema 2.21.0): where a screen carries `context` and the
@@ -13,10 +13,17 @@
13
13
  // a file matching no contract, or several, is a finding and produces no row. `relies_on` and `agrees` are a digest
14
14
  // beside `file:`, or a map from each file to its digest beside `files:`. A `relies_on` that no longer matches the
15
15
  // file's content is reported: the change relies on a revision that has moved, and is re-agreed.
16
+ //
17
+ // A contract declared outside the SDD has no file here. Its current content is what .sdd/contract-availability.yaml
18
+ // attests (M-ADR 0202): the entry whose path is the contract's spec_path as the declaration writes it, so a fresh clone
19
+ // with no sibling checkout builds the same rows. A contract neither here nor attested has no current revision, and an
20
+ // archived change's revision of it is not classified: retired is a comparison with the current content, never the
21
+ // absence of one.
16
22
  import { createHash } from "node:crypto";
17
23
  import { readdir, readFile } from "node:fs/promises";
18
24
  import { join } from "node:path";
19
25
  import { parse as parseYaml } from "yaml";
26
+ import { readAvailability } from "../../contracts-availability/index.js";
20
27
  const AGREEMENTS = join("docs", "agreements");
21
28
  const DIGEST = /^sha256:[0-9a-f]{64}$/;
22
29
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -71,21 +78,29 @@ export async function buildContractRevisions(inputs) {
71
78
  }
72
79
  /**
73
80
  * The revision rows, and every contract an archived change deprecated. The second is kept apart because a contract
74
- * declared outside this SDD has no file here and so no revision row, yet its version is still deprecated.
81
+ * declared outside this SDD and not attested has no revision row, yet its version is still deprecated.
75
82
  */
76
83
  export async function contractRevisionsAndDeprecations(inputs) {
77
84
  const { sddPath, findings } = inputs;
78
85
  const rows = new Map();
79
86
  const current = new Map(); // contract id -> current digest
87
+ const attested = new Map(); // spec_path -> each digest attested for it
88
+ for (const e of await readAvailability(sddPath)) {
89
+ if (!/^[0-9a-f]{64}$/.test(e.sha256))
90
+ continue;
91
+ attested.set(e.path, (attested.get(e.path) ?? new Set()).add(`sha256:${e.sha256}`));
92
+ }
80
93
  for (const c of inputs.contracts) {
81
94
  const path = String(c["spec_path"] ?? "");
82
95
  if (path === "")
83
96
  continue;
84
97
  const bytes = await readFile(join(sddPath, path)).catch(() => null);
85
- if (bytes === null)
98
+ const claims = attested.get(path);
99
+ // Two entries attesting different content for one path say nothing about which is current.
100
+ const digest = bytes !== null ? contentDigest(bytes) : claims?.size === 1 ? [...claims][0] : undefined;
101
+ if (digest === undefined)
86
102
  continue;
87
103
  const id = String(c["id"]);
88
- const digest = contentDigest(bytes);
89
104
  current.set(id, digest);
90
105
  const rid = revisionId(id, digest);
91
106
  rows.set(rid, { id: rid, contract_id: id, digest, state: c["status"] === "deprecated" ? "deprecated" : "active", sunset_date: "", change_id: "", source_sdd_id: "", platform_sdd_id: "" });
@@ -145,14 +160,14 @@ export async function contractRevisionsAndDeprecations(inputs) {
145
160
  }
146
161
  if (state === "agreed")
147
162
  rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "agreed", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
148
- else if (!existing)
163
+ else if (!existing && now)
149
164
  rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "retired", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
150
165
  }
151
166
  }
152
167
  const deprecated = new Map();
153
168
  for (const [contract, d] of deprecations) {
154
169
  deprecated.set(contract, { sunset: d.sunset, changeId: d.changeId });
155
- // A contract whose file is outside this SDD has no revision row to mark.
170
+ // A contract outside this SDD with no attestation has no revision row to mark.
156
171
  const now = current.get(contract);
157
172
  const rid = now ? revisionId(contract, now) : undefined;
158
173
  const row = rid ? rows.get(rid) : undefined;
@@ -10,8 +10,9 @@
10
10
  // `..` (non-identifying) is read as `--`, and reported
11
11
  // TypeQL, in the documented subset (M-ADR 0210 §4.2, `data-model.tql`), through the fail-closed parseTypes:
12
12
  // an entity's owned attributes (@key, @card), and each binary relation as a relationship between its two roles'
13
- // players, roles kept. The subset admits no participation cardinality, so both ends are zero-or-more, and a role's
14
- // own @card is a cardinality invariant on the relationship.
13
+ // players, roles kept. An end's cardinality is the other player's participation: B plays R:b @card(1..1) puts
14
+ // exactly-one at A's end (each B has exactly one A), and an unannotated plays is zero-or-more (TypeDB's default).
15
+ // A role's own @card is a cardinality invariant on the relationship.
15
16
  // The keys become invariants: a primary key (PK, @key) is one unique invariant over its attributes, which are not
16
17
  // nullable; a UK is a unique invariant over its attribute. What a parser reads and cannot hold is an `extraction`
17
18
  // finding, never a silent skip.
@@ -194,9 +195,13 @@ export function parseTypeqlDataModel(text, where) {
194
195
  return false;
195
196
  });
196
197
  const cards = [...d.relates].flatMap(([role, ann]) => ann.required ? [`each ${label} relates exactly one ${role}`] : ann.optional ? [`each ${label} relates at most one ${role}`] : []);
198
+ const participation = (player, role) => {
199
+ const ann = t.entities.get(player)?.plays.get(`${label}:${role}`);
200
+ return ann?.required ? "exactly-one" : ann?.optional ? "zero-or-one" : "zero-or-more";
201
+ };
197
202
  for (const from of playersOf(a)) {
198
203
  for (const to of playersOf(b)) {
199
- relationships.push({ from, to, name: label, fromCardinality: "zero-or-more", toCardinality: "zero-or-more", fromRole: a, toRole: b, roleCards: cards });
204
+ relationships.push({ from, to, name: label, fromCardinality: participation(to, b), toCardinality: participation(from, a), fromRole: a, toRole: b, roleCards: cards });
200
205
  }
201
206
  }
202
207
  }
@@ -721,7 +721,7 @@ enums:
721
721
  - value: zero-or-one
722
722
  - value: one-or-more
723
723
  - value: zero-or-more
724
- description: "How many entities one end of a relationship holds for one entity at the other end (M-ADR 0230 §4.3): a Mermaid ER end marker (|| exactly one, |o or o| zero or one, }| or |{ one or more, }o or o{ zero or more). A TypeQL relationship is zero-or-more at both ends, because the subset admits no participation cardinality; a role's @card is a cardinality invariant (domain_invariants). Replaced entity_relationship_cardinality in schema 6.0.0."
724
+ description: "How many entities one end of a relationship holds for one entity at the other end (M-ADR 0230 §4.3): a Mermaid ER end marker (|| exactly one, |o or o| zero or one, }| or |{ one or more, }o or o{ zero or more). A TypeQL relationship's end is the other player's participation: B plays R:b @card(1..1) makes A's end exactly-one, @card(0..1) zero-or-one, and an unannotated plays zero-or-more; a role's @card is a cardinality invariant (domain_invariants). Replaced entity_relationship_cardinality in schema 6.0.0."
725
725
  domain_invariant_kind:
726
726
  values:
727
727
  - value: unique
@@ -203,6 +203,10 @@ export function inferKind(ref) {
203
203
  return "cli";
204
204
  return undefined;
205
205
  }
206
+ /** The SDD's attested external artefacts (`.sdd/contract-availability.yaml`); none when it has no manifest. */
207
+ export async function readAvailability(repo) {
208
+ return readManifest(join(repo, AVAILABILITY_MANIFEST_REL_PATH));
209
+ }
206
210
  async function readManifest(abs) {
207
211
  if (!existsSync(abs))
208
212
  return [];
@@ -175,11 +175,10 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
175
175
  if (decl.plays.has(w[1]))
176
176
  throw new ParseError(`${where}: ${lab} plays ${w[1]} twice`);
177
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
- }
178
+ // A plays clause's cardinality constrains participation, and is kept: a reader that can hold it does, and a
179
+ // projection that cannot refuses it there (schema-projection), never here, so no reader drops it silently.
180
+ if (ann.key)
181
+ throw new ParseError(`${where}: ${lab} plays ${w[1]} @key; a role is not a key`);
183
182
  decl.plays.set(w[1], ann);
184
183
  }
185
184
  else if (head === "relates") {
@@ -86,6 +86,8 @@ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
86
86
  // OpenSpec counts a task done when its box holds only `x`, either case, any spacing.
87
87
  const TASK_DONE_RE = /^\s*-\s*\[\s*[xX]\s*\]/m;
88
88
  const TARGET_HEADING_RE = /^##\s+(MODIFIED|REMOVED|RENAMED)\s+Requirements\b/m;
89
+ // A tasks.md group headed by one word, `## <n>. <word>`, is that repository's work order (taskGroups).
90
+ const GROUP_REPO = (repo) => `tasks.md has a work order for ${repo} and the record's work_orders do not; its evidence cannot be checked (a group heading of one word, \`## <n>. ${repo}\`, names the group's repository)`;
89
91
  async function exists(p) {
90
92
  try {
91
93
  await access(p);
@@ -516,6 +518,14 @@ export async function checkBehaviourStore(repo, options = {}) {
516
518
  }
517
519
  }
518
520
  }
521
+ // The archive check reads a task group's repository from its heading; once the record declares work orders, a
522
+ // heading it would read as a repository the record lacks fails here, while the change can still be amended.
523
+ if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed" && record.workOrders.length > 0 && tasks !== null) {
524
+ const recorded = new Set(record.workOrders.map((w) => w.repo));
525
+ for (const g of taskGroups(tasks))
526
+ if (!recorded.has(g.repo))
527
+ failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
528
+ }
519
529
  // A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
520
530
  if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
521
531
  const plan = await readText(join(repo, dir, "test-plan.md"));
@@ -717,7 +727,7 @@ export async function checkBehaviourStore(repo, options = {}) {
717
727
  const recorded = new Set(record.workOrders.map((w) => w.repo));
718
728
  for (const g of groups)
719
729
  if (!recorded.has(g.repo))
720
- failures.push(`${record.file}: tasks.md has a work order for ${g.repo} and the record's work_orders do not; its evidence cannot be checked`);
730
+ failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
721
731
  // Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
722
732
  // or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
723
733
  const built = [];
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.1",
3
- "built_against_sdd_cli": "5.1.1"
2
+ "version": "0.16.1",
3
+ "built_against_sdd_cli": "5.2.1"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "5.1.1",
3
- "built_against_sdd_cli": "5.1.1"
2
+ "version": "5.2.1",
3
+ "built_against_sdd_cli": "5.2.1"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/author-cli",
3
- "version": "0.14.1",
4
- "sddCliVersion": "5.1.1",
3
+ "version": "0.15.1",
4
+ "sddCliVersion": "5.2.1",
5
5
  "description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",