@sdd-method/maintain-cli 0.0.0-stage → 0.1.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.
- package/LICENSE +201 -0
- package/NOTICE +17 -0
- package/README.md +6 -2
- package/dist/lib/adr-origin.js +59 -0
- package/dist/lib/agent-harness.js +28 -0
- package/dist/lib/briefing/index.js +13 -0
- package/dist/lib/briefing/render.js +123 -0
- package/dist/lib/briefing/variables.js +92 -0
- package/dist/lib/bundle.js +123 -0
- package/dist/lib/catalogue/aggregate.js +2404 -0
- package/dist/lib/catalogue/aggregation-manifest-loader.js +173 -0
- package/dist/lib/catalogue/application-archetype/expectations.js +119 -0
- package/dist/lib/catalogue/application-archetype/vocabulary.js +177 -0
- package/dist/lib/catalogue/archetype/maps/android.yaml +66 -0
- package/dist/lib/catalogue/archetype/maps/swiftui.yaml +58 -0
- package/dist/lib/catalogue/archetype/maps/uikit.yaml +48 -0
- package/dist/lib/catalogue/archetype/maps/web-generic.yaml +86 -0
- package/dist/lib/catalogue/archetype/openui-archetype-vocabulary.yaml +46 -0
- package/dist/lib/catalogue/archetype/resolver.js +103 -0
- package/dist/lib/catalogue/archetype/wireframe.js +418 -0
- package/dist/lib/catalogue/baseline-check.js +151 -0
- package/dist/lib/catalogue/build.js +1502 -0
- package/dist/lib/catalogue/builders/adr-supersedes.js +103 -0
- package/dist/lib/catalogue/builders/adrs.js +222 -0
- package/dist/lib/catalogue/builders/application-conformance-coverage.js +47 -0
- package/dist/lib/catalogue/builders/application-data-plane-bindings.js +115 -0
- package/dist/lib/catalogue/builders/c4-components.js +229 -0
- package/dist/lib/catalogue/builders/c4-containers.js +240 -0
- package/dist/lib/catalogue/builders/c4-entity-rollups.js +87 -0
- package/dist/lib/catalogue/builders/c4-external-systems.js +194 -0
- package/dist/lib/catalogue/builders/c4-l1-candidates.js +107 -0
- package/dist/lib/catalogue/builders/c4-systems.js +98 -0
- package/dist/lib/catalogue/builders/capabilities.js +488 -0
- package/dist/lib/catalogue/builders/capability-domain-model.js +74 -0
- package/dist/lib/catalogue/builders/capability-edges.js +256 -0
- package/dist/lib/catalogue/builders/capability-spec-walker.js +99 -0
- package/dist/lib/catalogue/builders/capability-spec.js +348 -0
- package/dist/lib/catalogue/builders/container-dependency.js +183 -0
- package/dist/lib/catalogue/builders/contracts.js +402 -0
- package/dist/lib/catalogue/builders/data-items.js +344 -0
- package/dist/lib/catalogue/builders/data-store-mapping.js +113 -0
- package/dist/lib/catalogue/builders/domain-models.js +175 -0
- package/dist/lib/catalogue/builders/domains.js +119 -0
- package/dist/lib/catalogue/builders/edge-label-merge.js +19 -0
- package/dist/lib/catalogue/builders/entities-manifest.js +160 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +189 -0
- package/dist/lib/catalogue/builders/entity-persistence.js +102 -0
- package/dist/lib/catalogue/builders/entity-slug.js +47 -0
- package/dist/lib/catalogue/builders/event-catalog-walker.js +186 -0
- package/dist/lib/catalogue/builders/event-edges.js +327 -0
- package/dist/lib/catalogue/builders/events.js +168 -0
- package/dist/lib/catalogue/builders/evidence-class.js +43 -0
- package/dist/lib/catalogue/builders/evidence-target-repo.js +67 -0
- package/dist/lib/catalogue/builders/external-system-exemption.js +120 -0
- package/dist/lib/catalogue/builders/feature-contract.js +77 -0
- package/dist/lib/catalogue/builders/feature-domain-model.js +50 -0
- package/dist/lib/catalogue/builders/feature-grounding-walker.js +254 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +391 -0
- package/dist/lib/catalogue/builders/feature-groups.js +63 -0
- package/dist/lib/catalogue/builders/feature-subscription-channels.js +228 -0
- package/dist/lib/catalogue/builders/feature-tree-walker.js +295 -0
- package/dist/lib/catalogue/builders/features.js +300 -0
- package/dist/lib/catalogue/builders/glossary-xref.js +378 -0
- package/dist/lib/catalogue/builders/glossary.js +204 -0
- package/dist/lib/catalogue/builders/implementations.js +214 -0
- package/dist/lib/catalogue/builders/index.js +64 -0
- package/dist/lib/catalogue/builders/integration-components.js +344 -0
- package/dist/lib/catalogue/builders/integrations.js +181 -0
- package/dist/lib/catalogue/builders/persona-c4system.js +149 -0
- package/dist/lib/catalogue/builders/persona-container.js +133 -0
- package/dist/lib/catalogue/builders/persona-journey-edges.js +133 -0
- package/dist/lib/catalogue/builders/personas.js +152 -0
- package/dist/lib/catalogue/builders/product-edges.js +106 -0
- package/dist/lib/catalogue/builders/product-platform-dependencies.js +405 -0
- package/dist/lib/catalogue/builders/products.js +162 -0
- package/dist/lib/catalogue/builders/quality-scores.js +635 -0
- package/dist/lib/catalogue/builders/repo-capability-exemption.js +61 -0
- package/dist/lib/catalogue/builders/repo-dependency.js +127 -0
- package/dist/lib/catalogue/builders/repo-edges.js +340 -0
- package/dist/lib/catalogue/builders/repositories.js +458 -0
- package/dist/lib/catalogue/builders/scaffolds.js +197 -0
- package/dist/lib/catalogue/builders/screen-tree.js +222 -0
- package/dist/lib/catalogue/builders/screens.js +396 -0
- package/dist/lib/catalogue/builders/service-capability-exemption.js +63 -0
- package/dist/lib/catalogue/builders/service-contract-consumes.js +186 -0
- package/dist/lib/catalogue/builders/service-contract.js +74 -0
- package/dist/lib/catalogue/builders/service-entity-tables.js +187 -0
- package/dist/lib/catalogue/builders/services.js +232 -0
- package/dist/lib/catalogue/builders/system-external-dependency.js +115 -0
- package/dist/lib/catalogue/builders/user-journeys.js +253 -0
- package/dist/lib/catalogue/canonical-schema.yaml +4117 -0
- package/dist/lib/catalogue/cli-version.js +10 -0
- package/dist/lib/catalogue/contract-id.js +49 -0
- package/dist/lib/catalogue/contract-path-resolution.js +149 -0
- package/dist/lib/catalogue/coverage-predicates.js +603 -0
- package/dist/lib/catalogue/coverage-render.js +552 -0
- package/dist/lib/catalogue/coverage-run.js +115 -0
- package/dist/lib/catalogue/csv-reader.js +97 -0
- package/dist/lib/catalogue/csv-writer.js +96 -0
- package/dist/lib/catalogue/data-item-coverage.js +142 -0
- package/dist/lib/catalogue/data-plane-coherence.js +77 -0
- package/dist/lib/catalogue/ddl-generator.js +233 -0
- package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
- package/dist/lib/catalogue/engine-validator.js +239 -0
- package/dist/lib/catalogue/ext-id.js +76 -0
- package/dist/lib/catalogue/feature-grounding-aliases-loader.js +65 -0
- package/dist/lib/catalogue/feature-repo-zero-resolution-summary.js +31 -0
- package/dist/lib/catalogue/finding-identity.js +183 -0
- package/dist/lib/catalogue/findings.js +46 -0
- package/dist/lib/catalogue/fk-validator.js +136 -0
- package/dist/lib/catalogue/freshness.js +69 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +346 -0
- package/dist/lib/catalogue/graph-run.js +64 -0
- package/dist/lib/catalogue/impact-run.js +228 -0
- package/dist/lib/catalogue/manifest-loader.js +435 -0
- package/dist/lib/catalogue/platform-scope.js +7 -0
- package/dist/lib/catalogue/population-checks.js +87 -0
- package/dist/lib/catalogue/relationship-semantics.js +66 -0
- package/dist/lib/catalogue/reverse-product-quality.js +99 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +871 -0
- package/dist/lib/catalogue/runway-vocabulary-loader.js +56 -0
- package/dist/lib/catalogue/scaffold-delta.js +261 -0
- package/dist/lib/catalogue/schema-compat.js +52 -0
- package/dist/lib/catalogue/schema-loader.js +87 -0
- package/dist/lib/catalogue/scores-coverage.js +573 -0
- package/dist/lib/catalogue/screen-validation-loader.js +89 -0
- package/dist/lib/catalogue/service-architecture-parser.js +193 -0
- package/dist/lib/catalogue/service-contract-aliases-loader.js +109 -0
- package/dist/lib/catalogue/staleness-verdicts.js +345 -0
- package/dist/lib/catalogue/traceability-loader.js +350 -0
- package/dist/lib/catalogue/validate.js +200 -0
- package/dist/lib/catalogue/version.js +18 -0
- package/dist/lib/ci-binding/index.js +220 -0
- package/dist/lib/ci-binding/module.js +204 -0
- package/dist/lib/conformance/binding.js +70 -0
- package/dist/lib/context-pull/index.js +151 -0
- package/dist/lib/context-pull/resolve.js +70 -0
- package/dist/lib/generate-briefing/index.js +77 -0
- package/dist/lib/hooks-seed/copilot.js +167 -0
- package/dist/lib/hooks-seed/entries.js +47 -0
- package/dist/lib/hooks-seed/index.js +17 -0
- package/dist/lib/hooks-seed/lifecycle-mapping.js +28 -0
- package/dist/lib/hooks-seed/merge.js +128 -0
- package/dist/lib/hooks-seed/types.js +20 -0
- package/dist/lib/init/ci-workflow.js +98 -0
- package/dist/lib/list/discover.js +100 -0
- package/dist/lib/list/format.js +84 -0
- package/dist/lib/list/index.js +16 -0
- package/dist/lib/lock-file.js +437 -0
- package/dist/lib/mcp-seed/entries.js +14 -0
- package/dist/lib/mcp-seed/index.js +28 -0
- package/dist/lib/mcp-seed/merge.js +99 -0
- package/dist/lib/mcp-seed/types.js +13 -0
- package/dist/lib/pack/builder.js +303 -0
- package/dist/lib/pack/index.js +87 -0
- package/dist/lib/pack/leakage.js +144 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/publish-transform.js +233 -0
- package/dist/lib/pack/reference-closure.js +125 -0
- package/dist/lib/pack/schema.js +121 -0
- package/dist/lib/pack/seed.js +182 -0
- package/dist/lib/pack/validate.js +49 -0
- package/dist/lib/package-version.js +92 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/snapshot/catalogue-diff.js +219 -0
- package/dist/lib/snapshot/manifest.js +206 -0
- package/dist/lib/sync/adr-transitions.js +250 -0
- package/dist/lib/sync/apply.js +91 -0
- package/dist/lib/sync/briefing-merge.js +90 -0
- package/dist/lib/sync/bundle-cache.js +109 -0
- package/dist/lib/sync/bundle-source.js +61 -0
- package/dist/lib/sync/config.js +72 -0
- package/dist/lib/sync/copilot-hooks-merge.js +108 -0
- package/dist/lib/sync/diff.js +251 -0
- package/dist/lib/sync/index.js +818 -0
- package/dist/lib/sync/settings-merge.js +216 -0
- package/dist/lib/sync/yaml-block-merge.js +120 -0
- package/dist/lib/validate/aggregate.js +51 -0
- package/dist/lib/validate/bundle-coherence.js +74 -0
- package/dist/lib/validate/dispatch.js +243 -0
- package/dist/lib/validate/index.js +110 -0
- package/dist/lib/validate/plan.js +54 -0
- package/dist/lib/validate/runner.js +85 -0
- package/dist/lib/validate/yaml-parse.js +112 -0
- package/dist/maintain-bin.js +8 -0
- package/dist/maintain-program.js +91 -0
- package/dist/maintain-version.json +4 -0
- package/dist/verbs/catalogue-maintain.js +110 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/context.js +39 -0
- package/dist/verbs/generate-briefing.js +29 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-build.js +31 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/render.js +87 -0
- package/dist/verbs/sync.js +110 -0
- package/dist/verbs/validate.js +48 -0
- package/dist/verify-program.js +122 -0
- package/dist/verify-version.json +4 -0
- package/package.json +32 -4
- package/scripts/validate-app-dependencies.sh +664 -0
- package/scripts/validate-app-docs.sh +199 -0
- package/scripts/validate-integration-docs.sh +505 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import { loadBundle } from "../bundle.js";
|
|
5
|
+
import { getSddCliVersion } from "../package-version.js";
|
|
6
|
+
import { LOCK_VERSION, findBundle, findForwardBase, hasForwardBase, pathToBundleMap, readLockFile, removeBundle, upsertBundle, writeLockFile, } from "../lock-file.js";
|
|
7
|
+
import { REPO_KIND_FILE, profileFromBundleName, repoKindFromProfile, writeRepoKind, } from "../repo-kind.js";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { copyManagedFiles, removeManagedFiles } from "./apply.js";
|
|
10
|
+
import { cacheBundle, ensureCacheGitignored, splitConsumptionModes, } from "./bundle-cache.js";
|
|
11
|
+
import { PROTECTED_PREFIXES, allPristine, classifyConflicts, computeRemovals, detectShadowCollisions, digestManagedPaths, findConflicts, findMissingPayloadFiles, findProtectedPaths, } from "./diff.js";
|
|
12
|
+
import { applyAdrRenames, findStrandedCitations, planAdrTransitions, } from "./adr-transitions.js";
|
|
13
|
+
import { syncMethodManagedSettings } from "./settings-merge.js";
|
|
14
|
+
import { syncMethodManagedCopilotHooks } from "./copilot-hooks-merge.js";
|
|
15
|
+
import { syncBriefingFiles } from "./briefing-merge.js";
|
|
16
|
+
import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
|
|
17
|
+
import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
|
|
18
|
+
import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
|
|
19
|
+
import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, harnessWrapperOutputPath, } from "../briefing/index.js";
|
|
20
|
+
const SECTION_RULE = "═".repeat(59);
|
|
21
|
+
/**
|
|
22
|
+
* Report the pristine/edited/unclassifiable split for a conflict set.
|
|
23
|
+
*
|
|
24
|
+
* The message this replaces said only "Found N conflicting managed file(s)",
|
|
25
|
+
* which reads as "you have local edits at risk" — and twice the truth was the
|
|
26
|
+
* opposite. Each branch below states what is actually known, and the
|
|
27
|
+
* undecidable case says so plainly rather than defaulting to alarm.
|
|
28
|
+
*/
|
|
29
|
+
function reportClassification(stdout, total, c, priorVersion) {
|
|
30
|
+
const from = priorVersion ? ` (match last-applied ${priorVersion})` : "";
|
|
31
|
+
if (c.edited.length === 0 && c.unclassifiable.length === 0) {
|
|
32
|
+
stdout(pass(`${total} differ, ${c.pristine.length} pristine${from} — safe to apply, ` +
|
|
33
|
+
`--allow-overwrite not required`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (c.unclassifiable.length === total) {
|
|
37
|
+
// The accepted non-retroactive case. Silence here would recreate exactly the
|
|
38
|
+
// "you have local edits at risk" misread this work exists to remove.
|
|
39
|
+
stdout(info(`${total} differ, cannot classify: no per-path digests recorded` +
|
|
40
|
+
(priorVersion ? ` for ${priorVersion}` : "") +
|
|
41
|
+
` — re-run after one sync to enable pristine detection`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
stdout(info(`${total} differ: ${c.pristine.length} pristine${from}, ` +
|
|
45
|
+
`${c.edited.length} locally edited, ${c.unclassifiable.length} not classifiable`));
|
|
46
|
+
}
|
|
47
|
+
export async function runSync(opts, stdout = (line) => process.stdout.write(line)) {
|
|
48
|
+
if (!(await exists(opts.bundle))) {
|
|
49
|
+
stdout(fail(`Bundle not found: ${opts.bundle}`));
|
|
50
|
+
return { exitCode: 1 };
|
|
51
|
+
}
|
|
52
|
+
if (!(await exists(opts.targetRepo))) {
|
|
53
|
+
stdout(fail(`Target repo not found: ${opts.targetRepo}`));
|
|
54
|
+
return { exitCode: 1 };
|
|
55
|
+
}
|
|
56
|
+
stdout(section("Sync Method Baseline"));
|
|
57
|
+
const bundle = await loadBundle(opts.bundle);
|
|
58
|
+
try {
|
|
59
|
+
// Enforce minimum sdd-cli version declared by the bundle manifest.
|
|
60
|
+
// Bundles built before v0.13.2 omit the field; for those, no check
|
|
61
|
+
// applies. Forward-baseline bundles starting at v0.6.0 (sdd-method
|
|
62
|
+
// v0.9.0, schema relocation) declare a minimum so adopters whose
|
|
63
|
+
// installed sdd-cli predates the relocation cannot apply a bundle
|
|
64
|
+
// that depends on schema-from-sdd-cli rather than schema-from-bundle.
|
|
65
|
+
const minVersionError = checkMinimumSddCliVersion(bundle.manifest);
|
|
66
|
+
if (minVersionError !== null) {
|
|
67
|
+
stdout(fail(minVersionError));
|
|
68
|
+
return { exitCode: 1 };
|
|
69
|
+
}
|
|
70
|
+
if (bundle.manifest.minimumSddCliVersion) {
|
|
71
|
+
stdout(info(`sdd-cli ${getSddCliVersion()} satisfies bundle minimum ${bundle.manifest.minimumSddCliVersion}`));
|
|
72
|
+
}
|
|
73
|
+
const missing = await findMissingPayloadFiles(bundle.managedPaths, bundle.payloadDir);
|
|
74
|
+
if (missing.length > 0) {
|
|
75
|
+
stdout(fail(`Missing payload file listed in MANAGED_PATHS.txt: ${missing[0]}`));
|
|
76
|
+
return { exitCode: 1 };
|
|
77
|
+
}
|
|
78
|
+
// ADR 0176 §4.3: split consumption modes. Scaffold-source paths and
|
|
79
|
+
// the registry file extract to the bundle cache only; everything
|
|
80
|
+
// else materialises into the repo tree as before.
|
|
81
|
+
const { materialise: materialisePaths, cacheOnly: cacheOnlyPaths } = splitConsumptionModes(bundle.managedPaths, bundle.manifest.scaffoldSourcePaths);
|
|
82
|
+
const protectedIncoming = findProtectedPaths(bundle.managedPaths);
|
|
83
|
+
if (protectedIncoming.length > 0) {
|
|
84
|
+
stdout(fail(`Incoming bundle attempts to manage protected product-owned path: ${protectedIncoming[0]}`));
|
|
85
|
+
stdout(fail(`Method baseline bundles must not manage files under: ${PROTECTED_PREFIXES.join(" ")}`));
|
|
86
|
+
return { exitCode: 1 };
|
|
87
|
+
}
|
|
88
|
+
const conflicts = await findConflicts(materialisePaths, bundle.payloadDir, opts.targetRepo);
|
|
89
|
+
// Read the lock BEFORE gating on conflicts: lock_version 3's per-path
|
|
90
|
+
// digests are what turn "N files differ" into "N files differ and every one
|
|
91
|
+
// is untouched", which decides whether --allow-overwrite is warranted at all.
|
|
92
|
+
const prevLock = await readLockFile(opts.targetRepo);
|
|
93
|
+
const prevEntry = findBundle(prevLock, bundle.manifest.bundleName);
|
|
94
|
+
const lockBinding = prevLock?.ciBinding;
|
|
95
|
+
if (lockBinding !== undefined && !isCiBinding(lockBinding)) {
|
|
96
|
+
stdout(fail(`method-baseline.lock names ci_binding '${lockBinding}', which is not one of ${CI_BINDINGS.join(", ")}. Re-run with --ci-binding <binding> to choose one.`));
|
|
97
|
+
return { exitCode: 1 };
|
|
98
|
+
}
|
|
99
|
+
const ciBinding = opts.ciBinding ?? lockBinding ?? DEFAULT_CI_BINDING;
|
|
100
|
+
const lockRunner = prevLock?.ciRunner === undefined ? undefined : parseCiRunner(prevLock.ciRunner);
|
|
101
|
+
if (lockRunner === null) {
|
|
102
|
+
stdout(fail(`method-baseline.lock names ci_runner '${prevLock.ciRunner}', which is not a list of runner labels. Re-run with --ci-runner <label[,label]> or --ci-runner default.`));
|
|
103
|
+
return { exitCode: 1 };
|
|
104
|
+
}
|
|
105
|
+
const ciRunner = opts.ciRunner ?? lockRunner;
|
|
106
|
+
const classification = await classifyConflicts(conflicts, opts.targetRepo, prevEntry?.pathDigests);
|
|
107
|
+
if (conflicts.length > 0) {
|
|
108
|
+
reportClassification(stdout, conflicts.length, classification, prevEntry?.bundleVersion);
|
|
109
|
+
}
|
|
110
|
+
// A conflict set that is provably pristine is not a risk, so the flag is not
|
|
111
|
+
// required for it. Genuine edits, and anything undecidable, still are.
|
|
112
|
+
const safeWithoutFlag = allPristine(classification);
|
|
113
|
+
if (conflicts.length > 0 && !opts.allowOverwrite && !safeWithoutFlag) {
|
|
114
|
+
const blocking = [...classification.edited, ...classification.unclassifiable];
|
|
115
|
+
stdout(fail(`Re-run with --allow-overwrite to apply. ${blocking.length} of ${conflicts.length} ` +
|
|
116
|
+
`conflicting file(s) are not provably untouched:`));
|
|
117
|
+
for (const p of blocking) {
|
|
118
|
+
const why = classification.edited.includes(p) ? "locally edited" : "not classifiable";
|
|
119
|
+
stdout(` - ${p} (${why})\n`);
|
|
120
|
+
}
|
|
121
|
+
return { exitCode: 1 };
|
|
122
|
+
}
|
|
123
|
+
// Validate protected prefixes across ALL previously-locked paths.
|
|
124
|
+
if (prevLock) {
|
|
125
|
+
for (const b of prevLock.bundles) {
|
|
126
|
+
const protectedPrev = findProtectedPaths(b.managedPaths);
|
|
127
|
+
if (protectedPrev.length > 0) {
|
|
128
|
+
stdout(fail(`Existing lock file contains protected product-owned path: ${protectedPrev[0]} (bundle: ${b.name})`));
|
|
129
|
+
stdout(fail("Resolve this manually before refreshing the method baseline."));
|
|
130
|
+
return { exitCode: 1 };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Additive bundles require a forward base already in the lock.
|
|
135
|
+
// For adopter packs (ADR 0151) the requires_forward_base field
|
|
136
|
+
// declares which sdd-method bundle (and minimum version) is needed.
|
|
137
|
+
const incomingBundleName = bundle.manifest.bundleName;
|
|
138
|
+
const incomingIsForward = bundle.manifest.isForwardBase;
|
|
139
|
+
if (!incomingIsForward && !hasForwardBase(prevLock, incomingBundleName)) {
|
|
140
|
+
stdout(fail(`${incomingBundleName} is an additive extension. Sync a forward base first.`));
|
|
141
|
+
stdout(fail(`Forward bases: platform-baseline, app-baseline, integration-baseline, reverse-baseline.`));
|
|
142
|
+
return { exitCode: 1 };
|
|
143
|
+
}
|
|
144
|
+
// Bundle migration (--replace-bundle): validate the request before any
|
|
145
|
+
// gate that would misread the overlap as a defect.
|
|
146
|
+
const migrateFrom = opts.replaceBundle
|
|
147
|
+
? findBundle(prevLock, opts.replaceBundle)
|
|
148
|
+
: null;
|
|
149
|
+
if (opts.replaceBundle) {
|
|
150
|
+
if (opts.replaceBundle === incomingBundleName) {
|
|
151
|
+
stdout(fail(`--replace-bundle ${opts.replaceBundle}: replacing a bundle with itself is a plain sync — drop the flag.`));
|
|
152
|
+
return { exitCode: 1 };
|
|
153
|
+
}
|
|
154
|
+
if (!migrateFrom) {
|
|
155
|
+
stdout(fail(`--replace-bundle ${opts.replaceBundle}: the lock has no bundle of that name. Nothing to migrate.`));
|
|
156
|
+
return { exitCode: 1 };
|
|
157
|
+
}
|
|
158
|
+
if (migrateFrom.isForwardBase !== incomingIsForward) {
|
|
159
|
+
stdout(fail(`--replace-bundle: '${opts.replaceBundle}' (forward base: ${migrateFrom.isForwardBase}) and '${incomingBundleName}' (forward base: ${incomingIsForward}) are different layer kinds — a migration swaps like for like.`));
|
|
160
|
+
return { exitCode: 1 };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Adopter pack requires_forward_base check: the named forward base
|
|
164
|
+
// must be in the lock at a compatible version. Per ADR 0151 §4.2.
|
|
165
|
+
if (!incomingIsForward && bundle.manifest.requiresForwardBase) {
|
|
166
|
+
const err = checkRequiresForwardBase(prevLock, bundle.manifest.requiresForwardBase, incomingBundleName);
|
|
167
|
+
if (err !== null) {
|
|
168
|
+
stdout(fail(err));
|
|
169
|
+
return { exitCode: 1 };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Adopter pack requires_pack check: vertical extensions name a base
|
|
173
|
+
// pack they depend on. Per ADR 0151 §4.3.
|
|
174
|
+
if (!incomingIsForward && bundle.manifest.requiresPack) {
|
|
175
|
+
const err = checkRequiresPack(prevLock, bundle.manifest.requiresPack, incomingBundleName);
|
|
176
|
+
if (err !== null) {
|
|
177
|
+
stdout(fail(err));
|
|
178
|
+
return { exitCode: 1 };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Cross-bundle ownership: a path managed by another bundle in the
|
|
182
|
+
// lock cannot also be claimed by this bundle. Under --replace-bundle the
|
|
183
|
+
// outgoing bundle's claims are released — migration is the overlap
|
|
184
|
+
// guard's sanctioned inverse, not its removal: a path owned by any THIRD
|
|
185
|
+
// bundle still refuses below.
|
|
186
|
+
const otherBundlePaths = pathToBundleMap(prevLock, incomingBundleName);
|
|
187
|
+
if (migrateFrom) {
|
|
188
|
+
for (const p of migrateFrom.managedPaths) {
|
|
189
|
+
if (otherBundlePaths.get(p) === migrateFrom.name)
|
|
190
|
+
otherBundlePaths.delete(p);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const p of bundle.managedPaths) {
|
|
194
|
+
const owner = otherBundlePaths.get(p);
|
|
195
|
+
if (owner !== undefined) {
|
|
196
|
+
stdout(fail(`Path '${p}' is already managed by bundle '${owner}'; cannot also be claimed by '${incomingBundleName}'.`));
|
|
197
|
+
stdout(fail("Method baselines must not overlap. Fix upstream and rebuild."));
|
|
198
|
+
return { exitCode: 1 };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ADR transitions (bundle-declared rename / withdrawal / reassignment).
|
|
202
|
+
// Planned BEFORE removals because a withdrawal's whole contract is that the
|
|
203
|
+
// file is preserved and reported rather than deleted — and removal-by-omission
|
|
204
|
+
// would otherwise sweep it, since a withdrawn ADR is no longer in the bundle.
|
|
205
|
+
const adrTransitions = await planAdrTransitions(bundle.rootDir, opts.targetRepo, prevEntry?.bundleVersion);
|
|
206
|
+
// Removals are scoped to THIS bundle's previously-managed paths — plus,
|
|
207
|
+
// under --replace-bundle, the outgoing bundle's paths, so its orphans
|
|
208
|
+
// (old-only, unclaimed by the incoming bundle) are removed in the same
|
|
209
|
+
// pass rather than left as untracked strays.
|
|
210
|
+
const prevBundleEntry = findBundle(prevLock, incomingBundleName);
|
|
211
|
+
const prevPaths = [
|
|
212
|
+
...(prevBundleEntry?.managedPaths ?? []),
|
|
213
|
+
...(migrateFrom?.managedPaths ?? []),
|
|
214
|
+
];
|
|
215
|
+
const withdrawnKeep = new Set(adrTransitions.protectedFromRemoval);
|
|
216
|
+
const removals = computeRemovals(prevPaths, materialisePaths, {
|
|
217
|
+
noDelete: !opts.delete,
|
|
218
|
+
}).filter((p) => !withdrawnKeep.has(p));
|
|
219
|
+
stdout(info(`Bundle: ${incomingBundleName} (version: ${bundle.manifest.bundleVersion})`));
|
|
220
|
+
stdout(info(`Incoming managed files: ${bundle.managedPaths.length}`));
|
|
221
|
+
if (cacheOnlyPaths.length > 0) {
|
|
222
|
+
stdout(info(`Cache-only (scaffold-source/registry) files: ${cacheOnlyPaths.length} — extracted to .sdd/bundle-cache, not materialised`));
|
|
223
|
+
}
|
|
224
|
+
// SHADOW-COLLISION FLAG (ledger §3ap, the durable move-rule's machinery).
|
|
225
|
+
// When the distributed surface MOVES a file, previously-delivered copies at
|
|
226
|
+
// the old location are stranded as unmanaged files: they keep resolving, so
|
|
227
|
+
// nothing breaks loudly, and they silently go stale as their managed twins
|
|
228
|
+
// evolve. One estate carried six such shadows, all stale — one of them a
|
|
229
|
+
// rubric its own scoring tooling was still reading, so a repaired anchor
|
|
230
|
+
// shipped and was simultaneously unreachable.
|
|
231
|
+
//
|
|
232
|
+
// The quiet failure is the one that needs machinery; a rename that breaks
|
|
233
|
+
// its citations announces itself. This flags the collision at the moment it
|
|
234
|
+
// is created — an incoming managed path whose BASENAME already exists at a
|
|
235
|
+
// shallower, non-managed path — rather than leaving it to a per-estate
|
|
236
|
+
// census to find later.
|
|
237
|
+
const shadows = detectShadowCollisions(materialisePaths, (rel) => existsSync(join(opts.targetRepo, rel)), (rel) => {
|
|
238
|
+
try {
|
|
239
|
+
return readFileSync(join(opts.targetRepo, rel), "utf-8");
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
if (shadows.length > 0) {
|
|
246
|
+
stdout(info(`Shadow collisions: ${shadows.length} — an incoming managed file shares its name with an ` +
|
|
247
|
+
`existing UNMANAGED file at a shallower path. These are not conflicts and nothing is ` +
|
|
248
|
+
`overwritten, but a stale twin that still resolves is read by tooling and by people ` +
|
|
249
|
+
`without either noticing (M-ADR 0200 move-rule):`));
|
|
250
|
+
for (const sh of shadows) {
|
|
251
|
+
stdout(info(` ${sh.shadow} (shadowed by managed ${sh.managed})`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
stdout(info(`Conflicts: ${conflicts.length}`));
|
|
255
|
+
// RELABEL (ledger §3y.2): the raw count mixes two different things and an
|
|
256
|
+
// adopter's dry-run reported 31 where the artefacts on disk numbered 5.
|
|
257
|
+
// Nothing was wrong with the mechanism — the LABEL claimed deletions for
|
|
258
|
+
// lock entries whose paths were never materialised (cache-only payload
|
|
259
|
+
// members). An alarming label on a harmless mechanism is a stop-generator,
|
|
260
|
+
// and this one generated a stop.
|
|
261
|
+
const removalsOnDisk = [];
|
|
262
|
+
for (const rel of removals) {
|
|
263
|
+
if (existsSync(join(opts.targetRepo, rel)))
|
|
264
|
+
removalsOnDisk.push(rel);
|
|
265
|
+
}
|
|
266
|
+
const lockOnly = removals.length - removalsOnDisk.length;
|
|
267
|
+
stdout(info(`Removals from previous sync of this bundle: ${removals.length}` +
|
|
268
|
+
(lockOnly > 0
|
|
269
|
+
? ` — ${removalsOnDisk.length} file(s) on disk to delete, ${lockOnly} lock entr(ies) only ` +
|
|
270
|
+
`(paths never materialised here, e.g. cache-only payload members; nothing is deleted for these)`
|
|
271
|
+
: ` (all present on disk)`)));
|
|
272
|
+
stdout(info(`ADR renames to apply: ${adrTransitions.renames.length}`));
|
|
273
|
+
stdout(info(`ADR transition warnings: ${adrTransitions.warnings.length}`));
|
|
274
|
+
if (adrTransitions.warnings.length > 0) {
|
|
275
|
+
stdout(info("ADR transition notices:"));
|
|
276
|
+
for (const w of adrTransitions.warnings)
|
|
277
|
+
stdout(` ! ${w}\n`);
|
|
278
|
+
}
|
|
279
|
+
// A move strands its citations as well as its file (ruled 2026-09-29): report
|
|
280
|
+
// each citation in the adopter's own documents to a moved path. Never
|
|
281
|
+
// rewritten; printed in dry-run too, so the figure a reviewer approves is the
|
|
282
|
+
// figure the run states.
|
|
283
|
+
const stranded = await findStrandedCitations(opts.targetRepo, adrTransitions.movedPaths, new Set(materialisePaths), await trackedTextFiles(opts.targetRepo));
|
|
284
|
+
if (stranded.length > 0) {
|
|
285
|
+
stdout(warn(`Citations to moved paths: ${stranded.length} — the adopter's own documents still name ` +
|
|
286
|
+
`a path the method moved. Update each (not rewritten automatically):`));
|
|
287
|
+
for (const s of stranded)
|
|
288
|
+
stdout(` ! ${s}\n`);
|
|
289
|
+
}
|
|
290
|
+
stdout(info(`Protected ownership prefixes: ${PROTECTED_PREFIXES.join(" ")}`));
|
|
291
|
+
// Migration three-way split — always printed, dry-run or not, so the
|
|
292
|
+
// figures a reviewer approves are the figures the real run states.
|
|
293
|
+
if (migrateFrom) {
|
|
294
|
+
const incomingSet = new Set(bundle.managedPaths);
|
|
295
|
+
const shared = migrateFrom.managedPaths.filter((p) => incomingSet.has(p));
|
|
296
|
+
const arriving = bundle.managedPaths.filter((p) => !migrateFrom.managedPaths.includes(p));
|
|
297
|
+
const orphaned = migrateFrom.managedPaths.filter((p) => !incomingSet.has(p));
|
|
298
|
+
stdout(info(`Migration ${migrateFrom.name} → ${incomingBundleName}:`));
|
|
299
|
+
stdout(info(` shared (ownership transfers): ${shared.length}`));
|
|
300
|
+
stdout(info(` arriving (new to this repo): ${arriving.length}`));
|
|
301
|
+
stdout(info(` orphaned (removed unless --no-delete): ${orphaned.length}`));
|
|
302
|
+
for (const p of orphaned)
|
|
303
|
+
stdout(` - ${p}\n`);
|
|
304
|
+
if (!opts.dryRun && opts.yes !== true) {
|
|
305
|
+
stdout(fail(`--replace-bundle is destructive (drops the '${migrateFrom.name}' lock block). ` +
|
|
306
|
+
`Review the split above (use --dry-run first), then re-run with --yes.`));
|
|
307
|
+
return { exitCode: 1 };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// PRE-FLIGHT: evaluate every merge-managed refusal BEFORE the first
|
|
311
|
+
// write. The same gates exist downstream, but they used to fire after
|
|
312
|
+
// the payload had been applied — a refused sync left the repo holding
|
|
313
|
+
// the new bundle's content under the OLD lock, and the next run's
|
|
314
|
+
// digest comparison then classified every method-written file as an
|
|
315
|
+
// adopter edit. A gate that fires after 46 writes is not a gate.
|
|
316
|
+
// Measured on a live estate: 46 files written, then exit 1, no warning
|
|
317
|
+
// that anything had been written. The checks here are read-only and
|
|
318
|
+
// mirror the real gates exactly; those stay in place as defence in
|
|
319
|
+
// depth. All of them no-op under --allow-overwrite, hence the guard.
|
|
320
|
+
// Under --replace-bundle the method-managed settings/workflow blocks were
|
|
321
|
+
// rendered for the OUTGOING bundle's profile; the incoming profile renders
|
|
322
|
+
// different blocks, so drift against them is EXPECTED, not adopter edits.
|
|
323
|
+
// The merges below re-render method-managed content only — adopter entries
|
|
324
|
+
// outside the markers are preserved either way — so migration overrides
|
|
325
|
+
// this gate without touching --allow-overwrite's file-conflict semantics.
|
|
326
|
+
// The dry run evaluates this too: it used to return before the pre-flight
|
|
327
|
+
// ran, so on a live estate the dry run PASSED on 70 repos whose apply the
|
|
328
|
+
// pre-flight then refused on all 70 — a plan reported as clean that could
|
|
329
|
+
// not be performed. One function, both paths.
|
|
330
|
+
const preflightFailures = async () => {
|
|
331
|
+
const failures = [];
|
|
332
|
+
if (incomingIsForward && !opts.allowOverwrite && !migrateFrom) {
|
|
333
|
+
const preflightProfile = profileFromBundleName(incomingBundleName);
|
|
334
|
+
if (preflightProfile) {
|
|
335
|
+
if (opts.agentHarness === "github-copilot-cli") {
|
|
336
|
+
const probe = await syncMethodManagedCopilotHooks({
|
|
337
|
+
targetRepo: opts.targetRepo,
|
|
338
|
+
allowOverwrite: false,
|
|
339
|
+
checkOnly: true,
|
|
340
|
+
});
|
|
341
|
+
if (probe.refused) {
|
|
342
|
+
failures.push(`.github/hooks/*.json has adopter-modified method entries (${probe.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const probe = await syncMethodManagedSettings({
|
|
347
|
+
targetRepo: opts.targetRepo,
|
|
348
|
+
profile: preflightProfile,
|
|
349
|
+
allowOverwrite: false,
|
|
350
|
+
checkOnly: true,
|
|
351
|
+
hadPreviousSync: prevLock !== null,
|
|
352
|
+
...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
|
|
353
|
+
});
|
|
354
|
+
if (probe.refused) {
|
|
355
|
+
const conflicts = [];
|
|
356
|
+
if (probe.drifted.length > 0)
|
|
357
|
+
conflicts.push(`mcpServers: ${probe.drifted.join(", ")}`);
|
|
358
|
+
if (probe.hooksDrifted.length > 0)
|
|
359
|
+
conflicts.push(`hooks: ${probe.hooksDrifted.join(", ")}`);
|
|
360
|
+
failures.push(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// The CI host wrapper and the Method Validation module (M-ADR 0219): the same check the
|
|
364
|
+
// write path runs, read-only.
|
|
365
|
+
const ciProbe = await syncCiBinding({
|
|
366
|
+
targetRepo: opts.targetRepo,
|
|
367
|
+
profile: preflightProfile,
|
|
368
|
+
binding: ciBinding,
|
|
369
|
+
...(ciRunner !== undefined ? { runner: ciRunner } : {}),
|
|
370
|
+
prevLock,
|
|
371
|
+
allowDrift: false,
|
|
372
|
+
allowOverwrite: false,
|
|
373
|
+
checkOnly: true,
|
|
374
|
+
});
|
|
375
|
+
failures.push(...ciProbe.failures);
|
|
376
|
+
// Briefing wrapper — only refusable when this bundle ships briefing
|
|
377
|
+
// templates; pre-ADR-0148 baselines skip the briefing pass entirely.
|
|
378
|
+
const shipsBriefing = bundle.managedPaths.some((p) => p.startsWith(`${BRIEFING_TEMPLATE_ROOT}/`));
|
|
379
|
+
if (shipsBriefing) {
|
|
380
|
+
const wrapperRel = harnessWrapperOutputPath(opts.agentHarness ?? DEFAULT_AGENT_HARNESS);
|
|
381
|
+
const preflightWrapper = await tryReadFile(join(opts.targetRepo, wrapperRel));
|
|
382
|
+
if (preflightWrapper !== null &&
|
|
383
|
+
preflightWrapper !== "" &&
|
|
384
|
+
!(preflightWrapper.includes(WRAPPER_BLOCK_BEGIN) &&
|
|
385
|
+
preflightWrapper.includes(WRAPPER_BLOCK_END))) {
|
|
386
|
+
failures.push(`${wrapperRel} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return failures;
|
|
392
|
+
};
|
|
393
|
+
if (opts.dryRun) {
|
|
394
|
+
const wouldRefuse = await preflightFailures();
|
|
395
|
+
if (wouldRefuse.length > 0) {
|
|
396
|
+
for (const f of wouldRefuse)
|
|
397
|
+
stdout(fail(`the apply would REFUSE: ${f}`));
|
|
398
|
+
stdout(info("Dry run: the plan below cannot be performed as it stands — the apply's pre-flight refuses it (see above). Exit 1."));
|
|
399
|
+
}
|
|
400
|
+
stdout(info("Dry run enabled. No files modified."));
|
|
401
|
+
if (conflicts.length > 0) {
|
|
402
|
+
stdout(info("Conflicting files:"));
|
|
403
|
+
for (const p of conflicts)
|
|
404
|
+
stdout(` - ${p}\n`);
|
|
405
|
+
}
|
|
406
|
+
if (removals.length > 0) {
|
|
407
|
+
stdout(info("Files that would be removed:"));
|
|
408
|
+
for (const p of removals)
|
|
409
|
+
stdout(` - ${p}\n`);
|
|
410
|
+
}
|
|
411
|
+
return { exitCode: wouldRefuse.length > 0 ? 1 : 0 };
|
|
412
|
+
}
|
|
413
|
+
const failures = await preflightFailures();
|
|
414
|
+
if (failures.length > 0) {
|
|
415
|
+
for (const f of failures)
|
|
416
|
+
stdout(fail(f));
|
|
417
|
+
stdout(info(
|
|
418
|
+
// The figures above describe the PLAN, and they print before this
|
|
419
|
+
// line because they are computed before the gate runs. An estate
|
|
420
|
+
// sweep keyed on them read 39 correct plans as 39 successes while
|
|
421
|
+
// every repo was untouched, so the outcome restates them in their
|
|
422
|
+
// own vocabulary rather than leaving the reader to infer that a
|
|
423
|
+
// plan reported is a plan performed.
|
|
424
|
+
`Pre-flight refusal: NOTHING was written — no files copied, no removals, lock unchanged. The ${bundle.managedPaths.length} incoming managed file(s), ${conflicts.length} conflict(s) and ${removals.length} removal(s) reported above are the plan that was ABANDONED, not work performed. Resolve the above (or re-run with --allow-overwrite) and sync again.`));
|
|
425
|
+
return { exitCode: 1 };
|
|
426
|
+
}
|
|
427
|
+
// Before the copy: move renamed ADRs so the adopter's own edits travel with the
|
|
428
|
+
// file and git records a rename, instead of the copy phase creating the new path
|
|
429
|
+
// and removal deleting the old one.
|
|
430
|
+
await applyAdrRenames(adrTransitions, opts.targetRepo);
|
|
431
|
+
await copyManagedFiles(materialisePaths, bundle.payloadDir, opts.targetRepo);
|
|
432
|
+
// Each pruned directory is named, not counted: it is an action taken on the
|
|
433
|
+
// adopter's tree, and git cannot show it afterwards because empty
|
|
434
|
+
// directories are untracked. The sync output is the only place it can ever
|
|
435
|
+
// be seen.
|
|
436
|
+
const prunedDirs = await removeManagedFiles(removals, opts.targetRepo);
|
|
437
|
+
if (prunedDirs.length > 0) {
|
|
438
|
+
stdout(info(`Emptied directories removed: ${prunedDirs.length} (withdrawal left them empty)`));
|
|
439
|
+
for (const d of prunedDirs)
|
|
440
|
+
stdout(` - ${d}/\n`);
|
|
441
|
+
}
|
|
442
|
+
// ADR 0176 §4.2: cache non-forward-base bundles (the resolution
|
|
443
|
+
// substrate for scaffold --archetype). Forward bases are not cached
|
|
444
|
+
// — they carry no archetype registries and are large.
|
|
445
|
+
if (!bundle.manifest.isForwardBase) {
|
|
446
|
+
await cacheBundle({
|
|
447
|
+
targetRepo: opts.targetRepo,
|
|
448
|
+
bundleName: bundle.manifest.bundleName,
|
|
449
|
+
bundleVersion: bundle.manifest.bundleVersion,
|
|
450
|
+
managedPaths: bundle.managedPaths,
|
|
451
|
+
payloadDir: bundle.payloadDir,
|
|
452
|
+
});
|
|
453
|
+
const ignored = await ensureCacheGitignored(opts.targetRepo);
|
|
454
|
+
stdout(info(`Bundle cached: .sdd/bundle-cache/${bundle.manifest.bundleName}@${bundle.manifest.bundleVersion}${ignored ? " (.gitignore updated)" : ""}`));
|
|
455
|
+
}
|
|
456
|
+
const bundleSha = await sha256File(opts.bundle);
|
|
457
|
+
// Per-path digests (lock_version 3), taken from the PAYLOAD rather than the
|
|
458
|
+
// repo after copying: the payload is definitionally what this sync applied,
|
|
459
|
+
// and hashing the repo afterwards would silently absorb any concurrent local
|
|
460
|
+
// write into the "as applied" record — making a later edit look pristine.
|
|
461
|
+
// Cache-only paths are excluded because they never land in the tree, so a
|
|
462
|
+
// digest for them could never be compared against anything.
|
|
463
|
+
const pathDigests = await digestManagedPaths(materialisePaths, bundle.payloadDir);
|
|
464
|
+
const newEntry = {
|
|
465
|
+
name: incomingBundleName,
|
|
466
|
+
bundleVersion: bundle.manifest.bundleVersion,
|
|
467
|
+
sourceCommit: bundle.manifest.sourceCommit,
|
|
468
|
+
bundleSha256: bundleSha,
|
|
469
|
+
// Re-applying the SAME bundle (same sha256) keeps the time it was first applied, so a repeat
|
|
470
|
+
// sync writes the same lock bytes: sync is a function of its inputs, not of the clock
|
|
471
|
+
// (M-ADR 0218 §4.2, determinism). Found by maintain-cli's acceptance run, where the second
|
|
472
|
+
// sync on one commit changed nothing but this timestamp. A different bundle gets a new time.
|
|
473
|
+
syncedAtUtc: prevBundleEntry !== null && prevBundleEntry !== undefined && prevBundleEntry.bundleSha256 === bundleSha
|
|
474
|
+
? prevBundleEntry.syncedAtUtc
|
|
475
|
+
: nowUtcIso(),
|
|
476
|
+
isForwardBase: incomingIsForward,
|
|
477
|
+
managedPaths: [...bundle.managedPaths],
|
|
478
|
+
pathDigests,
|
|
479
|
+
// Adopter-pack provenance per ADR 0151 — fields only present for
|
|
480
|
+
// non-forward-base bundles produced by adopter pack builders.
|
|
481
|
+
...(bundle.manifest.producedByAdopter && {
|
|
482
|
+
producedByAdopter: bundle.manifest.producedByAdopter,
|
|
483
|
+
}),
|
|
484
|
+
...(bundle.manifest.scope && { scope: bundle.manifest.scope }),
|
|
485
|
+
...(bundle.manifest.requiresForwardBase && {
|
|
486
|
+
requiresForwardBase: bundle.manifest.requiresForwardBase,
|
|
487
|
+
}),
|
|
488
|
+
...(bundle.manifest.requiresPack && {
|
|
489
|
+
requiresPack: bundle.manifest.requiresPack,
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
// Additive layers (e.g. reverse-extension-baseline) own only the
|
|
493
|
+
// reverse-method content. They do NOT set .sdd-repo-kind, refresh
|
|
494
|
+
// .claude/settings.json, or rewrite the CI workflow — the forward
|
|
495
|
+
// base owns those.
|
|
496
|
+
if (!incomingIsForward) {
|
|
497
|
+
await writeLockFile(opts.targetRepo, upsertBundle(migrateFrom ? removeBundle(prevLock, migrateFrom.name) : prevLock, newEntry));
|
|
498
|
+
stdout(pass(`Method baseline synced to: ${opts.targetRepo}`));
|
|
499
|
+
stdout(info(`Updated lock file: method-baseline.lock (lock_version: ${LOCK_VERSION})`));
|
|
500
|
+
stdout(info("Additive layer applied; .sdd-repo-kind unchanged."));
|
|
501
|
+
return { exitCode: 0 };
|
|
502
|
+
}
|
|
503
|
+
// Forward base path: settings, workflow, repo-kind all belong here.
|
|
504
|
+
const profile = profileFromBundleName(incomingBundleName);
|
|
505
|
+
if (!profile) {
|
|
506
|
+
// Should be unreachable: forward bases all map to a profile.
|
|
507
|
+
throw new Error(`Forward-base bundle "${incomingBundleName}" has no profile mapping`);
|
|
508
|
+
}
|
|
509
|
+
const repoKind = repoKindFromProfile(profile);
|
|
510
|
+
await writeRepoKind(opts.targetRepo, repoKind);
|
|
511
|
+
// Harness-aware merge of agent-harness wiring per ADR 0141 §4.6.
|
|
512
|
+
// claude-code keeps the .claude/settings.json path (MCP + Claude
|
|
513
|
+
// hooks). github-copilot-cli writes .github/hooks/*.json instead;
|
|
514
|
+
// its MCP wiring lands separately under ADR 0150 Phase 3.
|
|
515
|
+
if (opts.agentHarness === "github-copilot-cli") {
|
|
516
|
+
const copilotResult = await syncMethodManagedCopilotHooks({
|
|
517
|
+
targetRepo: opts.targetRepo,
|
|
518
|
+
allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
|
|
519
|
+
});
|
|
520
|
+
if (copilotResult.refused) {
|
|
521
|
+
stdout(fail(`.github/hooks/*.json has adopter-modified method entries (${copilotResult.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`));
|
|
522
|
+
return { exitCode: 1 };
|
|
523
|
+
}
|
|
524
|
+
const written = copilotResult.files.filter((f) => f.wrote);
|
|
525
|
+
if (written.length > 0) {
|
|
526
|
+
stdout(info(`Refreshed ${written.map((f) => f.relPath).join(", ")}.`));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
const settingsResult = await syncMethodManagedSettings({
|
|
531
|
+
targetRepo: opts.targetRepo,
|
|
532
|
+
profile,
|
|
533
|
+
allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
|
|
534
|
+
// A lock is the only sound evidence the managed entries were seeded
|
|
535
|
+
// before, which is what makes a re-seed distinguishable from a first
|
|
536
|
+
// sync into an adopter's own settings file.
|
|
537
|
+
hadPreviousSync: prevLock !== null,
|
|
538
|
+
...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
|
|
539
|
+
});
|
|
540
|
+
if (settingsResult.refused) {
|
|
541
|
+
const conflicts = [];
|
|
542
|
+
if (settingsResult.drifted.length > 0)
|
|
543
|
+
conflicts.push(`mcpServers: ${settingsResult.drifted.join(", ")}`);
|
|
544
|
+
if (settingsResult.hooksDrifted.length > 0)
|
|
545
|
+
conflicts.push(`hooks: ${settingsResult.hooksDrifted.join(", ")}`);
|
|
546
|
+
stdout(fail(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`));
|
|
547
|
+
return { exitCode: 1 };
|
|
548
|
+
}
|
|
549
|
+
// Drift-class line of its own, ahead of the refresh summary: these
|
|
550
|
+
// entries were seeded by an earlier sync and had been removed since.
|
|
551
|
+
// Restoring them is not the news — that they went missing is, and the
|
|
552
|
+
// same ids reappearing here every sync means something is removing them
|
|
553
|
+
// repeatedly.
|
|
554
|
+
if (settingsResult.reseeded.length > 0) {
|
|
555
|
+
stdout(info(`${settingsResult.path}: RE-SEEDED ${settingsResult.reseeded.length} method-managed entr${settingsResult.reseeded.length === 1 ? "y" : "ies"} (${settingsResult.reseeded.join(", ")}) — present after a previous sync, absent now. Restored; if this recurs, something in this repo is removing them.`));
|
|
556
|
+
}
|
|
557
|
+
if (settingsResult.wroteFile) {
|
|
558
|
+
const labels = [];
|
|
559
|
+
if (settingsResult.added.length > 0)
|
|
560
|
+
labels.push(`mcp added: ${settingsResult.added.join(", ")}`);
|
|
561
|
+
if (settingsResult.updated.length > 0)
|
|
562
|
+
labels.push(`mcp updated: ${settingsResult.updated.join(", ")}`);
|
|
563
|
+
if (settingsResult.hooksChangedEvents.length > 0)
|
|
564
|
+
labels.push(`hooks: ${settingsResult.hooksChangedEvents.join(", ")}`);
|
|
565
|
+
stdout(info(`Refreshed ${settingsResult.path} (${labels.join("; ") || "no changes"}).`));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// The CI host wrapper and the Method Validation module it calls (M-ADR 0219). Under
|
|
569
|
+
// --replace-bundle the blocks were rendered for the outgoing profile, so drift is expected.
|
|
570
|
+
const ci = await syncCiBinding({
|
|
571
|
+
targetRepo: opts.targetRepo,
|
|
572
|
+
profile,
|
|
573
|
+
binding: ciBinding,
|
|
574
|
+
...(ciRunner !== undefined ? { runner: ciRunner } : {}),
|
|
575
|
+
prevLock,
|
|
576
|
+
allowDrift: opts.allowOverwrite || migrateFrom !== null,
|
|
577
|
+
allowOverwrite: opts.allowOverwrite,
|
|
578
|
+
checkOnly: false,
|
|
579
|
+
});
|
|
580
|
+
if (ci.failures.length > 0) {
|
|
581
|
+
for (const f of ci.failures)
|
|
582
|
+
stdout(fail(f));
|
|
583
|
+
return { exitCode: 1 };
|
|
584
|
+
}
|
|
585
|
+
for (const line of ci.log)
|
|
586
|
+
stdout(info(line));
|
|
587
|
+
// Briefing layer per ADR 0148: agent-instructions.md (unconditional
|
|
588
|
+
// overwrite) + harness wrapper (marker-aware merge). Templates ship
|
|
589
|
+
// in the bundle's orchestration/templates/briefing/; gracefully skip
|
|
590
|
+
// when an older bundle without them is being synced.
|
|
591
|
+
const briefingResult = await syncBriefingFiles({
|
|
592
|
+
targetRepo: opts.targetRepo,
|
|
593
|
+
repoKind: repoKindFromProfile(profile),
|
|
594
|
+
baselineVersion: bundle.manifest.bundleVersion,
|
|
595
|
+
agentHarness: opts.agentHarness ?? DEFAULT_AGENT_HARNESS,
|
|
596
|
+
allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
|
|
597
|
+
});
|
|
598
|
+
if (briefingResult.refused) {
|
|
599
|
+
stdout(fail(`${briefingResult.refused} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`));
|
|
600
|
+
return { exitCode: 1 };
|
|
601
|
+
}
|
|
602
|
+
for (const line of briefingResult.log)
|
|
603
|
+
stdout(info(line));
|
|
604
|
+
// Record the wrapper block and the module exactly as they now stand on disk, so the next sync
|
|
605
|
+
// can tell them untouched from edited (M-ADR 0218 §4.7), and the binding when it is not the default.
|
|
606
|
+
const nextLock = upsertBundle(migrateFrom ? removeBundle(prevLock, migrateFrom.name) : prevLock, newEntry);
|
|
607
|
+
const { ciBinding: _previousBinding, ciRunner: _previousRunner, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
|
|
608
|
+
await writeLockFile(opts.targetRepo, {
|
|
609
|
+
...lockBase,
|
|
610
|
+
...(ciBinding !== DEFAULT_CI_BINDING ? { ciBinding } : {}),
|
|
611
|
+
...(ciRunner !== undefined && ciRunner.length > 0 ? { ciRunner: ciRunner.join(",") } : {}),
|
|
612
|
+
...(ci.wrapperSha !== null ? { ciWorkflowBlockSha256: ci.wrapperSha } : {}),
|
|
613
|
+
ciModuleSha256: ci.moduleSha,
|
|
614
|
+
});
|
|
615
|
+
stdout(pass(`Method baseline synced to: ${opts.targetRepo}`));
|
|
616
|
+
stdout(info(`Updated lock file: method-baseline.lock (lock_version: ${LOCK_VERSION})`));
|
|
617
|
+
stdout(info(`Set repo kind marker: ${REPO_KIND_FILE} = ${repoKind}`));
|
|
618
|
+
return { exitCode: 0 };
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
await bundle.cleanup();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** sha256 of the GitHub workflow's method-managed block (begin marker through end marker), or null without one. */
|
|
625
|
+
export function workflowBlockSha256(text) {
|
|
626
|
+
return blockSha256(text, { begin: BLOCK_BEGIN, end: BLOCK_END });
|
|
627
|
+
}
|
|
628
|
+
function section(title) {
|
|
629
|
+
return `\n${SECTION_RULE}\n ${title}\n${SECTION_RULE}\n\n`;
|
|
630
|
+
}
|
|
631
|
+
function info(msg) {
|
|
632
|
+
return `[INFO] ${msg}\n`;
|
|
633
|
+
}
|
|
634
|
+
function warn(msg) {
|
|
635
|
+
return `[WARN] ${msg}\n`;
|
|
636
|
+
}
|
|
637
|
+
function pass(msg) {
|
|
638
|
+
return `[PASS] ${msg}\n`;
|
|
639
|
+
}
|
|
640
|
+
function fail(msg) {
|
|
641
|
+
return `[FAIL] ${msg}\n`;
|
|
642
|
+
}
|
|
643
|
+
async function exists(path) {
|
|
644
|
+
try {
|
|
645
|
+
await access(path);
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* If the bundle manifest declares a minimum sdd-cli version, verify the
|
|
654
|
+
* installed version satisfies it. Returns null on success or no
|
|
655
|
+
* declared minimum; returns a formatted error message on failure.
|
|
656
|
+
*
|
|
657
|
+
* Comparison is structural semver (major.minor.patch) — installed >=
|
|
658
|
+
* minimum at each segment. Pre-release identifiers (e.g. "0.14.0-rc.1")
|
|
659
|
+
* are stripped before comparison; the minimum field always declares a
|
|
660
|
+
* stable release version.
|
|
661
|
+
*/
|
|
662
|
+
export function checkMinimumSddCliVersion(manifest, installedOverride) {
|
|
663
|
+
const required = manifest.minimumSddCliVersion;
|
|
664
|
+
if (!required || required.length === 0)
|
|
665
|
+
return null;
|
|
666
|
+
const installed = installedOverride ?? getSddCliVersion();
|
|
667
|
+
if (versionAtLeast(installed, required))
|
|
668
|
+
return null;
|
|
669
|
+
return [
|
|
670
|
+
`Bundle ${manifest.bundleName} ${manifest.bundleVersion} requires sdd-cli >= ${required}`,
|
|
671
|
+
`but installed sdd-cli is ${installed}.`,
|
|
672
|
+
`Remediation: npm install -g @sdd-method/sdd-cli@^${required}`,
|
|
673
|
+
].join(" ");
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Verify `requires_forward_base` declared by an adopter pack against
|
|
677
|
+
* the consumer's lock. Returns null on success or a formatted error.
|
|
678
|
+
*/
|
|
679
|
+
export function checkRequiresForwardBase(prevLock, required, incomingBundleName) {
|
|
680
|
+
const fwd = findForwardBase(prevLock, incomingBundleName);
|
|
681
|
+
if (fwd === null) {
|
|
682
|
+
return (`${incomingBundleName} requires forward base ${required.bundle} ` +
|
|
683
|
+
`>= ${required.minimumVersion}, but the consumer lock has no forward base.`);
|
|
684
|
+
}
|
|
685
|
+
if (fwd.name !== required.bundle) {
|
|
686
|
+
return (`${incomingBundleName} requires forward base ${required.bundle}, ` +
|
|
687
|
+
`but the consumer lock has ${fwd.name} as its forward base.`);
|
|
688
|
+
}
|
|
689
|
+
if (!versionAtLeast(fwd.bundleVersion, required.minimumVersion)) {
|
|
690
|
+
return (`${incomingBundleName} requires ${required.bundle} >= ${required.minimumVersion}, ` +
|
|
691
|
+
`but the consumer lock has ${fwd.name} ${fwd.bundleVersion}.`);
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Verify `requires_pack` declared by a vertical extension against the
|
|
697
|
+
* consumer's lock. Returns null on success or a formatted error.
|
|
698
|
+
*
|
|
699
|
+
* @public exported for unit testing.
|
|
700
|
+
*
|
|
701
|
+
* Version semantics: caret-range default (`^vX.Y.Z` matches `X.x.y` ≥
|
|
702
|
+
* `X.Y.Z` for X >= 1; for `^v0.Y.Z` only the same minor — `0.Y.x`).
|
|
703
|
+
* Exact pin (`vX.Y.Z`) matches only that version. Tilde (`~vX.Y.Z`)
|
|
704
|
+
* matches `X.Y.x`. `>=vX.Y.Z` matches anything ≥ that version.
|
|
705
|
+
*/
|
|
706
|
+
export function checkRequiresPack(prevLock, required, incomingBundleName) {
|
|
707
|
+
const target = findBundle(prevLock, required.name);
|
|
708
|
+
if (target === null) {
|
|
709
|
+
return (`${incomingBundleName} requires pack ${required.name} ${required.version}, ` +
|
|
710
|
+
`but no such pack is in the consumer lock.`);
|
|
711
|
+
}
|
|
712
|
+
if (!rangeSatisfies(target.bundleVersion, required.version)) {
|
|
713
|
+
return (`${incomingBundleName} requires ${required.name} ${required.version}, ` +
|
|
714
|
+
`but the consumer lock has ${target.name} ${target.bundleVersion} (range not satisfied).`);
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Semver range satisfaction. Supports caret (^), tilde (~), >= and
|
|
720
|
+
* exact-pin (no prefix). Versions may carry a leading 'v'.
|
|
721
|
+
*
|
|
722
|
+
* Pre-1.0 caret narrows to the same minor: ^v0.1.x matches only 0.1.y
|
|
723
|
+
* (per npm convention). ^v1.2.x matches 1.x.y >= 1.2.x.
|
|
724
|
+
*/
|
|
725
|
+
export function rangeSatisfies(version, range) {
|
|
726
|
+
const stripV = (s) => s.replace(/^v/, "");
|
|
727
|
+
const parse = (s) => parseSimpleSemver(stripV(s));
|
|
728
|
+
let prefix = "=";
|
|
729
|
+
let body = range;
|
|
730
|
+
if (range.startsWith("^")) {
|
|
731
|
+
prefix = "^";
|
|
732
|
+
body = range.slice(1);
|
|
733
|
+
}
|
|
734
|
+
else if (range.startsWith("~")) {
|
|
735
|
+
prefix = "~";
|
|
736
|
+
body = range.slice(1);
|
|
737
|
+
}
|
|
738
|
+
else if (range.startsWith(">=")) {
|
|
739
|
+
prefix = ">=";
|
|
740
|
+
body = range.slice(2);
|
|
741
|
+
}
|
|
742
|
+
const v = parse(version);
|
|
743
|
+
const r = parse(body);
|
|
744
|
+
if (v === null || r === null)
|
|
745
|
+
return false;
|
|
746
|
+
switch (prefix) {
|
|
747
|
+
case "=":
|
|
748
|
+
return v.major === r.major && v.minor === r.minor && v.patch === r.patch;
|
|
749
|
+
case ">=":
|
|
750
|
+
return versionAtLeast(stripV(version), stripV(body));
|
|
751
|
+
case "~":
|
|
752
|
+
// Same major.minor, patch >= r.patch
|
|
753
|
+
return v.major === r.major && v.minor === r.minor && v.patch >= r.patch;
|
|
754
|
+
case "^":
|
|
755
|
+
// Pre-1.0: same major.minor (narrow caret)
|
|
756
|
+
if (r.major === 0) {
|
|
757
|
+
return v.major === 0 && v.minor === r.minor && v.patch >= r.patch;
|
|
758
|
+
}
|
|
759
|
+
// Stable: same major, version >= range
|
|
760
|
+
return v.major === r.major && versionAtLeast(stripV(version), stripV(body));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function versionAtLeast(installed, minimum) {
|
|
764
|
+
const i = parseSimpleSemver(installed);
|
|
765
|
+
const m = parseSimpleSemver(minimum);
|
|
766
|
+
if (i === null || m === null)
|
|
767
|
+
return false;
|
|
768
|
+
if (i.major !== m.major)
|
|
769
|
+
return i.major > m.major;
|
|
770
|
+
if (i.minor !== m.minor)
|
|
771
|
+
return i.minor > m.minor;
|
|
772
|
+
return i.patch >= m.patch;
|
|
773
|
+
}
|
|
774
|
+
function parseSimpleSemver(s) {
|
|
775
|
+
// Strip optional `v` prefix and any pre-release / build suffix.
|
|
776
|
+
const cleaned = s.replace(/^v/, "").replace(/[-+].*$/, "");
|
|
777
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(cleaned);
|
|
778
|
+
if (!m)
|
|
779
|
+
return null;
|
|
780
|
+
return {
|
|
781
|
+
major: parseInt(m[1], 10),
|
|
782
|
+
minor: parseInt(m[2], 10),
|
|
783
|
+
patch: parseInt(m[3], 10),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
async function tryReadFile(path) {
|
|
787
|
+
try {
|
|
788
|
+
await access(path);
|
|
789
|
+
return await readFile(path, "utf8");
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function sha256File(path) {
|
|
796
|
+
const buf = await readFile(path);
|
|
797
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
798
|
+
}
|
|
799
|
+
function nowUtcIso() {
|
|
800
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* The adopter's git-tracked Markdown and YAML — the documents that can cite a
|
|
804
|
+
* moved path. Outside a git work tree there is no honest scope: nothing is
|
|
805
|
+
* scanned, rather than guessing at the whole directory.
|
|
806
|
+
*/
|
|
807
|
+
async function trackedTextFiles(targetRepo) {
|
|
808
|
+
const { execFile } = await import("node:child_process");
|
|
809
|
+
const { promisify } = await import("node:util");
|
|
810
|
+
try {
|
|
811
|
+
const { stdout } = await promisify(execFile)("git", ["-C", targetRepo, "ls-files", "-z", "--", "*.md", "*.yaml", "*.yml"], { maxBuffer: 64 * 1024 * 1024 });
|
|
812
|
+
return stdout.split("\0").filter((p) => p !== "");
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
//# sourceMappingURL=index.js.map
|