@sdd-method/maintain-cli 0.2.0 → 0.4.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 (85) hide show
  1. package/NOTICE +1 -1
  2. package/dist/lib/briefing/render.js +1 -2
  3. package/dist/lib/briefing/variables.js +1 -2
  4. package/dist/lib/catalogue/aggregate.js +38 -39
  5. package/dist/lib/catalogue/application-archetype/expectations.js +1 -1
  6. package/dist/lib/catalogue/archetype/wireframe.js +1 -1
  7. package/dist/lib/catalogue/build.js +17 -23
  8. package/dist/lib/catalogue/builders/application-conformance-coverage.js +1 -1
  9. package/dist/lib/catalogue/builders/c4-components.js +4 -5
  10. package/dist/lib/catalogue/builders/c4-containers.js +4 -4
  11. package/dist/lib/catalogue/builders/c4-external-systems.js +1 -1
  12. package/dist/lib/catalogue/builders/c4-l1-candidates.js +2 -2
  13. package/dist/lib/catalogue/builders/capabilities.js +2 -2
  14. package/dist/lib/catalogue/builders/capability-edges.js +4 -4
  15. package/dist/lib/catalogue/builders/capability-spec-walker.js +3 -3
  16. package/dist/lib/catalogue/builders/container-dependency.js +19 -20
  17. package/dist/lib/catalogue/builders/contracts.js +5 -6
  18. package/dist/lib/catalogue/builders/data-store-mapping.js +4 -4
  19. package/dist/lib/catalogue/builders/edge-label-merge.js +2 -2
  20. package/dist/lib/catalogue/builders/entities-manifest.js +1 -1
  21. package/dist/lib/catalogue/builders/entities-mermaid.js +3 -3
  22. package/dist/lib/catalogue/builders/entity-slug.js +11 -12
  23. package/dist/lib/catalogue/builders/event-catalog-walker.js +3 -3
  24. package/dist/lib/catalogue/builders/evidence-class.js +2 -2
  25. package/dist/lib/catalogue/builders/external-system-exemption.js +1 -1
  26. package/dist/lib/catalogue/builders/feature-contract.js +1 -3
  27. package/dist/lib/catalogue/builders/feature-grounding.js +1 -2
  28. package/dist/lib/catalogue/builders/feature-groups.js +0 -1
  29. package/dist/lib/catalogue/builders/feature-subscription-channels.js +2 -3
  30. package/dist/lib/catalogue/builders/feature-tree-walker.js +1 -1
  31. package/dist/lib/catalogue/builders/features.js +3 -4
  32. package/dist/lib/catalogue/builders/glossary-xref.js +1 -1
  33. package/dist/lib/catalogue/builders/persona-c4system.js +7 -7
  34. package/dist/lib/catalogue/builders/persona-container.js +14 -14
  35. package/dist/lib/catalogue/builders/personas.js +1 -2
  36. package/dist/lib/catalogue/builders/product-platform-dependencies.js +2 -2
  37. package/dist/lib/catalogue/builders/quality-scores.js +1 -1
  38. package/dist/lib/catalogue/builders/repositories.js +2 -2
  39. package/dist/lib/catalogue/builders/screen-tree.js +2 -2
  40. package/dist/lib/catalogue/builders/screens.js +3 -4
  41. package/dist/lib/catalogue/builders/system-external-dependency.js +7 -7
  42. package/dist/lib/catalogue/canonical-schema.yaml +23 -23
  43. package/dist/lib/catalogue/contract-path-resolution.js +3 -3
  44. package/dist/lib/catalogue/coverage-predicates.js +4 -4
  45. package/dist/lib/catalogue/coverage-run.js +1 -1
  46. package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
  47. package/dist/lib/catalogue/ext-id.js +4 -4
  48. package/dist/lib/catalogue/feature-grounding-aliases-loader.js +1 -1
  49. package/dist/lib/catalogue/finding-identity.js +3 -3
  50. package/dist/lib/catalogue/freshness.js +1 -1
  51. package/dist/lib/catalogue/manifest-loader.js +2 -2
  52. package/dist/lib/catalogue/scaffold-delta.js +1 -1
  53. package/dist/lib/catalogue/schema-compat.js +1 -2
  54. package/dist/lib/catalogue/scores-coverage.js +1 -1
  55. package/dist/lib/catalogue/screen-validation-loader.js +1 -1
  56. package/dist/lib/catalogue/service-contract-aliases-loader.js +1 -1
  57. package/dist/lib/catalogue/staleness-verdicts.js +2 -2
  58. package/dist/lib/catalogue/traceability-loader.js +2 -3
  59. package/dist/lib/ci-binding/index.js +147 -35
  60. package/dist/lib/ci-binding/module.js +74 -2
  61. package/dist/lib/hooks-seed/entries.js +43 -21
  62. package/dist/lib/hooks-seed/index.js +1 -1
  63. package/dist/lib/init/ci-workflow.js +26 -2
  64. package/dist/lib/lock-file.js +11 -1
  65. package/dist/lib/pack/builder.js +56 -4
  66. package/dist/lib/pack/index.js +1 -0
  67. package/dist/lib/pack/publish-transform.js +1 -1
  68. package/dist/lib/pack/reference-closure.js +3 -3
  69. package/dist/lib/package-version.js +1 -1
  70. package/dist/lib/snapshot/manifest.js +1 -1
  71. package/dist/lib/sync/adr-transitions.js +1 -1
  72. package/dist/lib/sync/diff.js +1 -1
  73. package/dist/lib/sync/index.js +34 -5
  74. package/dist/lib/sync/settings-merge.js +1 -1
  75. package/dist/lib/validate/index.js +1 -1
  76. package/dist/lib/validate/plan.js +7 -4
  77. package/dist/lib/validate/yaml-parse.js +1 -1
  78. package/dist/maintain-program.js +1 -1
  79. package/dist/maintain-version.json +2 -2
  80. package/dist/verbs/pack-build.js +22 -0
  81. package/dist/verbs/sync.js +18 -1
  82. package/dist/verbs/validate.js +6 -1
  83. package/dist/verify-program.js +1 -1
  84. package/dist/verify-version.json +2 -2
  85. package/package.json +2 -6
