@sdd-method/maintain-cli 0.1.0 → 0.3.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 (53) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/dist/lib/agent-harness.js +17 -8
  4. package/dist/lib/briefing/index.js +1 -1
  5. package/dist/lib/briefing/render.js +17 -8
  6. package/dist/lib/catalogue/aggregate.js +6 -6
  7. package/dist/lib/catalogue/aggregation-manifest-loader.js +4 -4
  8. package/dist/lib/catalogue/build.js +1 -1
  9. package/dist/lib/catalogue/builders/c4-components.js +1 -1
  10. package/dist/lib/catalogue/builders/c4-containers.js +1 -1
  11. package/dist/lib/catalogue/builders/c4-l1-candidates.js +1 -1
  12. package/dist/lib/catalogue/builders/container-dependency.js +1 -1
  13. package/dist/lib/catalogue/builders/data-store-mapping.js +1 -1
  14. package/dist/lib/catalogue/builders/screen-tree.js +1 -1
  15. package/dist/lib/catalogue/contract-path-resolution.js +2 -2
  16. package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
  17. package/dist/lib/catalogue/freshness.js +4 -3
  18. package/dist/lib/catalogue/scores-coverage.js +1 -1
  19. package/dist/lib/catalogue/service-contract-aliases-loader.js +1 -1
  20. package/dist/lib/ci-binding/index.js +44 -5
  21. package/dist/lib/ci-binding/module.js +74 -2
  22. package/dist/lib/context-pull/index.js +79 -39
  23. package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
  24. package/dist/lib/hooks-seed/entries.js +55 -4
  25. package/dist/lib/hooks-seed/index.js +4 -7
  26. package/dist/lib/hooks-seed/merge.js +31 -0
  27. package/dist/lib/init/ci-workflow.js +26 -2
  28. package/dist/lib/lock-file.js +10 -0
  29. package/dist/lib/pack/builder.js +56 -4
  30. package/dist/lib/pack/index.js +1 -0
  31. package/dist/lib/pack/publish-transform.js +1 -1
  32. package/dist/lib/sync/briefing-merge.js +50 -34
  33. package/dist/lib/sync/diff.js +1 -1
  34. package/dist/lib/sync/index.js +46 -72
  35. package/dist/lib/sync/settings-merge.js +71 -163
  36. package/dist/lib/validate/plan.js +6 -3
  37. package/dist/maintain-program.js +6 -3
  38. package/dist/maintain-version.json +2 -2
  39. package/dist/verbs/catalogue-check.js +28 -0
  40. package/dist/verbs/context.js +1 -1
  41. package/dist/verbs/pack-build.js +22 -0
  42. package/dist/verbs/sync.js +17 -0
  43. package/dist/verbs/validate.js +6 -1
  44. package/dist/verify-program.js +2 -2
  45. package/dist/verify-version.json +2 -2
  46. package/package.json +2 -6
  47. package/dist/lib/hooks-seed/copilot.js +0 -167
  48. package/dist/lib/hooks-seed/lifecycle-mapping.js +0 -28
  49. package/dist/lib/mcp-seed/entries.js +0 -14
  50. package/dist/lib/mcp-seed/index.js +0 -28
  51. package/dist/lib/mcp-seed/merge.js +0 -99
  52. package/dist/lib/mcp-seed/types.js +0 -13
  53. package/dist/lib/sync/copilot-hooks-merge.js +0 -108
package/NOTICE CHANGED
@@ -2,7 +2,7 @@ sdd-cli
2
2
  Copyright 2026 Tom Foster
3
3
 
4
4
  This product includes software developed as part of the SDD method
