@sdd-method/author-cli 0.10.0 → 0.11.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.
@@ -933,3 +933,21 @@ 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
@@ -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: "^5.0.0",
31
31
  sdd_manifest: "^1.0.0",
32
32
  aggregation_manifest: "^1.0.0",
33
33
  };
@@ -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);
@@ -0,0 +1,204 @@
1
+ // What an agreement binds and what a verdict must show (M-ADR 0222 §4.6, M-ADR 0223 §4.4-§4.6).
2
+ // Pure text helpers; behaviour-store.ts runs them against git.
3
+ //
4
+ // agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored) and the test plan's
5
+ // scenario-to-test rows (its Before and After cells ignored) are what was approved. Each is compared
6
+ // with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
7
+ // requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
8
+ // stale check compares the blocks a change targets, not the whole file.
9
+ // scenario kind changed (a scenario of an ADDED requirement, or one of a MODIFIED requirement that is new or
10
+ // differs from the living spec's scenario of the same title) goes red then green; carried (identical
11
+ // to the living spec's) is regression and stays green.
12
+ // verdict line verdict.md's own verdict: `Verdict: PASS|FAIL` or a leading `**PASS**`/`**FAIL**`.
13
+ /** The files under a change directory that agreement binds, relative to the change directory. */
14
+ export function boundFile(rel) {
15
+ return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel);
16
+ }
17
+ /** A bound file's content as agreement compares it: all of it, with only the fields execution fills in masked. */
18
+ export function normaliseBound(rel, text) {
19
+ if (rel === "tasks.md")
20
+ return text.replace(/^(\s*-\s*\[)\s*[xX~]?\s*(\])/gm, "$1 $2").trimEnd();
21
+ if (rel === "test-plan.md")
22
+ return normaliseTestPlan(text);
23
+ return text.trimEnd();
24
+ }
25
+ /**
26
+ * The test plan as agreed. Masked, because execution fills them in: the last two cells (Before and After) of each
27
+ * row of the scenario table, the whole Ledger section, and the last two cells (Killed by and Result) of each row of
28
+ * the Mutants table. Everything else, the stand-ins and any strategy or limitation stated, stays bound.
29
+ */
30
+ export function normaliseTestPlan(text) {
31
+ const out = [];
32
+ let section = "";
33
+ for (const line of text.split("\n")) {
34
+ const h = /^##\s+(.+?)\s*$/.exec(line);
35
+ if (h) {
36
+ section = h[1].toLowerCase();
37
+ out.push(line);
38
+ continue;
39
+ }
40
+ if (section === "ledger")
41
+ continue;
42
+ if ((section === "" || section === "mutants") && /^\s*\|/.test(line)) {
43
+ const cells = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
44
+ out.push(cells.slice(0, Math.max(0, cells.length - 2)).join(" | "));
45
+ continue;
46
+ }
47
+ out.push(line);
48
+ }
49
+ return out.join("\n").trimEnd();
50
+ }
51
+ /** A tasks file's repository groups (`## <n>. <repository>`) and the requirements each says it implements. */
52
+ export function taskGroups(tasks) {
53
+ const out = [];
54
+ let current = null;
55
+ for (const line of tasks.split("\n")) {
56
+ const g = /^##\s+\d+\.\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*$/.exec(line);
57
+ if (g) {
58
+ current = { repo: g[1], implements: [] };
59
+ out.push(current);
60
+ continue;
61
+ }
62
+ if (/^##\s/.test(line)) {
63
+ current = null;
64
+ continue;
65
+ }
66
+ const m = /^\s*(?:[-*]\s+)?\**Implements:?\**:?\s*(.+)$/i.exec(line);
67
+ if (current && m)
68
+ current.implements.push(...m[1].split(",").map((x) => x.trim().replace(/^`|`$/g, "")).filter((x) => x && !/^none$/i.test(x)));
69
+ }
70
+ return out;
71
+ }
72
+ /** A pinned commit: 7 to 40 hex characters. */
73
+ export function isCommit(v) {
74
+ return typeof v === "string" && /^[0-9a-f]{7,40}$/i.test(v);
75
+ }
76
+ /** a >= b, for x.y.z versions; a missing or malformed version is lowest. */
77
+ export function versionAtLeast(a, b) {
78
+ const p = (v) => (v && /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : [-1, -1, -1]);
79
+ const [x, y] = [p(a), p(b)];
80
+ for (let i = 0; i < 3; i++)
81
+ if (x[i] !== y[i])
82
+ return x[i] > y[i];
83
+ return true;
84
+ }
85
+ /** The test plan's scenario-to-test rows: every table row before `## Ledger`, as trimmed cells, with the first row (the header) and separator rows dropped. The template holds one table there; a second would be read as more rows. */
86
+ export function testPlanMapping(text) {
87
+ const head = text.split(/^##\s+Ledger\b/m)[0] ?? "";
88
+ const rows = head.split("\n").filter((l) => /^\s*\|/.test(l)).map((l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));
89
+ return rows.filter((r, i) => i > 0 && !r.every((c) => /^:?-{3,}:?$/.test(c) || c === ""));
90
+ }
91
+ /** The test plan table's header cells, lower-cased. */
92
+ export function testPlanHeader(text) {
93
+ const head = text.split(/^##\s+Ledger\b/m)[0] ?? "";
94
+ const first = head.split("\n").find((l) => /^\s*\|/.test(l));
95
+ return first ? first.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim().toLowerCase()) : [];
96
+ }
97
+ /** A requirement's block: `### Requirement: <name>` to the next requirement or `##` heading. Null when absent. */
98
+ export function requirementBlock(spec, name) {
99
+ const lines = spec.split("\n");
100
+ const start = lines.findIndex((l) => new RegExp(`^###\\s+Requirement:\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`).test(l));
101
+ if (start < 0)
102
+ return null;
103
+ let end = lines.length;
104
+ for (let i = start + 1; i < lines.length; i++) {
105
+ if (/^###\s+Requirement:/.test(lines[i]) || /^##\s/.test(lines[i])) {
106
+ end = i;
107
+ break;
108
+ }
109
+ }
110
+ return lines.slice(start, end).join("\n").trimEnd();
111
+ }
112
+ /** Scenario title -> its block text, within one requirement block. */
113
+ export function scenarios(block) {
114
+ const out = new Map();
115
+ let title = null;
116
+ let buf = [];
117
+ const flush = () => {
118
+ if (title !== null)
119
+ out.set(title, buf.join("\n").trim());
120
+ };
121
+ for (const line of block.split("\n")) {
122
+ const m = /^####\s+Scenario:\s*(.+?)\s*$/.exec(line);
123
+ if (m) {
124
+ flush();
125
+ title = m[1];
126
+ buf = [];
127
+ continue;
128
+ }
129
+ if (title !== null)
130
+ buf.push(line);
131
+ }
132
+ flush();
133
+ return out;
134
+ }
135
+ /** The section a requirement sits under in a delta: ADDED, MODIFIED, REMOVED or RENAMED. */
136
+ export function deltaOperation(delta, name) {
137
+ let op = null;
138
+ for (const line of delta.split("\n")) {
139
+ const s = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
140
+ if (s)
141
+ op = s[1];
142
+ else if (/^##\s/.test(line))
143
+ op = null;
144
+ if (new RegExp(`^###\\s+Requirement:\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`).test(line))
145
+ return op;
146
+ }
147
+ return null;
148
+ }
149
+ /** changed or carried, for one scenario of a requirement in a delta, against the living spec (null when the delta has no such scenario). */
150
+ export function scenarioKind(delta, living, requirement, scenario) {
151
+ const op = deltaOperation(delta, requirement);
152
+ const block = requirementBlock(delta, requirement);
153
+ if (!op || !block)
154
+ return null;
155
+ const mine = scenarios(block).get(scenario);
156
+ if (mine === undefined)
157
+ return null;
158
+ if (op !== "MODIFIED")
159
+ return "changed";
160
+ const theirs = living === null ? null : requirementBlock(living, requirement);
161
+ const before = theirs === null ? undefined : scenarios(theirs).get(scenario);
162
+ return before !== undefined && before === mine ? "carried" : "changed";
163
+ }
164
+ /** verdict.md's own verdict, or null when it states none in a form this reads. */
165
+ export function verdictLine(text) {
166
+ const m = /^\s*(?:#+\s*)?Verdict:\s*\**\s*(PASS|FAIL)\b/im.exec(text) ?? /^\s*\*\*(PASS|FAIL)\*\*/m.exec(text);
167
+ return m?.[1] ?? null;
168
+ }
169
+ /** Two pins name one commit when one abbreviates the other. */
170
+ export function sameCommit(a, b) {
171
+ const [x, y] = [a.toLowerCase(), b.toLowerCase()];
172
+ return x.length >= 7 && y.length >= 7 && (x.startsWith(y) || y.startsWith(x));
173
+ }
174
+ function isoDate(v) {
175
+ if (v instanceof Date && !Number.isNaN(v.getTime()))
176
+ return v.toISOString().slice(0, 10);
177
+ if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v))
178
+ return v;
179
+ return null;
180
+ }
181
+ /**
182
+ * Problems with one agreement condition (M-ADR 0222 §4.6 as amended); `today` is YYYY-MM-DD. A withdrawn or
183
+ * superseded change has closed its obligations: its conditions are not overdue.
184
+ */
185
+ export function conditionProblems(c, today, recordStatus = "agreed") {
186
+ const out = [];
187
+ for (const k of ["assumption", "owner", "how", "release_limit"]) {
188
+ if (typeof c[k] !== "string" || c[k].trim() === "")
189
+ out.push(`no ${k}`);
190
+ }
191
+ const by = isoDate(c.validate_by);
192
+ if (by === null)
193
+ out.push("validate_by is not a YYYY-MM-DD date");
194
+ const status = typeof c.status === "string" ? c.status : "";
195
+ if (!["open", "validated", "failed"].includes(status))
196
+ out.push(`status ${JSON.stringify(c.status)} is not open | validated | failed`);
197
+ if ((status === "validated" || status === "failed") && (typeof c.evidence !== "string" || c.evidence.trim() === ""))
198
+ out.push(`${status} without evidence`);
199
+ if (status === "open" && by !== null && by < today && recordStatus !== "withdrawn" && recordStatus !== "superseded")
200
+ out.push(`OVERDUE: open past its validate_by ${by}; validate it, extend it by a ruling, or withdraw the change`);
201
+ return out;
202
+ }
203
+ export { isoDate };
204
+ //# sourceMappingURL=agreement-evidence.js.map