@@ -8,14 +8,21 @@
8
8
  * wrote is pristine and re-renders with no override, and only an edited copy needs --allow-overwrite.
9
9
  * The binding is remembered in the lock too (`ci_binding:`, absent meaning github), so an estate
10
10
  * chooses it once.
11
+ *
12
+ * `local` is the binding with no host: the module is written and nothing calls it but people, for an
13
+ * estate that validates on its own machines only (the module is run with `dagger call` there). Sync
14
+ * removes the wrapper this tool wrote for the previous binding when its block still hashes to the
15
+ * lock's digest; an edited one is drift, and a file without markers is the adopter's and stays.
11
16
  */
12
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
17
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
18
  import { createHash } from "node:crypto";
14
19
  import { dirname, join } from "node:path";
15
20
  import { BLOCK_BEGIN, BLOCK_END, WORKFLOW_REL_PATH, renderMethodManagedWorkflowBlock, renderMethodValidationWorkflow, } from "../init/ci-workflow.js";
16
21
  import { detectBlockDrift, writeMarkedBlockFile } from "../sync/yaml-block-merge.js";
17
- import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, moduleSha256, renderModuleFiles } from "./module.js";
18
- export const CI_BINDINGS = ["github", "gitlab", "jenkins"];
22
+ import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, REBUILD_FLAG, REBUILD_SCRIPT, moduleSha256, renderModuleFiles } from "./module.js";
23
+ /** The bindings that render a wrapper for a CI host. */
24
+ export const HOST_BINDINGS = ["github", "gitlab", "jenkins"];
25
+ export const CI_BINDINGS = [...HOST_BINDINGS, "local"];
19
26
  /** M-ADR 0219 §8.2 (ruled 2026-10-16): what every SDD has before it chooses. */
20
27
  export const DEFAULT_CI_BINDING = "github";