5
- distribution (https://github.com/tomfosteruk/sdd-method).
5
+ distribution.
6
6
 
7
7
  Licensed under the Apache License, Version 2.0 (the "License");
8
8
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -4,4 +4,4 @@
4
4
 
5
5
  Verbs: everything in `@sdd-method/verify-cli`, plus `sync`, `generate-briefing`, `context pull`, `catalogue build`, `catalogue aggregate`, `catalogue graph`, `catalogue diff`, `pack build`, `render screen`.
6
6
 
7
- Each writes only derived or method-managed content, and two runs on the same commit write the same bytes. Authoring verbs — `init`, `scaffold`, `plan`, `mcp`, `conformance` and the rest — are not present in it, and are in `@sdd-method/sdd-cli`. Invoking one fails with exit 1.
7
+ Each writes only derived or method-managed content, and two runs on the same commit write the same bytes. Authoring verbs — `init`, `scaffold`, `plan`, `conformance` and the rest — are not present in it, and are in `@sdd-method/sdd-cli`. Invoking one fails with exit 1.
@@ -1,25 +1,34 @@
1
1
  /**
2
2
  * Agent-harness identifiers — the value space of `--agent-harness` on
3
- * `sdd-cli init` and `sdd-cli sync` per ADR 0141 §4.6.
3
+ * `sdd-cli init`, `sdd-cli sync` and `context pull` per ADR 0141 §4.6.
4
4
  *
5
- * Claude Code is the reference implementation per ADR 0141 §4.4 and
6
- * the default. GitHub Copilot CLI is the second reference per
7
- * `docs/method/methods/hook-contracts.md`. Adopters using a different
8
- * harness implement the portable contracts themselves per ADR 0141 §4.5.
5
+ * Claude Code is the one supported harness (M-ADR 0220 §4.6) and the default. The GitHub Copilot
6
+ * CLI value is retired: passing it fails, naming that record. Every SDD and code repository also
7
+ * gets an AGENTS.md pointer to agent-instructions.md, with no flag; it is a pointer, not support
8
+ * for another harness. Adopters binding any other harness implement the portable contracts
9
+ * themselves (ADR 0141 §4.5).
9
10
  */
10
- export const AGENT_HARNESSES = ["claude-code", "github-copilot-cli"];
11
+ export const AGENT_HARNESSES = ["claude-code"];
11
12
  export const DEFAULT_AGENT_HARNESS = "claude-code";
13
+ /** Retired values, each with the record that retired it. */
14
+ export const RETIRED_AGENT_HARNESSES = {
15
+ "github-copilot-cli": "M-ADR 0220 §4.6",
16
+ };
12
17
  export function isAgentHarness(value) {
13
18
  return AGENT_HARNESSES.includes(value);
14
19
  }
15
20
  /**
16
21
  * Resolve an optional CLI input to a concrete harness, falling back to
17
- * the reference default when unset. Throws on an unsupported value so
18
- * the CLI can surface a clear error to the caller.
22
+ * the reference default when unset. Throws on an unsupported or retired
23
+ * value so the CLI can surface a clear error to the caller.
19
24
  */
20
25
  export function resolveAgentHarness(input) {
21
26
  if (input === undefined)
22
27
  return DEFAULT_AGENT_HARNESS;
28
+ const retired = RETIRED_AGENT_HARNESSES[input];
29
+ if (retired !== undefined) {
30
+ throw new Error(`--agent-harness "${input}" is retired (${retired}): Claude Code is the one supported harness, and every repository gets an AGENTS.md pointer to agent-instructions.md without a flag. Supported: ${AGENT_HARNESSES.join(", ")}.`);
31
+ }
23
32
  if (!isAgentHarness(input)) {
24
33
  throw new Error(`Unsupported --agent-harness "${input}". Supported: ${AGENT_HARNESSES.join(", ")}.`);
25
34
  }
@@ -8,6 +8,6 @@
8
8
  * content below survives sync via the existing `writeMarkedBlockFile`
9
9
  * machinery.
10
10
  */
11
- export { AGENT_INSTRUCTIONS_REL_PATH, BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, extractWrapperBlock, findUnresolvedPlaceholders, harnessWrapperOutputPath, renderAgentInstructions, renderHarnessWrapper, splitWrapperTemplate, substitute, } from "./render.js";
11
+ export { AGENT_INSTRUCTIONS_REL_PATH, BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, extractWrapperBlock, findUnresolvedPlaceholders, AGENTS_POINTER, briefingWrappers, harnessWrapperOutputPath, renderAgentInstructions, renderHarnessWrapper, renderWrapper, splitWrapperTemplate, substitute, } from "./render.js";
12
12
  export { resolveBriefingVariables } from "./variables.js";
13
13
  //# sourceMappingURL=index.js.map
@@ -21,15 +21,22 @@ const HARNESS_WRAPPER_TEMPLATE = {
21
21
  templateRel: "claude-code/CLAUDE.md.hbs",
22
22
  outputRel: "CLAUDE.md",
23
23
  },
24
- "github-copilot-cli": {
25
- templateRel: "github-copilot-cli/AGENTS.md.hbs",
26
- outputRel: "AGENTS.md",
27
- },
28
24
  };
29
- /** Output filename per harness — e.g. `CLAUDE.md` or `AGENTS.md`. */
25
+ /** Output filename per harness — `CLAUDE.md`. */
30
26
  export function harnessWrapperOutputPath(harness) {
31
27
  return HARNESS_WRAPPER_TEMPLATE[harness].outputRel;
32
28
  }
29
+ /**
30
+ * The AGENTS.md pointer (M-ADR 0220 §4.6): a few lines directing any agent tool that reads a root
31
+ * AGENTS.md to agent-instructions.md. Written for every SDD and code repository with no flag, and
32
+ * rendered and refreshed exactly as the CLAUDE.md wrapper is. A pointer, not support for another
33
+ * harness. Relative to the briefing template root; code repositories read it under code-repo/.
34
+ */
35
+ export const AGENTS_POINTER = { templateRel: "AGENTS.md.hbs", outputRel: "AGENTS.md" };
36
+ /** Every marker-merged wrapper a repository gets: the harness's own, then the AGENTS.md pointer. */
37
+ export function briefingWrappers(harness) {
38
+ return [HARNESS_WRAPPER_TEMPLATE[harness], AGENTS_POINTER];
39
+ }
33
40
  /**
34
41
  * Marker pair the wrapper templates use to delimit the method-managed
35
42
  * region. The wrapper sync path slices this region out of the freshly
@@ -71,9 +78,11 @@ export async function renderAgentInstructions(targetRepo, vars) {
71
78
  return renderFromPath(templatePath, vars);
72
79
  }
73
80
  export async function renderHarnessWrapper(targetRepo, harness, vars) {
74
- const { templateRel } = HARNESS_WRAPPER_TEMPLATE[harness];
75
- const templatePath = join(targetRepo, BRIEFING_TEMPLATE_ROOT, templateRel);
76
- return renderFromPath(templatePath, vars);
81
+ return renderWrapper(targetRepo, HARNESS_WRAPPER_TEMPLATE[harness].templateRel, vars);
82
+ }
83
+ /** Render one wrapper template from the briefing root; null when the baseline does not ship it. */
84
+ export async function renderWrapper(targetRepo, templateRel, vars) {
85
+ return renderFromPath(join(targetRepo, BRIEFING_TEMPLATE_ROOT, templateRel), vars);
77
86
  }
78
87
  /**
79
88
  * Split a fully-rendered wrapper template at the method-managed
@@ -184,8 +184,8 @@ export async function runCatalogueAggregate(inputs, write) {
184
184
  // The same roots, additionally reachable by DIRECTORY BASENAME. An author who
185
185
  // writes a `platform/<name>/...` contract path is reading that name off the
186
186
  // filesystem, so it is the directory — which is routinely not the sdd_id (one
187
- // estate declares `experience-platform` in a directory named
188
- // `sdd-experience-platform`). Kept separate from sddRootsById on purpose: the
187
+ // estate declares `customer-platform` in a directory named
188
+ // `sdd-customer-platform`). Kept separate from sddRootsById on purpose: the
189
189
  // try-every-root pass must iterate each root ONCE, or a root reachable under two
190
190
  // names would count as two hits and report a false ambiguity.
191
191
  const sddRootAliases = new Map(sddRootsById);
@@ -1385,8 +1385,8 @@ export function adjudicateExternalSystems(rowsByCatalogue, findings) {
1385
1385
  const containers = rowsByCatalogue.get("c4_containers") ?? [];
1386
1386
  const deps = rowsByCatalogue.get("system_external_dependency") ?? [];
1387
1387
  // Camel humps split BEFORE lowercasing (F11a): most SDD ids are
1388
- // hyphenated, and `bvfmGatewayFramework` must be able to declare
1389
- // `bvfm-gateway-framework`.
1388
+ // hyphenated, and `posGatewayFramework` must be able to declare
1389
+ // `pos-gateway-framework`.
1390
1390
  const norm = (s) => s
1391
1391
  .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
1392
1392
  .toLowerCase()
@@ -1556,10 +1556,10 @@ export function adjudicateExternalSystems(rowsByCatalogue, findings) {
1556
1556
  // the display name agrees with the target — diagram entry ids are
1557
1557
  // conventionally local, and an old mnemonic that happens to equal an
1558
1558
  // estate id must not convert silently. Comparison is TOKEN-level, not
1559
- // substring (F12): "Tachograph Service" must not corroborate `tacho`
1559
+ // substring (F12): "Stockroom Service" must not corroborate `stock`
1560
1560
  // (shared prefix, different entity), while "Engine (telematics
1561
1561
  // processor)" must corroborate a container named "Engine" (a richer
1562
- // label must not defeat the match) and three-letter names like "CWS"
1562
+ // label must not defeat the match) and three-letter names like "OMS"
1563
1563
  // must work (no length floor). Corroborated = compact equality with the
1564
1564
  // target's name or id, or every token of either appearing among the
1565
1565
  // label's tokens. A declared id that corroborates is an assertion the
@@ -1,8 +1,8 @@
1
1
  // Aggregation-manifest loader per
2
2
  // docs/method/methods/aggregation-manifest-schema.yaml.
3
3
  //
4
- // v1 supports `pull.kind: filesystem` only; other kinds (git-ref,
5
- // release-asset, mcp-resource) are schema-reserved and reject with a
4
+ // v1 supports `pull.kind: filesystem` only; the other kinds (git-ref,
5
+ // release-asset) are schema-reserved and reject with a
6
6
  // clear "deferred" error so adopters get unambiguous feedback.
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { isAbsolute, relative, resolve } from "node:path";
@@ -115,8 +115,8 @@ export async function loadAggregationManifest(manifestPath, options) {
115
115
  const kind = s.pull.kind;
116
116
  if (kind !== "filesystem") {
117
117
  throw new Error(`aggregation manifest: sibling "${s.sdd_id}" uses pull.kind="${kind}"\n` +
118
- ` v1 supports filesystem only. Other kinds (git-ref, release-asset,\n` +
119
- ` mcp-resource) are schema-reserved and not yet implemented.\n` +
118
+ ` v1 supports filesystem only. The other kinds (git-ref, release-asset)\n` +
119
+ ` are schema-reserved and not yet implemented.\n` +
120
120
  ` See: docs/method/methods/aggregation-manifest-schema.yaml#pull_kinds`);
121
121
  }
122
122
  const path = s.pull.path;
@@ -1158,7 +1158,7 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1158
1158
  out.set("repo_dependency", await buildRepoDependencies({ sddPath, configs: reposResult.configs, knownRepoIds: knownRepoIdsFinal, findings }));
1159
1159
  // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1160
1160
  // shipped per `coordination/c4-l3-walker-and-path-convention-coordination.md`
1161
- // (sdd-method-gov, 2026-05-29) — forward-method authoring path reading
1161
+ // (method governance, 2026-05-29) — forward-method authoring path reading
1162
1162
  // `c4-l3-*.mermaid.md` files alongside the L2 layout. The earlier Path-B
1163
1163
  // deferral assumed reverse-method code-walking; this walker is the
1164
1164
  // forward-method analogue, granularity-agnostic (per-container or
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Mirrors c4-containers.ts at one level down. Source convention per
8
8
  // `coordination/c4-l3-walker-and-path-convention-coordination.md`
9
- // (sdd-method-gov, 2026-05-29). The walker is granularity-agnostic — both
9
+ // (method governance, 2026-05-29). The walker is granularity-agnostic — both
10
10
  // per-container files (one `Container_Boundary` block per file) and
11
11
  // per-domain rollup files (multiple `Container_Boundary` blocks per file)
12
12
  // produce identical row output. Adopters pick the file shape that suits
@@ -219,7 +219,7 @@ function slugify(s) {
219
219
  }
220
220
  /**
221
221
  * F21: two technology strings flatly contradict when both are present and
222
- * share no token — ".NET 6 (in Linton)" vs "Java" needs no NLP. Absent or
222
+ * share no token — ".NET 6 (in Kestrel)" vs "Java" needs no NLP. Absent or
223
223
  * overlapping strings are NOT a contradiction (silence, not a guess).
224
224
  */
225
225
  export function techContradicts(a, b) {
@@ -20,7 +20,7 @@
20
20
  // personas instead of the sibling's. c4-external-systems UNIONS every
21
21
  // existing L1 file rather than picking one, so candidate order is irrelevant
22
22
  // there; it shares these paths only to avoid a divergent copy.
23
- // See sdd-method-gov/coordination/persona-c4system-l1-priority-bug-coordination.md.
23
+ // See the method's coordination record persona-c4system-l1-priority-bug.
24
24
  import { readdir, stat } from "node:fs/promises";
25
25
  import { join } from "node:path";
26
26
  /** Own-L1 candidates usable by ANY SDD, in priority order. */
@@ -121,7 +121,7 @@ export async function buildContainerDependency(inputs) {
121
121
  * SAME referent and fired. The predicate was inverted on the two
122
122
  * name-shapes the estate actually uses.
123
123
  *
124
- * The independent channel is the technology string: ".NET 6 (in Linton)"
124
+ * The independent channel is the technology string: ".NET 6 (in Kestrel)"
125
125
  * against "Java" is a flat contradiction and needs no NLP. A dual-use
126
126
  * entry-id (Container + System_Ext in one SDD) whose technologies
127
127
  * contradict draws the finding REGARDLESS of what the names say — name
@@ -71,7 +71,7 @@ export function collectTableLines(lines, startIdx) {
71
71
  return null;
72
72
  }
73
73
  // ADR 0164: map a Data Store Mapping `Store` cell (a physical engine such as
74
- // "MySQL (fleet-db)", "MongoDB", "DynamoDB", "Kinesis") to the canonical
74
+ // "MySQL (orders-db)", "MongoDB", "DynamoDB", "Kinesis") to the canonical
75
75
  // `entities.persistence_kind` classification. This mirrors the services
76
76
  // builder's deriveLanguage (a normalised token derived from a raw cell) —
77
77
  // faithful classification, not a verbatim engine. The raw engine string is
@@ -35,7 +35,7 @@ const STRING_RE = /"[^"]*"|'[^']*'/g;
35
35
  * line-structure-preserving) so call-head lexing can never fire on quoted
36
36
  * content — `Table("Odometer (km)")` must not mint a phantom `Odometer`
37
37
  * component, and `"total (sum)"` must not false-fire the lowercase-call-head
38
- * finding (tomfosteruk/sdd-cli#1). Quote characters are kept; newlines inside
38
+ * finding (sdd-cli issue 1). Quote characters are kept; newlines inside
39
39
  * a span survive. The grammar defines no escape sequences (a quote always
40
40
  * closes the literal), matching STRING_RE and stripLineComment.
41
41
  *
@@ -54,8 +54,8 @@ export function isPathShaped(value) {
54
54
  * sdd_id OR by its directory basename, and the distinction is not cosmetic: an
55
55
  * author writing this prefix is reading it off the filesystem, so it is the
56
56
  * DIRECTORY they write. In one measured estate every such prefix is
57
- * `platform/sdd-experience-platform/` while that SDD's declared sdd_id is
58
- * `experience-platform` — matching on sdd_id alone therefore split nothing and
57
+ * `platform/sdd-customer-platform/` while that SDD's declared sdd_id is
58
+ * `customer-platform` — matching on sdd_id alone therefore split nothing and
59
59
  * reported 13 existing contracts as missing. Both keys are accepted.
60
60
  *
61
61
  * That prefix
@@ -39,6 +39,7 @@ export async function compareCatalogueDirs(producedDir, committedDir) {
39
39
  return stale;
40
40
  }
41
41
  export async function checkCataloguesFresh(sddPath, opts, out) {
42
+ const label = opts.label ?? "catalogue build --check";
42
43
  const tmp = await mkdtemp(join(tmpdir(), "sdd-catalogue-check-"));
43
44
  try {
44
45
  const built = await runCatalogueBuild({
@@ -48,16 +49,16 @@ export async function checkCataloguesFresh(sddPath, opts, out) {
48
49
  ...(opts.snapshotStore !== undefined ? { snapshotStore: opts.snapshotStore } : {}),
49
50
  }, () => { });
50
51
  if (built.exitCode !== 0) {
51
- out(`[FAIL] catalogue build --check: the build itself failed (exit ${built.exitCode}); run catalogue build to see why.\n`);
52
+ out(`[FAIL] ${label}: the build itself failed (exit ${built.exitCode}); run catalogue build to see why.\n`);
52
53
  return { exitCode: built.exitCode, stale: [] };
53
54
  }
54
55
  const produced = (await readdir(tmp)).sort();
55
56
  const stale = await compareCatalogueDirs(tmp, join(sddPath, "catalogues"));
56
57
  if (stale.length === 0) {
57
- out(`[PASS] catalogue build --check: ${produced.length} catalogue file(s) reproduce byte-identically from the committed specs.\n`);
58
+ out(`[PASS] ${label}: ${produced.length} catalogue file(s) reproduce byte-identically from the committed specs.\n`);
58
59
  return { exitCode: 0, stale };
59
60
  }
60
- out(`[FAIL] catalogue build --check: ${stale.length} catalogue file(s) are stale against the committed specs — run sdd-cli catalogue build and commit the result:\n`);
61
+ out(`[FAIL] ${label}: ${stale.length} catalogue file(s) are stale against the committed specs — catalogue build (sdd-maintain or sdd-cli) rebuilds them:\n`);
61
62
  for (const s of stale)
62
63
  out(` ${s}\n`);
63
64
  return { exitCode: 1, stale };
@@ -171,7 +171,7 @@ export const RUBRIC_ELIGIBILITY = [
171
171
  joinColumn: "id",
172
172
  refTransform: null,
173
173
  eligibilityFilter: null,
174
- eligibilityNote: "NO local population, measured rather than assumed. Refs are '<domain>-feature-grounding-<slug>' and the tails are SIBLING SDD SLUGS (app-shell, cold-chain, connect-rapide), not rows in this SDD: 0/15 against products, applications, features and repositories, and 1/15 against capabilities — a coincidence, not a join. A cross-SDD reference the member tier cannot resolve, so its coverage is declared unmeasurable rather than given a transform that would produce plausible wrong numbers.",
174
+ eligibilityNote: "NO local population, measured rather than assumed. Refs are '<domain>-feature-grounding-<slug>' and the tails are SIBLING SDD SLUGS (app-shell, gift-cards, storefront-express), not rows in this SDD: 0/15 against products, applications, features and repositories, and 1/15 against capabilities — a coincidence, not a join. A cross-SDD reference the member tier cannot resolve, so its coverage is declared unmeasurable rather than given a transform that would produce plausible wrong numbers.",
175
175
  },
176
176
  {
177
177
  rubricId: "reverse-contract-quality",
@@ -27,7 +27,7 @@
27
27
  * # services). Use when the federated SDD lacks a services.csv to
28
28
  * # gate emission and consumes rows would otherwise dangle at the
29
29
  * # aggregator with unresolved service_id.
30
- * - postman-tacho
30
+ * - postman-stock
31
31
  * - smoke-tests
32
32
  *
33
33
  * same_product_inference:
@@ -14,7 +14,7 @@ import { createHash } from "node:crypto";
14
14
  import { dirname, join } from "node:path";
15
15
  import { BLOCK_BEGIN, BLOCK_END, WORKFLOW_REL_PATH, renderMethodManagedWorkflowBlock, renderMethodValidationWorkflow, } from "../init/ci-workflow.js";
16
16
  import { detectBlockDrift, writeMarkedBlockFile } from "../sync/yaml-block-merge.js";
17
- import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, moduleSha256, renderModuleFiles } from "./module.js";
17
+ import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, REBUILD_FLAG, REBUILD_SCRIPT, moduleSha256, renderModuleFiles } from "./module.js";
18
18
  export const CI_BINDINGS = ["github", "gitlab", "jenkins"];
19
19
  /** M-ADR 0219 §8.2 (ruled 2026-10-16): what every SDD has before it chooses. */
20
20
  export const DEFAULT_CI_BINDING = "github";
@@ -34,6 +34,18 @@ export function isCiBinding(s) {
34
34
  const HASH = { begin: BLOCK_BEGIN, end: BLOCK_END };
35
35
  // Groovy has no '#' comments.
36
36
  const SLASH = { begin: "// method-managed: begin", end: "// method-managed: end" };
37
+ // How each host's adopter switches the catalogue rebuild on (M-ADR 0220 §4.4): the variable the method names.
38
+ const REBUILD_SETUP = {
39
+ "#": [
40
+ "set the masked (not protected) CI variable",
41
+ "SDD_CATALOGUE_PUSH_TOKEN to a project or group access token with write_repository.",
42
+ ],
43
+ "//": [
44
+ "set the job's environment variable",
45
+ "SDD_CATALOGUE_PUSH_CREDENTIAL to the ID of a username-and-password credential (a token",
46
+ "with write) for the repository's https remote.",
47
+ ],
48
+ };
37
49
  function header(comment, profile) {
38
50
  return [
39
51
  `${comment} Method Validation per M-ADR 0129, run by the method's Dagger module (M-ADR 0219).`,
@@ -47,6 +59,11 @@ function header(comment, profile) {
47
59
  comment,
48
60
  `${comment} Run it locally:`,
49
61
  `${comment} ${MODULE_CALL}`,
62
+ comment,
63
+ `${comment} Catalogue rebuild (M-ADR 0220 §4.4), off until you configure it:`,
64
+ ...REBUILD_SETUP[comment].map((l) => `${comment} ${l}`),
65
+ `${comment} The job then rebuilds the catalogues and pushes them to the branch it is`,
66
+ `${comment} building (never a fork's), and the freshness step gates.`,
50
67
  ];
51
68
  }
52
69
  // GitLab: a Docker-in-Docker service, the shape GitLab documents for Dagger. The runner must allow
@@ -71,10 +88,16 @@ function gitlabBlock(profile, runner) {
71
88
  ' - if: $CI_PIPELINE_SOURCE == "merge_request_event"',
72
89
  " - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
73
90
  " before_script:",
74
- " - apk add --no-cache curl",
91
+ " - apk add --no-cache curl git",
75
92
  ` - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin DAGGER_VERSION=${DAGGER_VERSION} sh`,
76
93
  " script:",
77
- ` - ${MODULE_CALL}`,
94
+ " - |",
95
+ ' if [ -n "${SDD_CATALOGUE_PUSH_TOKEN:-}" ] && [ "${CI_MERGE_REQUEST_SOURCE_PROJECT_ID:-$CI_PROJECT_ID}" = "$CI_PROJECT_ID" ]; then',
96
+ ' SDD_REBUILD_BRANCH="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:-$CI_COMMIT_BRANCH}" \\',
97
+ ' SDD_REBUILD_PUSH_URL="${CI_SERVER_PROTOCOL}://oauth2:${SDD_CATALOGUE_PUSH_TOKEN}@${CI_SERVER_URL#*://}/${CI_PROJECT_PATH}.git" \\',
98
+ ` sh ${REBUILD_SCRIPT}`,
99
+ " fi",
100
+ ` - ${MODULE_CALL} ${REBUILD_FLAG}`,
78
101
  HASH.end,
79
102
  ].join("\n");
80
103
  }
@@ -92,7 +115,18 @@ function jenkinsBlock(profile, runner) {
92
115
  " stage('Method Validation') {",
93
116
  " steps {",
94
117
  ` sh 'curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$WORKSPACE@tmp/dagger" DAGGER_VERSION=${DAGGER_VERSION} sh'`,
95
- ` sh 'PATH="$WORKSPACE@tmp/dagger:$PATH" ${MODULE_CALL}'`,
118
+ " script {",
119
+ " if (env.SDD_CATALOGUE_PUSH_CREDENTIAL && !env.CHANGE_FORK) {",
120
+ " withCredentials([usernamePassword(credentialsId: env.SDD_CATALOGUE_PUSH_CREDENTIAL, usernameVariable: 'SDD_PUSH_USER', passwordVariable: 'SDD_PUSH_TOKEN')]) {",
121
+ " sh '''",
122
+ ' export SDD_REBUILD_BRANCH="${CHANGE_BRANCH:-${BRANCH_NAME:-${GIT_BRANCH#origin/}}}"',
123
+ ' export SDD_REBUILD_PUSH_URL="$(git config --get remote.origin.url | sed "s#^https://#https://${SDD_PUSH_USER}:${SDD_PUSH_TOKEN}@#")"',
124
+ ` PATH="$WORKSPACE@tmp/dagger:$PATH" sh ${REBUILD_SCRIPT}`,
125
+ " '''",
126
+ " }",
127
+ " }",
128
+ " }",
129
+ ` sh 'PATH="$WORKSPACE@tmp/dagger:$PATH" ${MODULE_CALL} ${REBUILD_FLAG}'`,
96
130
  " }",
97
131
  " }",
98
132
  " }",
@@ -160,7 +194,12 @@ export async function syncCiBinding(o) {
160
194
  onDisk.push([rel, await tryRead(join(o.targetRepo, MODULE_DIR, rel))]);
161
195
  const present = onDisk.some(([, c]) => c !== null);
162
196
  const matches = rendered.every(([rel, c], i) => onDisk[i][0] === rel && onDisk[i][1] === c);
163
- const modulePristine = o.prevLock?.ciModuleSha256 !== undefined && moduleSha256(onDisk) === o.prevLock.ciModuleSha256;
197
+ // Pristine when the files on disk hash to the lock's digest. The render may list a file the last sync
198
+ // never wrote (the generator gained one): it is absent, not edited, so the digest over the files present
199
+ // decides. A file the adopter deleted still changes that digest, so it is still drift.
200
+ const lockSha = o.prevLock?.ciModuleSha256;
201
+ const modulePristine = lockSha !== undefined &&
202
+ (moduleSha256(onDisk) === lockSha || moduleSha256(onDisk.filter(([, c]) => c !== null)) === lockSha);
164
203
  if (present && !matches && modulePristine) {
165
204
  log.push(`${MODULE_DIR}/: unmodified since the last sync (digest matches the lock); re-rendering it for this sdd-cli.`);
166
205
  }
@@ -22,6 +22,16 @@ export const MODULE_DIR = ".dagger/method-validation";
22
22
  export const DAGGER_VERSION = "0.21.10";
23
23
  /** The command every host wrapper runs. */
24
24
  export const MODULE_CALL = `dagger call -m ${MODULE_DIR} method-validation --source .`;
25
+ /** The pipeline catalogue rebuild (M-ADR 0220 §4.4): a method-managed script in the module, run by every host wrapper. */
26
+ export const REBUILD_SCRIPT = `${MODULE_DIR}/catalogue-rebuild.sh`;
27
+ /**
28
+ * Left in the git directory by REBUILD_SCRIPT when the rebuild ran, whether or not it pushed. Every
29
+ * wrapper turns it into the module's --catalogue-rebuild, so the freshness step gates exactly where
30
+ * the rebuild ran (sdm-jts.9's ruling) and the wrapper holds no condition of its own.
31
+ */
32
+ export const REBUILD_RAN_MARKER = "sdd-catalogue-rebuild-ran";
33
+ /** What every wrapper appends to MODULE_CALL: --catalogue-rebuild when, and only when, the rebuild ran. */
34
+ export const REBUILD_FLAG = `$(test -f "$(git rev-parse --git-dir)/${REBUILD_RAN_MARKER}" && echo --catalogue-rebuild)`;
25
35
  const VERIFY_PIN = "@@VERIFY_CLI_VERSION@@";
26
36
  // The module's SDK import, substituted at render: written literally, the persona builds' import-closure
27
37
  // scan would read this text as an import of the CLI's own and refuse a dependency it does not declare.
@@ -39,7 +49,8 @@ const INDEX_TS = String.raw `/**
39
49
  *
40
50
  * The steps are the method's job plan, read at run time from "sdd-verify validate --list". Every
41
51
  * step runs even after an earlier one fails, so one red step never hides the verdicts after it. The
42
- * job fails only when a non-advisory step fails; the catalogue freshness step is advisory.
52
+ * job fails only when a non-advisory step fails; the catalogue freshness step is advisory unless the
53
+ * pipeline rebuilt the catalogues on this run (catalogue-rebuild.sh, M-ADR 0220 §4.4).
43
54
  */
44
55
  import { dag, Container, Directory, File, object, func, ReturnType } from ${DAGGER_SDK}
45
56
 
@@ -102,12 +113,14 @@ export class MethodValidation {
102
113
  maintainVersion: string = "${MAINTAIN_PIN}",
103
114
  verifyTarball?: File,
104
115
  maintainTarball?: File,
116
+ catalogueRebuild: boolean = false,
105
117
  ): Promise<string> {
106
118
  const base = this.toolchain(verifyVersion, maintainVersion, verifyTarball, maintainTarball)
107
119
  .withMountedDirectory("/src", source)
108
120
  .withWorkdir("/src")
109
121
  const versions = (await base.withExec(["sh", "-c", "sdd-verify --version; sdd-maintain --version"]).stdout()).split("\n")
110
- const plan = JSON.parse(await base.withExec(["sdd-verify", "validate", "--list"]).stdout()) as {
122
+ const listArgs = ["sdd-verify", "validate", "--list"]
123
+ const plan = JSON.parse(await base.withExec(catalogueRebuild ? [...listArgs, "--catalogue-rebuild"] : listArgs).stdout()) as {
111
124
  profile: string
112
125
  steps: PlanStep[]
113
126
  }
@@ -130,8 +143,66 @@ export class MethodValidation {
130
143
  if (failed > 0) throw new Error(report)
131
144
  return report
132
145
  }
146
+
147
+ /**
148
+ * Rebuild the committed catalogues from the specifications (M-ADR 0220 §4.4) and return the whole
149
+ * new catalogues directory: every file sdd-maintain catalogue build produces, plus every file
150
+ * already there except a .csv the build no longer produces, the freshness check's own rule. The
151
+ * host wrapper's catalogue-rebuild.sh exports it over catalogues/ and pushes only if it changed.
152
+ */
153
+ @func()
154
+ catalogueRebuild(source: Directory, maintainVersion: string = "${MAINTAIN_PIN}", maintainTarball?: File): Directory {
155
+ return this.toolchain(undefined, maintainVersion, undefined, maintainTarball)
156
+ .withMountedDirectory("/src", source)
157
+ .withWorkdir("/src")
158
+ .withExec(["sdd-maintain", "catalogue", "build", "--sdd-path", ".", "--out", "/rebuilt"])
159
+ .withExec([
160
+ "sh",
161
+ "-c",
162
+ "mkdir -p /next && if [ -d catalogues ]; then cp -a catalogues/. /next/; fi && " +
163
+ "for f in /next/*.csv; do [ -e \"$f\" ] || continue; [ -e \"/rebuilt/$(basename \"$f\")\" ] || rm \"$f\"; done && " +
164
+ "cp -a /rebuilt/. /next/",
165
+ ])
166
+ .directory("/next")
167
+ }
133
168
  }
134
169
  `;
170
+ // Run by every host wrapper, and only where the adopter has provided the push credential. Plain
171
+ // strings, not a template: the shell's own \${...} must reach the file as written.
172
+ const REBUILD_SH = [
173
+ "#!/bin/sh",
174
+ "# Pipeline catalogue rebuild (M-ADR 0220 §4.4). Written by sdd-cli sync and method-managed, like the",
175
+ "# rest of this module. The host wrapper runs it only where the adopter has provided the push",
176
+ "# credential, and sets:",
177
+ "# SDD_REBUILD_BRANCH the branch this pipeline builds, which the rebuild is pushed to",
178
+ "# SDD_REBUILD_PUSH_URL the repository's URL, carrying the credential",
179
+ "#",
180
+ "# A rebuild that changes nothing pushes nothing, so the push cannot loop: the pipeline it starts",
181
+ "# rebuilds the same catalogues, finds no change, and runs Method Validation.",
182
+ "set -eu",
183
+ ': "${SDD_REBUILD_BRANCH:?the host wrapper sets it}" "${SDD_REBUILD_PUSH_URL:?the host wrapper sets it}"',
184
+ "",
185
+ "# A merge the pipeline made (a merged-results or merge-with-target build) is not the branch's head,",
186
+ "# and pushing it would put that merge on the branch. The branch's own pipeline rebuilds instead.",
187
+ 'if [ "$(git rev-list --parents -n 1 HEAD | wc -w)" -gt 2 ]; then',
188
+ ' echo "Catalogue rebuild: skipped, HEAD is a merge commit this pipeline made; the branch pipeline rebuilds."',
189
+ " exit 0",
190
+ "fi",
191
+ "",
192
+ "dagger call -m " + MODULE_DIR + " catalogue-rebuild --source . export --path catalogues --wipe >/dev/null",
193
+ 'touch "$(git rev-parse --git-dir)/' + REBUILD_RAN_MARKER + '"',
194
+ "git add -A catalogues",
195
+ "if git diff --cached --quiet; then",
196
+ ' echo "Catalogue rebuild: the committed catalogues already match the specifications; nothing to push."',
197
+ " exit 0",
198
+ "fi",
199
+ "git diff --cached --stat",
200
+ 'git -c user.name="sdd-method catalogue rebuild" -c user.email="catalogue-rebuild@sdd-method.invalid" \\',
201
+ ' commit -q -m "Rebuild catalogues from the specifications (Method Validation, M-ADR 0220 §4.4)"',
202
+ 'git push -q "$SDD_REBUILD_PUSH_URL" "HEAD:refs/heads/$SDD_REBUILD_BRANCH"',
203
+ 'echo "Catalogue rebuild: pushed $(git rev-parse --short HEAD) to $SDD_REBUILD_BRANCH; Method Validation runs on it there too."',
204
+ "",
205
+ ].join("\n");
135
206
  const DAGGER_JSON = `{
136
207
  "name": "method-validation",
137
208
  "engineVersion": "v${DAGGER_VERSION}",
@@ -183,6 +254,7 @@ const GITIGNORE = `/sdk
183
254
  export function renderModuleFiles(pins = { verify: getVerifyCliVersion(), maintain: getMaintainCliVersion() }) {
184
255
  return [
185
256
  [".gitignore", GITIGNORE],
257
+ ["catalogue-rebuild.sh", REBUILD_SH],
186
258
  ["dagger.json", DAGGER_JSON],
187
259
  ["package.json", PACKAGE_JSON],
188
260
  ["src/index.ts", INDEX_TS.split(VERIFY_PIN).join(pins.verify).split(MAINTAIN_PIN).join(pins.maintain).split(DAGGER_SDK).join('"@dagger.io' + '/dagger"')],