@sdd-method/author-cli 0.4.0 → 0.6.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.
@@ -22,6 +22,7 @@ import { registerConformance } from "./verbs/conformance.js";
22
22
  import { registerContext } from "./verbs/context.js";
23
23
  import { registerContracts } from "./verbs/contracts.js";
24
24
  import { registerGenerateBriefing } from "./verbs/generate-briefing.js";
25
+ import { registerMigrate } from "./verbs/migrate.js";
25
26
  import { registerPlan } from "./verbs/plan.js";
26
27
  import { registerRender } from "./verbs/render.js";
27
28
  import { registerScaffold } from "./verbs/scaffold.js";
@@ -44,6 +45,7 @@ export const AUTHOR_ONLY_SURFACE = [
44
45
  "generate-briefing",
45
46
  "context pull",
46
47
  "catalogue check",
48
+ "migrate vocabulary",
47
49
  ];
48
50
  /** The full surface: verify's, plus the author's own. */
49
51
  export const AUTHOR_SURFACE = [...VERIFY_SURFACE, ...AUTHOR_ONLY_SURFACE];
@@ -83,6 +85,7 @@ export function buildAuthorProgram() {
83
85
  // The verify surface first, from verify's own registration: verify ⊂ author is structural.
84
86
  registerVerifySurface(program);
85
87
  registerScaffold(program);
88
+ registerMigrate(program);
86
89
  registerPlan(program);
87
90
  registerContracts(program);
88
91
  registerConformance(program);
@@ -0,0 +1,180 @@
1
+ // What the catalogue and the behaviour-store checks both read from a store's archive: the order its changes
2
+ // were archived in, and the requirements each delta names. Pure reads, no catalogue or validator types, so the
3
+ // verify-only CLI can carry it.
4
+ //
5
+ // Archive order is the archive date, then the order in which the SDD's history first added each archive
6
+ // directory, then the name. The date prefix alone ties for changes archived the same day, and their names say
7
+ // nothing about which came first; an untracked archive is newer than every tracked one. Same-day archives the
8
+ // history cannot order are returned as ties, for the caller to report.
9
+ import { execFile as execFileCb } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ const execFile = promisify(execFileCb);
12
+ /** Requirement names and scenario counts under each `## <OP> Requirements` section (or the whole file for a living spec). */
13
+ export function parseRequirements(text) {
14
+ const out = [];
15
+ let op = null;
16
+ let current = null;
17
+ for (const line of text.split("\n")) {
18
+ const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
19
+ if (section) {
20
+ op = section[1];
21
+ current = null;
22
+ continue;
23
+ }
24
+ if (/^##\s/.test(line) && !/^###/.test(line)) {
25
+ current = null;
26
+ continue;
27
+ }
28
+ // RENAMED: "- FROM: `### Requirement: Old name`" then "- TO: `### Requirement: New name`"
29
+ const renamedFrom = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
30
+ if (op === "RENAMED" && renamedFrom) {
31
+ current = null;
32
+ continue;
33
+ }
34
+ const renamedTo = /^\s*-\s*TO:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
35
+ if (op === "RENAMED" && renamedTo) {
36
+ current = { name: renamedTo[1].trim(), operation: op, scenarios: 0 };
37
+ out.push(current);
38
+ continue;
39
+ }
40
+ const req = /^###\s+Requirement:\s*(.+?)\s*$/.exec(line);
41
+ if (req) {
42
+ current = { name: req[1], operation: op, scenarios: 0 };
43
+ out.push(current);
44
+ continue;
45
+ }
46
+ if (current && /^####\s+Scenario:/.test(line))
47
+ current.scenarios++;
48
+ }
49
+ return out;
50
+ }
51
+ /** The names a RENAMED section renames FROM (the requirement a rename removes). */
52
+ export function renamedFrom(text) {
53
+ const out = [];
54
+ let op = null;
55
+ for (const line of text.split("\n")) {
56
+ const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
57
+ if (section) {
58
+ op = section[1];
59
+ continue;
60
+ }
61
+ const m = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
62
+ if (op === "RENAMED" && m)
63
+ out.push(m[1].trim());
64
+ }
65
+ return out;
66
+ }
67
+ /** Each archive directory's position in the SDD's history: the index of the first commit that added a file under it. */
68
+ async function archiveHistory(sddPath, archiveRel) {
69
+ let stdout;
70
+ try {
71
+ ({ stdout } = await execFile("git", ["-C", sddPath, "log", "--relative", "--reverse", "--diff-filter=A", "--format=@%H", "--name-only", "--", archiveRel], { maxBuffer: 64 * 1024 * 1024 }));
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const order = new Map();
77
+ let commit = -1;
78
+ for (const line of stdout.split("\n")) {
79
+ if (line.startsWith("@")) {
80
+ commit++;
81
+ continue;
82
+ }
83
+ if (!line.startsWith(`${archiveRel}/`))
84
+ continue;
85
+ const dir = line.slice(archiveRel.length + 1).split("/")[0];
86
+ if (!order.has(dir))
87
+ order.set(dir, commit);
88
+ }
89
+ return order;
90
+ }
91
+ /** Archive directories in archive order, and the adjacent same-day pairs the history could not order. */
92
+ export async function orderArchives(sddPath, archiveRel, names) {
93
+ const history = await archiveHistory(sddPath, archiveRel);
94
+ const date = (d) => /^(\d{4}-\d{2}-\d{2})-/.exec(d)?.[1] ?? "";
95
+ const position = (d) => history?.get(d) ?? Number.POSITIVE_INFINITY;
96
+ const order = [...names].sort((a, b) => date(a).localeCompare(date(b)) || position(a) - position(b) || a.localeCompare(b));
97
+ const ties = [];
98
+ for (let i = 1; i < order.length; i++) {
99
+ const [a, b] = [order[i - 1], order[i]];
100
+ if (date(a) === date(b) && position(a) === position(b))
101
+ ties.push([a, b]);
102
+ }
103
+ return { order, ties };
104
+ }
105
+ /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
106
+ export function observedIn(evidence) {
107
+ const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
108
+ if (!m)
109
+ return [];
110
+ return m[1]
111
+ .split(",")
112
+ .map((s) => s.trim().split(/\s*@\s*/)[0].replace(/`/g, "").trim())
113
+ .filter((s) => s !== "");
114
+ }
115
+ /**
116
+ * A baseline's `**Surfaces:**` list (M-ADR 0222 §4.3, §4.4; agreement-record-schema.yaml, Baselines): one entry per
117
+ * contract the observed repositories serve, either
118
+ * - <contract>: covered at transport[; <note>]
119
+ * - <contract>: excluded, <reason>[; <note>]
120
+ * A note carries detail inside the contract, such as an operation left out and why. The last standalone
121
+ * `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
122
+ * Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
123
+ * not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
124
+ */
125
+ export function parseSurfaces(evidence) {
126
+ const lines = evidence.split("\n");
127
+ // The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
128
+ // the list is what the file ends up asserting.
129
+ let at = -1;
130
+ lines.forEach((l, i) => {
131
+ if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
132
+ at = i;
133
+ });
134
+ if (at < 0)
135
+ return { present: false, entries: [], problems: [] };
136
+ const entries = [];
137
+ const problems = [];
138
+ for (const line of lines.slice(at + 1)) {
139
+ if (line.trim() === "" && entries.length + problems.length === 0)
140
+ continue;
141
+ const item = /^\s*-\s+(.+?)\s*$/.exec(line);
142
+ if (!item)
143
+ break;
144
+ const m = /^`?([^`]+?)`?\s*:\s*(.+)$/.exec(item[1]);
145
+ if (!m) {
146
+ problems.push(`"${item[1]}" is not "<contract>: covered at transport" or "<contract>: excluded, <reason>"`);
147
+ continue;
148
+ }
149
+ const contract = m[1].trim();
150
+ const status = m[2].split(";")[0].trim();
151
+ const excluded = /^excluded\s*,\s*(.+)$/i.exec(status);
152
+ if (/^covered at transport$/i.test(status)) {
153
+ if (contract.includes("*"))
154
+ problems.push(`"${contract}": a wildcard may only exclude; list each covered contract`);
155
+ else
156
+ entries.push({ contract, covered: true, reason: "" });
157
+ }
158
+ else if (excluded) {
159
+ entries.push({ contract, covered: false, reason: excluded[1].trim() });
160
+ }
161
+ else if (/^covered\b/i.test(status)) {
162
+ problems.push(`"${contract}: ${status}": covered needs evidence at the transport; evidence below it (a handler called directly, a hand-built request) is an exclusion: "excluded, handler-only evidence"`);
163
+ }
164
+ else {
165
+ problems.push(`"${contract}: ${status}" is neither "covered at transport" nor "excluded, <reason>"`);
166
+ }
167
+ }
168
+ if (entries.length + problems.length === 0)
169
+ problems.push("the **Surfaces:** line lists no contract");
170
+ return { present: true, entries, problems };
171
+ }
172
+ /** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
173
+ export function surfaceMatches(entry, contractId, specPath) {
174
+ const e = entry.replace(/^\.\//, "");
175
+ if (e === contractId)
176
+ return true;
177
+ const re = new RegExp(`(^|/)${e.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
178
+ return re.test(specPath.split("\\").join("/"));
179
+ }
180
+ //# sourceMappingURL=archive.js.map
@@ -34,6 +34,7 @@ import { readCatalogueCsv } from "./csv-reader.js";
34
34
  import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./builders/data-items.js";
35
35
  import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
36
36
  import { buildRepoDependencies } from "./builders/repo-dependency.js";
37
+ import { buildRequirements } from "./builders/requirements.js";
37
38
  import { buildDomains, buildProducts, buildApplicationDataPlaneBindings, buildApplicationDataPlaneDeviations, buildApplicationConformanceCoverage, buildImplementations, buildRepositories, buildServices, buildCapabilities, buildCapabilityService, buildCapabilityAdr, buildCapabilityFeature, buildCapabilityContract, buildAdrs, buildAdrSupersedes, buildFeatures, buildFeatureGroups, buildFeatureGrounding, buildCapabilitySpec, buildContracts, buildDeclaredExternalContracts, buildRepoEdges, deriveFeatureRepoFromServices, buildRepoCapabilityExemption, buildServiceCapabilityExemption, buildEvents, buildServiceEvent, buildCapabilityEvent, buildFeatureEvent, buildServiceContract, buildServiceContractConsumes, buildFeatureContract, buildDomainModels, buildCapabilityDomainModel, buildFeatureDomainModel, buildFeatureSubscriptionChannels, buildProductPlatformDependencies, buildGlossaryItems, backfillGlossaryRelations, buildGlossaryXref, buildC4Systems, buildC4Containers, buildC4Components, buildC4ExternalSystems, buildExternalSystemExemption, buildContainerDependency, reportEntryIdReferentCollisions, buildSystemExternalDependency, buildPersonaC4System, buildPersonaContainer, buildPersonas, buildQualityScores, buildScaffolds, buildScreens, buildUserJourneys, buildPersonaJourney, buildPersonaFeature, buildJourneyFeature, buildProductEdges, buildIntegrations, entitySlug, buildEntitiesFromManifest, buildEntitiesFromMermaid, buildServiceEntityFromTables, enrichEntityPersistenceKind, buildC4ContainerEntity, buildC4SystemEntity, } from "./builders/index.js";
38
39
  import { buildIntegrationComponents } from "./builders/integration-components.js";
39
40
  import { loadReverseProductQuality } from "./reverse-product-quality.js";
@@ -825,7 +826,11 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
825
826
  // name/domain/publisher/transport; avsc augments schema_path/version.
826
827
  // service_event edges populate from both repo-config events.produces/consumes
827
828
  // (forward) and event-catalog.yaml publisher + consumers[] (reverse).
828
- const avroContracts = contractsResult.rows.filter((c) => c["kind"] === "event");
829
+ // Discovered AND declared-external event contracts: an event schema in a sibling contracts repository
830
+ // (M-ADR 0110) is as much an event as one in the SDD. Filtering the discovered rows alone left every
831
+ // declared-external schema without an event row, so produces/consumes edges that resolved to one were
832
+ // dropped as unknown events.
833
+ const avroContracts = [...contractsResult.rows, ...declaredExternal.rows].filter((c) => c["kind"] === "event");
829
834
  const eventsResult = await buildEvents({
830
835
  sddPath,
831
836
  findings,
@@ -1151,6 +1156,18 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1151
1156
  // M-ADR 0217: build-time and generation-time dependencies between repositories,
1152
1157
  // declared (repo-config depends_on) and derived (per-component sharedLibraries).
1153
1158
  out.set("repo_dependency", await buildRepoDependencies({ sddPath, configs: reposResult.configs, knownRepoIds: knownRepoIdsFinal, findings }));
1159
+ // M-ADR 0221 §4.5-4.7: the behaviour store projected into requirements, their lineage and their status.
1160
+ {
1161
+ const r = await buildRequirements({
1162
+ sddPath,
1163
+ findings,
1164
+ knownFeatureIds: new Set((out.get("features") ?? []).map((row) => String(row["id"]))),
1165
+ knownRepoIds: knownRepoIdsFinal,
1166
+ });
1167
+ out.set("requirements", r.requirements);
1168
+ out.set("requirement_lineage", r.lineage);
1169
+ out.set("requirement_status", r.status);
1170
+ }
1154
1171
  // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1155
1172
  // is the forward-method authoring path reading
1156
1173
  // `c4-l3-*.mermaid.md` files alongside the L2 layout. The earlier Path-B
@@ -13,6 +13,7 @@
13
13
  import { readFile, stat } from "node:fs/promises";
14
14
  import { join, relative, sep } from "node:path";
15
15
  import { readdir } from "node:fs/promises";
16
+ import { rename } from "../../vocabulary/renames.js";
16
17
  const DOMAINS_DIR_REL = join("docs", "domains");
17
18
  /**
18
19
  * The vocabulary `catalogue-schema.yaml` documents for `capabilities.status`.
@@ -48,7 +49,9 @@ const DOCUMENTED_CAPABILITY_STATUS = new Set([
48
49
  const SPEC_AUTHORING_STAGES = new Set([
49
50
  "placeholder",
50
51
  "first-draft",
51
- "detailed-pre-lock",
52
+ rename("authoring-status-detailed").new,
53
+ // The stage's former name (M-ADR 0222 §4.1) is the same stage on the same wrong axis.
54
+ rename("authoring-status-detailed").old,
52
55
  "reverse-derived",
53
56
  ]);
54
57
  /**
@@ -137,12 +137,19 @@ export async function buildEvents(inputs) {
137
137
  function normaliseSchemaPath(p) {
138
138
  return p.trim().replace(/\\/g, "/").replace(/^\.\//, "");
139
139
  }
140
- function inferDomainFromAvroPath(relPath) {
141
- // Layout: contracts/avro/<domain>/<file>.avsc
142
- const parts = relPath.split(sep);
143
- if (parts.length < 4)
140
+ export function inferDomainFromAvroPath(relPath) {
141
+ // Layouts: contracts/avro/<domain>/…/<file>.avsc (in the SDD); <x>-avro/<domain>/…/<file>.avsc (a sibling
142
+ // contracts repository, M-ADR 0110, as declared-external rows give it); docs/domains/<domain>/…
143
+ // The domain is the segment after the avro root, never a fixed position: the sibling layout is one level
144
+ // shallower, and a fixed index read its `events/` folder as the domain.
145
+ const parts = relPath.split("\\").join("/").split("/");
146
+ const d = parts.indexOf("domains");
147
+ if (d >= 0 && parts[d - 1] === "docs" && parts.length > d + 2)
148
+ return parts[d + 1] ?? "";
149
+ const root = parts.findIndex((p) => p === "avro" || p.endsWith("-avro"));
150
+ if (root < 0 || parts.length < root + 3)
144
151
  return "";
145
- return parts[2] ?? "";
152
+ return parts[root + 1] ?? "";
146
153
  }
147
154
  function resolveContractByPath(schema, kind, index) {
148
155
  const cleaned = schema.replace(/^\/+/, "");
@@ -15,6 +15,7 @@ import { readFile } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
16
  import { parse as parseYaml } from "yaml";
17
17
  import { z } from "zod";
18
+ import { canonicalValue } from "../../vocabulary/renames.js";
18
19
  const REL_PATH = "app-platform-dependencies.yaml";
19
20
  const DependencyEntrySchema = z.object({
20
21
  capability_id: z.string().optional(),
@@ -70,7 +71,8 @@ export async function buildProductEdges(inputs) {
70
71
  continue;
71
72
  const capabilityId = (dep.capability_id ?? "").trim();
72
73
  const serviceId = (dep.service_id ?? "").trim();
73
- const requiredStability = (dep.required_stability ?? "").trim();
74
+ // Normalised silently here: product-platform-dependencies reads the same file and reports the former name.
75
+ const requiredStability = canonicalValue("stability-stable", (dep.required_stability ?? "").trim());
74
76
  const notes = (dep.notes ?? "").trim();
75
77
  if (capabilityId !== "") {
76
78
  const id = `${inputs.productId}__${capabilityId}`;
@@ -21,7 +21,7 @@
21
21
  // service_id: <target-service-slug> # platform-side service
22
22
  // domain: <domain-hint> # optional (reverse shape)
23
23
  // contract_type: rest | graphql | openapi | grpc | event | other
24
- // required_stability: locked | provisional | unknown
24
+ // required_stability: stable | provisional | unknown
25
25
  // contract_path: <relative-path-in-platform-sdd> # optional (reverse)
26
26
  // consumed_by: # optional (reverse)
27
27
  // - <service-name-within-this-product>
@@ -57,6 +57,7 @@
57
57
  import { readFile, readdir, stat } from "node:fs/promises";
58
58
  import { join, relative, sep } from "node:path";
59
59
  import { parse as parseYaml } from "yaml";
60
+ import { canonicalValue, isFormerValue } from "../../vocabulary/renames.js";
60
61
  const FORWARD_REL_PATH = "app-platform-dependencies.yaml";
61
62
  /**
62
63
  * Contract types that denote an ASYNC shared-stream dependency (M-ADR 0192).
@@ -260,6 +261,20 @@ export async function buildProductPlatformDependencies(inputs) {
260
261
  });
261
262
  }
262
263
  }
264
+ // The stability class `locked` is `stable` from catalogue schema 4.0.0 (M-ADR 0222 §4.1). A source
265
+ // still carrying the former name is read as the new one for one major release, so no cell carries
266
+ // the former word, and reported once per SDD so the adopter can run the migration.
267
+ let former = 0;
268
+ for (let i = 0; i < rows.length; i++) {
269
+ const v = String(rows[i]["required_stability"] ?? "");
270
+ if (isFormerValue("stability-stable", v)) {
271
+ rows[i] = { ...rows[i], required_stability: canonicalValue("stability-stable", v) };
272
+ former++;
273
+ }
274
+ }
275
+ if (former > 0) {
276
+ inputs.findings.add("convention", `product_platform_dependencies: ${former} dependenc${former === 1 ? "y declares" : "ies declare"} the former required_stability 'locked', read as 'stable' (M-ADR 0222 §4.1); run 'sdd-cli migrate vocabulary'`);
277
+ }
263
278
  return rows;
264
279
  }
265
280
  /**
@@ -23,6 +23,7 @@
23
23
  // openapi: [<path-relative-to-contracts/openapi/>]
24
24
  // proto: [<path-relative-to-contracts/proto/>]
25
25
  // avro: [<path-relative-to-contracts/avro/>]
26
+ // spicedb: [<path-relative-to-contracts/spicedb/>]
26
27
  //
27
28
  // Reverse-method derived sources (universal — work for any SDD with the
28
29
  // underlying catalogue rows populated):
@@ -49,7 +50,7 @@
49
50
  // feature-set` path is documented in the metamodel but unused by any
50
51
  // observed adopter — left empty here pending first-adopter authoring.
51
52
  import { basename, sep } from "node:path";
52
- import { normaliseAdditionalContextEntry, } from "./repositories.js";
53
+ import { normaliseAdditionalContextEntry, REPO_CONFIG_CONTRACT_KINDS, } from "./repositories.js";
53
54
  /**
54
55
  * feature_repo derivation — `feature_service ⋈ services.primary_repo_id`.
55
56
  *
@@ -263,7 +264,7 @@ function collectRepoContract(inputs) {
263
264
  seen.add(id);
264
265
  rows.push({ id, repo_id: repoId, contract_id: contractId });
265
266
  };
266
- // Forward path — declared in repo-config contracts.{openapi,proto,avro}[].
267
+ // Forward path — declared in repo-config contracts.{openapi,proto,avro,spicedb}[].
267
268
  // Pre-compute suffix and basename indexes for fuzzy matching.
268
269
  const suffixIndex = new Map();
269
270
  const basenameIndex = new Map();
@@ -278,7 +279,7 @@ function collectRepoContract(inputs) {
278
279
  const block = cfg.data.contracts;
279
280
  if (!block)
280
281
  continue;
281
- for (const kind of ["openapi", "proto", "avro"]) {
282
+ for (const kind of REPO_CONFIG_CONTRACT_KINDS) {
282
283
  const list = block[kind] ?? [];
283
284
  for (const rawEntry of list) {
284
285
  const entry = rawEntry.replace(/^\/+/, "");
@@ -32,11 +32,17 @@ const LOC_REPORT_REL = join("docs", "platform", "reference", "loc-report.csv");
32
32
  // mirrored source, where the manifest profile remains the honest answer.
33
33
  const REPOS_DIR_REL = "repos";
34
34
  const REVERSE_METADATA_BASENAME = "reverse-metadata.yaml";
35
+ // The contract kinds a repo-config `contracts:` block keys, under `contracts/<kind>/`. The sdd-index reader
36
+ // accepts the same four; `spicedb` (an `authz` contract) was once missing here, and zod strips an unknown key,
37
+ // so a declared `.zed` schema vanished without a finding. GraphQL is not keyed in repo-config: that is its own
38
+ // question, on this schema.
39
+ export const REPO_CONFIG_CONTRACT_KINDS = ["openapi", "proto", "avro", "spicedb"];
35
40
  const ContractsBlockSchema = z
36
41
  .object({
37
42
  openapi: z.array(z.string()).optional(),
38
43
  proto: z.array(z.string()).optional(),
39
44
  avro: z.array(z.string()).optional(),
45
+ spicedb: z.array(z.string()).optional(),
40
46
  })
41
47
  .optional();
42
48
  const EventEntrySchema = z.object({
@@ -0,0 +1,208 @@
1
+ // Behaviour store -> requirements, requirement_lineage, requirement_status (M-ADR 0221 §4.5-4.7, M-ADR 0223 §4.7).
2
+ //
3
+ // The store is an OpenSpec project inside the SDD (`openspec/`). Everything here is a projection: the living specs
4
+ // and changes are the record, and the catalogue reads them at the SDD's own commit. A sibling-repository store is
5
+ // read in that repository, not here.
6
+ //
7
+ // requirements one row per `### Requirement:` in a living spec (stage living), keyed by the spec's
8
+ // directory name, which is the catalogue feature id; plus one per requirement an open,
9
+ // agreed change ADDS (stage agreed).
10
+ // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or RENAMED a
11
+ // requirement that is current now. A change carrying baseline-evidence.md is a baseline.
12
+ // requirement_status per (requirement, repository): observed from a baseline's `**Observed in:**` line;
13
+ // declared-shipped from an archived change whose agreement record's verdict is PASS, through
14
+ // its work orders' `implements` lists; agreed from an open change whose record is agreed.
15
+ // The latest event wins, archived changes in archive order, then open changes.
16
+ //
17
+ // Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
18
+ //
19
+ // A feature or repository the catalogue does not hold is a finding and produces no row, so no foreign key dangles.
20
+ import { readdir, readFile, stat } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { parse as parseYaml } from "yaml";
23
+ import { observedIn, orderArchives, parseRequirements } from "../../behaviour-store/archive.js";
24
+ export { observedIn, parseRequirements };
25
+ const STORE = "openspec";
26
+ const AGREEMENTS = join("docs", "agreements");
27
+ async function isDir(p) {
28
+ try {
29
+ return (await stat(p)).isDirectory();
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function dirs(p) {
36
+ try {
37
+ return (await readdir(p, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ async function readText(p) {
44
+ try {
45
+ return await readFile(p, "utf8");
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ async function deltaOf(changeDir) {
52
+ const out = [];
53
+ for (const feature of await dirs(join(changeDir, "specs"))) {
54
+ const file = join(changeDir, "specs", feature, "spec.md");
55
+ const text = await readText(file);
56
+ if (text === null)
57
+ continue;
58
+ for (const r of parseRequirements(text)) {
59
+ if (r.operation)
60
+ out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ async function agreementRecord(sddPath, change) {
66
+ const text = await readText(join(sddPath, AGREEMENTS, `${change}.yaml`));
67
+ if (text === null)
68
+ return null;
69
+ try {
70
+ const doc = parseYaml(text);
71
+ return doc?.agreement_record ?? null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /** Archive directories in archive order; same-day archives the history cannot order are reported. */
78
+ export async function archiveOrder(sddPath, names, findings) {
79
+ const { order, ties } = await orderArchives(sddPath, `${STORE}/changes/archive`, names);
80
+ for (const [a, b] of ties) {
81
+ findings.add("convention", `${STORE}/changes/archive: ${a} and ${b} were archived the same day and the SDD's history does not say which came first; ordered by name, so a status that depends on their order may be wrong`);
82
+ }
83
+ return order;
84
+ }
85
+ const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
86
+ export async function buildRequirements(inputs) {
87
+ const { sddPath, findings } = inputs;
88
+ const empty = { requirements: [], lineage: [], status: [] };
89
+ const store = join(sddPath, STORE);
90
+ if (!(await isDir(store)))
91
+ return empty;
92
+ const requirements = new Map();
93
+ const unknownFeatures = new Set();
94
+ const known = (feature) => {
95
+ if (inputs.knownFeatureIds.has(feature))
96
+ return true;
97
+ unknownFeatures.add(feature);
98
+ return false;
99
+ };
100
+ // 1. Living specs.
101
+ for (const feature of await dirs(join(store, "specs"))) {
102
+ const file = join(store, "specs", feature, "spec.md");
103
+ const text = await readText(file);
104
+ if (text === null || !known(feature))
105
+ continue;
106
+ for (const r of parseRequirements(text)) {
107
+ const id = `${feature}::${r.name}`;
108
+ if (requirements.has(id)) {
109
+ findings.add("convention", `${rel(sddPath, file)}: requirement "${r.name}" is named twice; names must be unique within a spec (M-ADR 0221 §4.7)`);
110
+ continue;
111
+ }
112
+ requirements.set(id, { id, feature_id: feature, name: r.name, stage: "living", spec_path: rel(sddPath, file), scenario_count: r.scenarios, source_sdd_id: "", platform_sdd_id: "" });
113
+ }
114
+ }
115
+ const events = [];
116
+ const lineage = [];
117
+ const unknownRepos = new Set();
118
+ const pushEvent = (e) => {
119
+ if (!inputs.knownRepoIds.has(e.repo)) {
120
+ unknownRepos.add(e.repo);
121
+ return;
122
+ }
123
+ if (requirements.has(e.requirementId))
124
+ events.push(e);
125
+ };
126
+ // 2. Archived changes, in archive order.
127
+ for (const dir of await archiveOrder(sddPath, await dirs(join(store, "changes", "archive")), findings)) {
128
+ const changeDir = join(store, "changes", "archive", dir);
129
+ const change = dir.replace(/^\d{4}-\d{2}-\d{2}-/, "");
130
+ const archivedOn = /^(\d{4}-\d{2}-\d{2})-/.exec(dir)?.[1] ?? "";
131
+ const evidence = await readText(join(changeDir, "baseline-evidence.md"));
132
+ const kind = evidence !== null ? "baseline" : "change";
133
+ const delta = await deltaOf(changeDir);
134
+ for (const d of delta) {
135
+ if (d.operation === "REMOVED")
136
+ continue;
137
+ const requirementId = `${d.feature}::${d.name}`;
138
+ if (!requirements.has(requirementId))
139
+ continue;
140
+ lineage.push({ id: `${requirementId}__${dir}`, requirement_id: requirementId, change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" });
141
+ }
142
+ if (evidence !== null) {
143
+ const repos = observedIn(evidence);
144
+ if (repos.length === 0) {
145
+ findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
146
+ }
147
+ for (const repo of repos)
148
+ for (const d of delta)
149
+ if (d.operation !== "REMOVED")
150
+ pushEvent({ requirementId: `${d.feature}::${d.name}`, repo, status: "observed", basis: dir });
151
+ continue;
152
+ }
153
+ const record = await agreementRecord(sddPath, change);
154
+ if (record?.verdict?.result !== "PASS")
155
+ continue; // the behaviour-store validator reports an archived change without a PASS verdict
156
+ for (const wo of record.work_orders ?? []) {
157
+ if (!wo.repo)
158
+ continue;
159
+ for (const name of wo.implements ?? []) {
160
+ const match = delta.filter((d) => d.name === name && d.operation !== "REMOVED");
161
+ if (match.length === 0) {
162
+ findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
163
+ }
164
+ for (const d of match)
165
+ pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "declared-shipped", basis: dir });
166
+ }
167
+ }
168
+ }
169
+ // 3. Open changes whose agreement record is agreed.
170
+ for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
171
+ const record = await agreementRecord(sddPath, dir);
172
+ if (record?.status !== "agreed")
173
+ continue;
174
+ const delta = await deltaOf(join(store, "changes", dir));
175
+ for (const d of delta) {
176
+ const id = `${d.feature}::${d.name}`;
177
+ if (d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
178
+ requirements.set(id, { id, feature_id: d.feature, name: d.name, stage: "agreed", spec_path: rel(sddPath, d.file), scenario_count: d.scenarios, source_sdd_id: "", platform_sdd_id: "" });
179
+ }
180
+ }
181
+ for (const wo of record.work_orders ?? []) {
182
+ if (!wo.repo)
183
+ continue;
184
+ for (const name of wo.implements ?? []) {
185
+ for (const d of delta.filter((x) => x.name === name && x.operation !== "REMOVED")) {
186
+ pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: dir });
187
+ }
188
+ }
189
+ }
190
+ }
191
+ for (const f of unknownFeatures)
192
+ findings.add("convention", `${STORE}/: a spec keyed "${f}" names no catalogue feature, so its requirements are not catalogued (M-ADR 0221 §4.7)`);
193
+ for (const r of unknownRepos)
194
+ findings.add("convention", `${STORE}/: a status names repository "${r}", which the catalogue does not hold; no status row`);
195
+ // Latest event per (requirement, repository) wins.
196
+ const status = new Map();
197
+ for (const e of events) {
198
+ const id = `${e.requirementId}__${e.repo}`;
199
+ status.set(id, { id, requirement_id: e.requirementId, repo_id: e.repo, status: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
200
+ }
201
+ const byId = (a, b) => String(a["id"]).localeCompare(String(b["id"]));
202
+ return {
203
+ requirements: [...requirements.values()].sort(byId),
204
+ lineage,
205
+ status: [...status.values()].sort(byId),
206
+ };
207
+ }
208
+ //# sourceMappingURL=requirements.js.map
@@ -1,10 +1,11 @@
1
1
  // service_contract edge — emits one row per (service, contract, exposes)
2
- // triple from `contracts.{openapi,proto,avro}` blocks in repo-config.
2
+ // triple from `contracts.{openapi,proto,avro,spicedb}` blocks in repo-config.
3
3
  //
4
4
  // Forward method convention: a service's repo-config declares the
5
5
  // contracts it exposes. The "consumes" role isn't declared in current
6
6
  // adopters; this builder emits role=exposes only.
7
7
  import { basename, sep } from "node:path";
8
+ import { REPO_CONFIG_CONTRACT_KINDS } from "./repositories.js";
8
9
  export function buildServiceContract(inputs) {
9
10
  const rows = [];
10
11
  const seen = new Set();
@@ -30,13 +31,9 @@ export function buildServiceContract(inputs) {
30
31
  // stays silent, as it always has.
31
32
  // NOTE: `block` here is repo-config's `contracts` (cfg.data.contracts), a
32
33
  // DIFFERENT schema from the traceability manifest's contracts block — so its
33
- // three kinds are not a stale copy of the manifest's five and must not be
34
- // "fixed" by the shared traceability helper. Whether repo-config should also
35
- // key graphql is its own question, on its own schema.
36
- const hasContracts = block !== undefined &&
37
- ((block.openapi?.length ?? 0) > 0 ||
38
- (block.proto?.length ?? 0) > 0 ||
39
- (block.avro?.length ?? 0) > 0);
34
+ // kinds (REPO_CONFIG_CONTRACT_KINDS) are not a stale copy of the manifest's five
35
+ // and must not be "fixed" by the shared traceability helper.
36
+ const hasContracts = block !== undefined && REPO_CONFIG_CONTRACT_KINDS.some((k) => (block[k]?.length ?? 0) > 0);
40
37
  if (hasContracts) {
41
38
  inputs.findings.add("orphan", `${cfg.relPath}: contracts block declares exposes-edges for service "${cfg.repoId}", which does not resolve in services.csv — dropped. Check a services: block entry exists at this id (M-ADR 0197); this can also mean the repo's own default service id was never a runtime type.`);
42
39
  }
@@ -44,7 +41,7 @@ export function buildServiceContract(inputs) {
44
41
  }
45
42
  if (!block)
46
43
  continue;
47
- for (const kind of ["openapi", "proto", "avro"]) {
44
+ for (const kind of REPO_CONFIG_CONTRACT_KINDS) {
48
45
  const list = block[kind] ?? [];
49
46
  for (const rawEntry of list) {
50
47
  const entry = rawEntry.replace(/^\/+/, "");