21
28
  /**
@@ -31,9 +38,24 @@ export function parseCiRunner(s) {
31
38
  export function isCiBinding(s) {
32
39
  return CI_BINDINGS.includes(s);
33
40
  }
41
+ export function isHostBinding(s) {
42
+ return HOST_BINDINGS.includes(s);
43
+ }
34
44
  const HASH = { begin: BLOCK_BEGIN, end: BLOCK_END };
35
45
  // Groovy has no '#' comments.
36
46
  const SLASH = { begin: "// method-managed: begin", end: "// method-managed: end" };
47
+ // How each host's adopter switches the catalogue rebuild on (M-ADR 0220 §4.4): the variable the method names.
48
+ const REBUILD_SETUP = {
49
+ "#": [
50
+ "set the masked (not protected) CI variable",
51
+ "SDD_CATALOGUE_PUSH_TOKEN to a project or group access token with write_repository.",
52
+ ],
53
+ "//": [
54
+ "set the job's environment variable",
55
+ "SDD_CATALOGUE_PUSH_CREDENTIAL to the ID of a username-and-password credential (a token",
56
+ "with write) for the repository's https remote.",
57
+ ],
58
+ };
37
59
  function header(comment, profile) {
38
60
  return [
39
61
  `${comment} Method Validation per M-ADR 0129, run by the method's Dagger module (M-ADR 0219).`,
@@ -47,6 +69,11 @@ function header(comment, profile) {
47
69
  comment,
48
70
  `${comment} Run it locally:`,
49
71
  `${comment} ${MODULE_CALL}`,
72
+ comment,
73
+ `${comment} Catalogue rebuild (M-ADR 0220 §4.4), off until you configure it:`,
74
+ ...REBUILD_SETUP[comment].map((l) => `${comment} ${l}`),
75
+ `${comment} The job then rebuilds the catalogues and pushes them to the branch it is`,
76
+ `${comment} building (never a fork's), and the freshness step gates.`,
50
77
  ];
51
78
  }
52
79
  // GitLab: a Docker-in-Docker service, the shape GitLab documents for Dagger. The runner must allow
@@ -71,10 +98,16 @@ function gitlabBlock(profile, runner) {
71
98
  ' - if: $CI_PIPELINE_SOURCE == "merge_request_event"',
72
99
  " - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
73
100
  " before_script:",
74
- " - apk add --no-cache curl",
101
+ " - apk add --no-cache curl git",
75
102
  ` - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin DAGGER_VERSION=${DAGGER_VERSION} sh`,
76
103
  " script:",
77
- ` - ${MODULE_CALL}`,
104
+ " - |",
105
+ ' if [ -n "${SDD_CATALOGUE_PUSH_TOKEN:-}" ] && [ "${CI_MERGE_REQUEST_SOURCE_PROJECT_ID:-$CI_PROJECT_ID}" = "$CI_PROJECT_ID" ]; then',
106
+ ' SDD_REBUILD_BRANCH="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:-$CI_COMMIT_BRANCH}" \\',
107
+ ' SDD_REBUILD_PUSH_URL="${CI_SERVER_PROTOCOL}://oauth2:${SDD_CATALOGUE_PUSH_TOKEN}@${CI_SERVER_URL#*://}/${CI_PROJECT_PATH}.git" \\',
108
+ ` sh ${REBUILD_SCRIPT}`,
109
+ " fi",
110
+ ` - ${MODULE_CALL} ${REBUILD_FLAG}`,
78
111
  HASH.end,
79
112
  ].join("\n");
80
113
  }
@@ -92,7 +125,18 @@ function jenkinsBlock(profile, runner) {
92
125
  " stage('Method Validation') {",
93
126
  " steps {",
94
127
  ` 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}'`,
128
+ " script {",
129
+ " if (env.SDD_CATALOGUE_PUSH_CREDENTIAL && !env.CHANGE_FORK) {",
130
+ " withCredentials([usernamePassword(credentialsId: env.SDD_CATALOGUE_PUSH_CREDENTIAL, usernameVariable: 'SDD_PUSH_USER', passwordVariable: 'SDD_PUSH_TOKEN')]) {",
131
+ " sh '''",
132
+ ' export SDD_REBUILD_BRANCH="${CHANGE_BRANCH:-${BRANCH_NAME:-${GIT_BRANCH#origin/}}}"',
133
+ ' export SDD_REBUILD_PUSH_URL="$(git config --get remote.origin.url | sed "s#^https://#https://${SDD_PUSH_USER}:${SDD_PUSH_TOKEN}@#")"',
134
+ ` PATH="$WORKSPACE@tmp/dagger:$PATH" sh ${REBUILD_SCRIPT}`,
135
+ " '''",
136
+ " }",
137
+ " }",
138
+ " }",
139
+ ` sh 'PATH="$WORKSPACE@tmp/dagger:$PATH" ${MODULE_CALL} ${REBUILD_FLAG}'`,
96
140
  " }",
97
141
  " }",
98
142
  " }",
@@ -134,8 +178,41 @@ export function blockSha256(text, markers) {
134
178
  export async function syncCiBinding(o) {
135
179
  const failures = [];
136
180
  const log = [];
181
+ const was = o.prevLock?.ciBinding;
182
+ const sameBinding = (was ?? DEFAULT_CI_BINDING) === o.binding;
183
+ if (o.binding === "local") {
184
+ // No host. A switch removes the wrapper this tool wrote for the previous binding, but only when its
185
+ // block still hashes to the lock's digest: an edited block is drift, a file without markers is the
186
+ // adopter's and stays.
187
+ if (o.runner !== undefined && o.runner.length > 0) {
188
+ failures.push("--ci-runner names where a CI host runs the job; the local binding has no host. Drop --ci-runner (or choose a host binding).");
189
+ }
190
+ let removal = null;
191
+ const previous = sameBinding ? null : was === undefined ? DEFAULT_CI_BINDING : isHostBinding(was) ? was : null;
192
+ if (previous !== null) {
193
+ const prevHost = HOST_WRAPPERS[previous];
194
+ const abs = join(o.targetRepo, prevHost.relPath);
195
+ const text = await tryRead(abs);
196
+ const sha = blockSha256(text, prevHost.markers);
197
+ if (text !== null && sha === null) {
198
+ log.push(`${prevHost.relPath} has no method-managed markers, so it is the adopter's: left in place.`);
199
+ }
200
+ else if (text !== null && (sha === o.prevLock?.ciWorkflowBlockSha256 || o.allowDrift)) {
201
+ removal = { abs, rel: prevHost.relPath, text, host: prevHost };
202
+ }
203
+ else if (text !== null) {
204
+ failures.push(`${prevHost.relPath}: the method-managed block was edited since the last sync (it no longer matches the lock's digest), so switching to the local binding will not remove it. Re-run with --allow-overwrite to remove the block; adopter content outside the markers is kept.`);
205
+ }
206
+ }
207
+ const mod = await checkModule(o, failures, log);
208
+ if (o.checkOnly || failures.length > 0)
209
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaOnDisk };
210
+ await writeModule(o, mod, log, "; the local binding calls it from no CI host");
211
+ if (removal !== null)
212
+ await removeWrapper(removal, o.profile, log);
213
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaRendered };
214
+ }
137
215
  const host = HOST_WRAPPERS[o.binding];
138
- const sameBinding = (o.prevLock?.ciBinding ?? DEFAULT_CI_BINDING) === o.binding;
139
216
  // 1. The host wrapper.
140
217
  const wrapperAbs = join(o.targetRepo, host.relPath);
141
218
  const existing = await tryRead(wrapperAbs);
@@ -153,49 +230,84 @@ export async function syncCiBinding(o) {
153
230
  else if (markerless && !o.allowOverwrite) {
154
231
  failures.push(`${host.relPath} exists without method-managed markers — refusing to overwrite adopter content. Re-run with --allow-overwrite to replace the file.`);
155
232
  }
156
- // 2. The module: every file it renders, compared as one set.
233
+ // 2. The module.
234
+ const mod = await checkModule(o, failures, log);
235
+ if (o.checkOnly || failures.length > 0) {
236
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaOnDisk };
237
+ }
238
+ const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile, o.runner) : block, host.markers, o.allowOverwrite);
239
+ if (written.changed)
240
+ log.push(`${existing === null ? "Wrote" : "Refreshed"} ${host.relPath}.`);
241
+ await writeModule(o, mod, log, "");
242
+ // A binding switch between hosts leaves the previous host's file alone: it is the adopter's to delete
243
+ // once the new host runs, and deleting it here would remove a job some branch protection may still
244
+ // require. (From local there is no previous host file.)
245
+ if (!sameBinding && (was === undefined || isHostBinding(was))) {
246
+ const prevHost = HOST_WRAPPERS[was ?? DEFAULT_CI_BINDING];
247
+ if ((await tryRead(join(o.targetRepo, prevHost.relPath))) !== null) {
248
+ log.push(`CI binding is now ${o.binding}; ${prevHost.relPath} (the previous binding) is left in place. Delete it once ${host.relPath} runs.`);
249
+ }
250
+ }
251
+ return {
252
+ failures,
253
+ log,
254
+ wrapperSha: blockSha256(await tryRead(wrapperAbs), host.markers),
255
+ moduleSha: mod.shaRendered,
256
+ };
257
+ }
258
+ /**
259
+ * The module: every file it renders, compared as one set. Pristine when the files on disk hash to the
260
+ * lock's digest. The render may list a file the last sync never wrote (the generator gained one): it is
261
+ * absent, not edited, so the digest over the files present decides. A file the adopter deleted still
262
+ * changes that digest, so it is still drift.
263
+ */
264
+ async function checkModule(o, failures, log) {
157
265
  const rendered = renderModuleFiles();
158
266
  const onDisk = [];
159
267
  for (const [rel] of rendered)
160
268
  onDisk.push([rel, await tryRead(join(o.targetRepo, MODULE_DIR, rel))]);
161
269
  const present = onDisk.some(([, c]) => c !== null);
162
270
  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;
271
+ const lockSha = o.prevLock?.ciModuleSha256;
272
+ const modulePristine = lockSha !== undefined &&
273
+ (moduleSha256(onDisk) === lockSha || moduleSha256(onDisk.filter(([, c]) => c !== null)) === lockSha);
164
274
  if (present && !matches && modulePristine) {
165
275
  log.push(`${MODULE_DIR}/: unmodified since the last sync (digest matches the lock); re-rendering it for this sdd-cli.`);
166
276
  }
167
277
  if (present && !matches && !modulePristine && !o.allowDrift) {
168
278
  failures.push(`${MODULE_DIR}/: the method-managed module differs from what this sdd-cli renders. Either the generator changed since the last sync (expected after a CLI upgrade) or a file was hand-edited. Re-run with --allow-overwrite to re-render it.`);
169
279
  }
170
- if (o.checkOnly || failures.length > 0) {
171
- return { failures, log, wrapperSha: null, moduleSha: moduleSha256(onDisk) };
280
+ return { rendered, present, matches, shaOnDisk: moduleSha256(onDisk), shaRendered: moduleSha256(rendered) };
281
+ }
282
+ async function writeModule(o, mod, log, note) {
283
+ if (mod.matches)
284
+ return;
285
+ for (const [rel, content] of mod.rendered) {
286
+ const abs = join(o.targetRepo, MODULE_DIR, rel);
287
+ await mkdir(dirname(abs), { recursive: true });
288
+ await writeFile(abs, content, "utf8");
172
289
  }
173
- const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile, o.runner) : block, host.markers, o.allowOverwrite);
174
- if (written.changed)
175
- log.push(`${existing === null ? "Wrote" : "Refreshed"} ${host.relPath}.`);
176
- if (!matches) {
177
- for (const [rel, content] of rendered) {
178
- const abs = join(o.targetRepo, MODULE_DIR, rel);
179
- await mkdir(dirname(abs), { recursive: true });
180
- await writeFile(abs, content, "utf8");
181
- }
182
- log.push(`${present ? "Refreshed" : "Wrote"} ${MODULE_DIR}/ (the Method Validation module, M-ADR 0219).`);
290
+ log.push(`${mod.present ? "Refreshed" : "Wrote"} ${MODULE_DIR}/ (the Method Validation module, M-ADR 0219)${note}.`);
291
+ }
292
+ /**
293
+ * Remove a wrapper's method-managed block. The file goes with it when what remains is this tool's own
294
+ * template text (or nothing), since then no line of it is the adopter's; otherwise the rest is kept.
295
+ */
296
+ async function removeWrapper(r, profile, log) {
297
+ const cut = (text) => {
298
+ const b = text.indexOf(r.host.markers.begin);
299
+ const e = text.indexOf(r.host.markers.end, b) + r.host.markers.end.length;
300
+ return (text.slice(0, b) + text.slice(e)).replace(/^\n+/, "");
301
+ };
302
+ const rest = cut(r.text);
303
+ if (rest.trim() === "" || rest.trim() === cut(r.host.file(profile)).trim()) {
304
+ await rm(r.abs);
305
+ log.push(`Removed ${r.rel}: the local binding has no CI host, and the file held only this tool's wrapper.`);
183
306
  }
184
- // A binding switch leaves the previous host's file alone: it is the adopter's to delete once the
185
- // new host runs, and deleting it here would remove a job some branch protection may still require.
186
- if (!sameBinding) {
187
- const was = o.prevLock?.ciBinding;
188
- const previous = HOST_WRAPPERS[was !== undefined && isCiBinding(was) ? was : DEFAULT_CI_BINDING];
189
- if ((await tryRead(join(o.targetRepo, previous.relPath))) !== null) {
190
- log.push(`CI binding is now ${o.binding}; ${previous.relPath} (the previous binding) is left in place. Delete it once ${host.relPath} runs.`);
191
- }
307
+ else {
308
+ await writeFile(r.abs, rest, "utf8");
309
+ log.push(`Removed the method-managed block from ${r.rel}; the adopter content outside it is kept.`);
192
310
  }
193
- return {
194
- failures,
195
- log,
196
- wrapperSha: blockSha256(await tryRead(wrapperAbs), host.markers),
197
- moduleSha: moduleSha256(rendered),
198
- };
199
311
  }
200
312
  async function tryRead(path) {
201
313
  try {
@@ -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 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"')],
@@ -24,40 +24,62 @@ export function buildMethodGuardPreToolUse() {
24
24
  };
25
25
  }
26
26
  const SESSION_START_VALIDATE_ARGS = ' validate --target-repo "${CLAUDE_PROJECT_DIR:-$(pwd)}" --validator adr-structure 2>&1 | head -20';
27
- const VERIFY_CLI_NPX = /^npx --yes @sdd-method\/verify-cli@(\S+)$/;
27
+ /** The verify persona's npm package, unless the lock names a rescoped one (sync --cli-package). */
28
+ export const DEFAULT_VERIFY_CLI_PACKAGE = "@sdd-method/verify-cli";
29
+ /** One npm package name, scoped or not, the shape `npm pack` and `npx` accept. */
30
+ export const NPM_PACKAGE_NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
31
+ export const CLI_SOURCES = ["registry", "installed"];
32
+ export const DEFAULT_CLI_SOURCE = { source: "registry" };
33
+ function registryLauncher(version, pkg) {
34
+ return `npx --yes ${pkg}@${version}${SESSION_START_VALIDATE_ARGS}`;
35
+ }
36
+ function installedLauncher(version) {
37
+ return (`v=$(sdd-verify --version 2>/dev/null); [ "$v" = "${version}" ] || ` +
38
+ `echo "[method] sdd-verify \${v:-is not installed}; this SDD pins verify-cli ${version}: ` +
39
+ `pull your CLI release repository and run ./install.sh ${version}"; ` +
40
+ `if [ -n "$v" ]; then sdd-verify${SESSION_START_VALIDATE_ARGS}; fi`);
41
+ }
28
42
  /**
29
43
  * The session-start check runs the VERIFY persona's CLI (M-ADR 0218, 0220), not sdd-cli, which is
30
- * the administrator's: validating is routine author work. It runs through npx at the exact
31
- * verify-cli version this sdd-cli releases with, the version the managed CI workflow installs, so a
32
- * local pass comes from the same verifier CI uses and nothing needs installing globally.
44
+ * the administrator's: validating is routine author work. It runs at the exact verify-cli version
45
+ * this sdd-cli releases with, the version the managed CI workflow installs, so a local pass comes
46
+ * from the same verifier CI uses.
33
47
  */
34
- export function sessionStartValidateCommand(verifyVersion = getVerifyCliVersion()) {
35
- return withSentinel(`npx --yes @sdd-method/verify-cli@${verifyVersion}${SESSION_START_VALIDATE_ARGS}`);
48
+ export function sessionStartValidateCommand(verifyVersion = getVerifyCliVersion(), cli = DEFAULT_CLI_SOURCE) {
49
+ const body = cli.source === "installed"
50
+ ? installedLauncher(verifyVersion)
51
+ : registryLauncher(verifyVersion, cli.package ?? DEFAULT_VERIFY_CLI_PACKAGE);
52
+ return withSentinel(body);
36
53
  }
54
+ const REGISTRY_FORM = /^npx --yes ((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\S+) validate /;
55
+ const INSTALLED_FORM = /^v=\$\(sdd-verify --version 2>\/dev\/null\); \[ "\$v" = "([^"]+)" \]/;
37
56
  /**
38
- * Whether a session-start command is one this tool rendered, at any version: the sdd-cli form it
39
- * wrote until the hook moved to verify-cli, or the npx form at any exact version. Sync replaces its
40
- * own earlier render without counting it as drift; otherwise every release that moves the pinned
41
- * version would refuse the settings sync until --allow-overwrite, the flag that also rewrites keys
42
- * the adopter owns. Anything else under the sentinel (an edited command) is still drift.
57
+ * Whether a session-start command is one this tool rendered, at any exact version and from either
58
+ * source: the sdd-cli form it wrote until the hook moved to verify-cli, the npx form under any
59
+ * package name, or the installed form. Sync replaces its own earlier render without counting it as
60
+ * drift, so a release that moves the pin, or a switch of source, needs no --allow-overwrite (the
61
+ * flag that also rewrites keys the adopter owns). The candidate is re-rendered from the version it
62
+ * names and must match exactly, so anything edited under the sentinel is still drift.
43
63
  */
44
64
  export function isOwnSessionStartRender(command) {
45
65
  const body = command.replace(METHOD_MANAGED_SENTINEL, "").trimEnd();
46
- if (!body.endsWith(SESSION_START_VALIDATE_ARGS))
47
- return false;
48
- const launcher = body.slice(0, body.length - SESSION_START_VALIDATE_ARGS.length);
49
- if (launcher === "sdd-cli")
66
+ if (body === `sdd-cli${SESSION_START_VALIDATE_ARGS}`)
50
67
  return true;
51
- const npx = VERIFY_CLI_NPX.exec(launcher);
52
- return npx !== null && EXACT_VERSION.test(npx[1]);
68
+ const npx = REGISTRY_FORM.exec(body);
69
+ if (npx && EXACT_VERSION.test(npx[2]))
70
+ return body === registryLauncher(npx[2], npx[1]);
71
+ const installed = INSTALLED_FORM.exec(body);
72
+ if (installed && EXACT_VERSION.test(installed[1]))
73
+ return body === installedLauncher(installed[1]);
74
+ return false;
53
75
  }
54
- export function buildSessionStartValidate() {
76
+ export function buildSessionStartValidate(cli = DEFAULT_CLI_SOURCE) {
55
77
  return {
56
78
  matcher: "startup",
57
79
  hooks: [
58
80
  {
59
81
  type: "command",
60
- command: sessionStartValidateCommand(),
82
+ command: sessionStartValidateCommand(getVerifyCliVersion(), cli),
61
83
  },
62
84
  ],
63
85
  };
@@ -66,10 +88,10 @@ export function buildSessionStartValidate() {
66
88
  * Full method-managed hooks block. When extending, add a new event
67
89
  * name to the returned object and a corresponding builder above.
68
90
  */
69
- export function buildMethodManagedHooks() {
91
+ export function buildMethodManagedHooks(cli = DEFAULT_CLI_SOURCE) {
70
92
  const hooks = {
71
93
  PreToolUse: [buildMethodGuardPreToolUse()],
72
- SessionStart: [buildSessionStartValidate()],
94
+ SessionStart: [buildSessionStartValidate(cli)],
73
95
  };
74
96
  return hooks;
75
97
  }
@@ -9,6 +9,6 @@
9
9
  * when the repo runs the claude-code harness (the default).
10
10
  */
11
11
  export { METHOD_MANAGED_SENTINEL, isMethodManagedMatcher } from "./types.js";
12
- export { buildMethodManagedHooks, isOwnSessionStartRender, sessionStartValidateCommand, } from "./entries.js";
12
+ export { buildMethodManagedHooks, CLI_SOURCES, DEFAULT_CLI_SOURCE, DEFAULT_VERIFY_CLI_PACKAGE, isOwnSessionStartRender, NPM_PACKAGE_NAME, sessionStartValidateCommand, } from "./entries.js";
13
13
  export { detectHooksDrift, normaliseOwnHookRenders, upsertMethodManagedHooks, } from "./merge.js";
14
14
  //# sourceMappingURL=index.js.map
@@ -8,7 +8,7 @@
8
8
  * block-marker pair so the sync path can preserve adopter-authored
9
9
  * content outside the block.
10
10
  */
11
- import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR } from "../ci-binding/module.js";
11
+ import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, REBUILD_FLAG, REBUILD_SCRIPT } from "../ci-binding/module.js";
12
12
  export const WORKFLOW_REL_PATH = ".github/workflows/method-validation.yml";
13
13
  export const BLOCK_BEGIN = "# method-managed: begin";
14
14
  export const BLOCK_END = "# method-managed: end";
@@ -56,6 +56,13 @@ function buildMethodManagedBlock(profile, runner) {
56
56
  "# released with the sdd-cli that last synced this repository. The catalogue",
57
57
  "# freshness step (@sdd-method/maintain-cli, pinned the same way) is advisory.",
58
58
  "#",
59
+ "# Catalogue rebuild (M-ADR 0220 §4.4), off until you configure it: set the",
60
+ "# repository variable SDD_CATALOGUE_APP_ID and the secret",
61
+ "# SDD_CATALOGUE_APP_PRIVATE_KEY for a GitHub App that can write contents, and",
62
+ "# the job rebuilds the catalogues and pushes them to the branch it is building",
63
+ "# (a pull request's own branch, never a fork's); the freshness step then gates.",
64
+ "# An App token, because pushes made with the default token start no workflow.",
65
+ "#",
59
66
  "# Run it locally (needs a container engine):",
60
67
  `# ${MODULE_CALL}`,
61
68
  "",
@@ -74,11 +81,28 @@ function buildMethodManagedBlock(profile, runner) {
74
81
  " name: Method Validation",
75
82
  ` runs-on: ${runsOn(runner)}`,
76
83
  " steps:",
84
+ " - name: Catalogue rebuild token",
85
+ " id: catalogue-token",
86
+ " if: vars.SDD_CATALOGUE_APP_ID != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)",
87
+ " uses: actions/create-github-app-token@v1",
88
+ " with:",
89
+ " app-id: ${{ vars.SDD_CATALOGUE_APP_ID }}",
90
+ " private-key: ${{ secrets.SDD_CATALOGUE_APP_PRIVATE_KEY }}",
77
91
  " - uses: actions/checkout@v4",
92
+ " with:",
93
+ // The branch itself when rebuilding, so the rebuild lands on it; otherwise the event's own ref.
94
+ " ref: ${{ steps.catalogue-token.outputs.token != '' && (github.head_ref || github.ref_name) || '' }}",
95
+ " persist-credentials: false",
78
96
  " - name: Install Dagger",
79
97
  ` run: curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$RUNNER_TEMP/dagger" DAGGER_VERSION=${DAGGER_VERSION} sh && echo "$RUNNER_TEMP/dagger" >> "$GITHUB_PATH"`,
98
+ " - name: Rebuild catalogues",
99
+ " if: steps.catalogue-token.outputs.token != ''",
100
+ " env:",
101
+ " SDD_REBUILD_TOKEN: ${{ steps.catalogue-token.outputs.token }}",
102
+ " SDD_REBUILD_BRANCH: ${{ github.head_ref || github.ref_name }}",
103
+ ` run: SDD_REBUILD_PUSH_URL="https://x-access-token:$SDD_REBUILD_TOKEN@\${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" sh ${REBUILD_SCRIPT}`,
80
104
  " - name: Method Validation",
81
- ` run: ${MODULE_CALL}`,
105
+ ` run: ${MODULE_CALL} ${REBUILD_FLAG}`,
82
106
  BLOCK_END,
83
107
  ].join("\n");
84
108
  }
@@ -66,6 +66,8 @@ export function serialiseLockFile(lock) {
66
66
  ...(lock.ciRunner !== undefined ? [`ci_runner: "${lock.ciRunner}"`] : []),
67
67
  ...(lock.ciWorkflowBlockSha256 !== undefined ? [`ci_workflow_block_sha256: "${lock.ciWorkflowBlockSha256}"`] : []),
68
68
  ...(lock.ciModuleSha256 !== undefined ? [`ci_module_sha256: "${lock.ciModuleSha256}"`] : []),
69
+ ...(lock.cliSource !== undefined ? [`cli_source: ${lock.cliSource}`] : []),
70
+ ...(lock.cliPackage !== undefined ? [`cli_package: "${lock.cliPackage}"`] : []),
69
71
  "bundles:",
70
72
  ];
71
73
  for (const b of lock.bundles) {
@@ -117,6 +119,8 @@ function parseVersioned(raw) {
117
119
  let ciBinding;
118
120
  let ciRunner;
119
121
  let ciModuleSha256;
122
+ let cliSource;
123
+ let cliPackage;
120
124
  const bundles = [];
121
125
  let inBundles = false;
122
126
  let inPaths = false;
@@ -186,6 +190,10 @@ function parseVersioned(raw) {
186
190
  ciRunner = stripQuotes(value);
187
191
  else if (key === "ci_module_sha256")
188
192
  ciModuleSha256 = stripQuotes(value);
193
+ else if (key === "cli_source")
194
+ cliSource = stripQuotes(value);
195
+ else if (key === "cli_package")
196
+ cliPackage = stripQuotes(value);
189
197
  }
190
198
  continue;
191
199
  }
@@ -207,7 +215,7 @@ function parseVersioned(raw) {
207
215
  // a YAML reader parses 113 of 325 entries in one real lock as MAPPINGS
208
216
  // rather than strings — silently, with no error. Quoting on write is the
209
217
  // fix; reading both forms ships first so no lock can be written that an
210
- // older reader cannot parse. See method-open-items §3l.
218
+ // older reader cannot parse.
211
219
  const entry = stripQuotes(line.slice(" - ".length));
212
220
  const withDigest = DIGEST_ENTRY_RE.exec(entry);
213
221
  if (withDigest) {
@@ -313,6 +321,8 @@ function parseVersioned(raw) {
313
321
  ...(ciRunner !== undefined ? { ciRunner } : {}),
314
322
  ...(ciWorkflowBlockSha256 !== undefined ? { ciWorkflowBlockSha256 } : {}),
315
323
  ...(ciModuleSha256 !== undefined ? { ciModuleSha256 } : {}),
324
+ ...(cliSource !== undefined ? { cliSource } : {}),
325
+ ...(cliPackage !== undefined ? { cliPackage } : {}),
316
326
  };
317
327
  }
318
328
  function migrateV1(raw, legacyBundleName) {