@sdd-method/maintain-cli 0.0.0-stage → 0.1.0-rc.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 +209 -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 +89 -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 +432 -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 +809 -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 +98 -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,109 @@
|
|
|
1
|
+
import { appendFile, copyFile, mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Consumer-side bundle cache (ADR 0176 §4.2).
|
|
5
|
+
*
|
|
6
|
+
* Every consumed non-forward-base bundle is extracted to
|
|
7
|
+
* `<targetRepo>/.sdd/bundle-cache/<bundleName>@<version>/` — the
|
|
8
|
+
* resolution substrate for `sdd-cli scaffold domain --archetype` and
|
|
9
|
+
* the staging source for materialise-mode paths. The cache directory
|
|
10
|
+
* is gitignored (ensured at sync time).
|
|
11
|
+
*
|
|
12
|
+
* Cache layout per bundle: the bundle's payload tree, verbatim (so
|
|
13
|
+
* registry.yaml and scaffold-source files sit at their shipped paths).
|
|
14
|
+
* Stale versions of the same bundle name are pruned on refresh.
|
|
15
|
+
*/
|
|
16
|
+
export const BUNDLE_CACHE_REL = ".sdd/bundle-cache";
|
|
17
|
+
/**
|
|
18
|
+
* The registry file name (ADR 0176 §4.1) — always cache-only. A bundle may
|
|
19
|
+
* ship its registry at the payload root or nested (a multi-bundle producer
|
|
20
|
+
* repo keeps one registry per bundle subtree, e.g. a vertical overlay's
|
|
21
|
+
* `verticals/fleet/registry.yaml`, because the producer-layout rule forbids
|
|
22
|
+
* path mapping and the root name can only be claimed once).
|
|
23
|
+
*/
|
|
24
|
+
export const REGISTRY_FILE = "registry.yaml";
|
|
25
|
+
/**
|
|
26
|
+
* The application-archetype vocabulary file name (M-ADR 0191 §4.2) — always
|
|
27
|
+
* cache-only, like the registry. Carries the seedless classification
|
|
28
|
+
* vocabulary a bundle contributes (resolution data, not adopter-editable
|
|
29
|
+
* content). May sit at the payload root (core layer) or nested (a vertical
|
|
30
|
+
* overlay's `verticals/<v>/application-archetypes.yaml`), same as the
|
|
31
|
+
* registry.
|
|
32
|
+
*/
|
|
33
|
+
export const VOCAB_FILE = "application-archetypes.yaml";
|
|
34
|
+
/**
|
|
35
|
+
* Split a bundle's managed paths into the set to materialise into the
|
|
36
|
+
* repo tree vs the set that stays cache-only (ADR 0176 §4.3):
|
|
37
|
+
* scaffold-source-prefixed paths, registry files, and the archetype
|
|
38
|
+
* vocabulary file are cache-only.
|
|
39
|
+
*/
|
|
40
|
+
export function splitConsumptionModes(managedPaths, scaffoldSourcePaths) {
|
|
41
|
+
const prefixes = scaffoldSourcePaths ?? [];
|
|
42
|
+
const materialise = [];
|
|
43
|
+
const cacheOnly = [];
|
|
44
|
+
for (const p of managedPaths) {
|
|
45
|
+
const isScaffoldSource = prefixes.some((prefix) => p.startsWith(prefix));
|
|
46
|
+
const isRegistry = p === REGISTRY_FILE || p.endsWith(`/${REGISTRY_FILE}`);
|
|
47
|
+
const isVocab = p === VOCAB_FILE || p.endsWith(`/${VOCAB_FILE}`);
|
|
48
|
+
if (isScaffoldSource || isRegistry || isVocab) {
|
|
49
|
+
cacheOnly.push(p);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
materialise.push(p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { materialise, cacheOnly };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract a bundle's payload into the cache, replacing any previously
|
|
59
|
+
* cached version of the same bundle name (one cached version per
|
|
60
|
+
* bundle — the lock pins exactly one).
|
|
61
|
+
*/
|
|
62
|
+
export async function cacheBundle(input) {
|
|
63
|
+
const cacheRoot = join(input.targetRepo, BUNDLE_CACHE_REL);
|
|
64
|
+
await prunePreviousVersions(cacheRoot, input.bundleName);
|
|
65
|
+
const dest = join(cacheRoot, `${input.bundleName}@${input.bundleVersion}`);
|
|
66
|
+
for (const p of input.managedPaths) {
|
|
67
|
+
const src = join(input.payloadDir, p);
|
|
68
|
+
const dst = join(dest, p);
|
|
69
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
70
|
+
await copyFile(src, dst);
|
|
71
|
+
}
|
|
72
|
+
return dest;
|
|
73
|
+
}
|
|
74
|
+
async function prunePreviousVersions(cacheRoot, bundleName) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = await readdir(cacheRoot, { withFileTypes: true });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return; // no cache yet
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (e.isDirectory() && e.name.startsWith(`${bundleName}@`)) {
|
|
84
|
+
await rm(join(cacheRoot, e.name), { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Ensure the consumer's .gitignore excludes the bundle cache. Appends
|
|
90
|
+
* a marked line when absent; leaves adopter content untouched otherwise.
|
|
91
|
+
*/
|
|
92
|
+
export async function ensureCacheGitignored(targetRepo) {
|
|
93
|
+
const giPath = join(targetRepo, ".gitignore");
|
|
94
|
+
const line = `${BUNDLE_CACHE_REL}/`;
|
|
95
|
+
let existing = "";
|
|
96
|
+
try {
|
|
97
|
+
existing = await readFile(giPath, "utf8");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// no .gitignore yet — create one below
|
|
101
|
+
}
|
|
102
|
+
if (existing.split("\n").some((l) => l.trim() === line || l.trim() === BUNDLE_CACHE_REL)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const prefixNewline = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
106
|
+
await appendFile(giPath, `${prefixNewline}# sdd-cli bundle cache (ADR 0176) — extracted bundle payloads, not source\n${line}\n`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=bundle-cache.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
function isUrl(s) {
|
|
6
|
+
return /^https?:\/\//i.test(s);
|
|
7
|
+
}
|
|
8
|
+
async function sha256File(path) {
|
|
9
|
+
const buf = await readFile(path);
|
|
10
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
/** Verify a tarball's sha256 against a pin; throw on mismatch. */
|
|
13
|
+
export async function verifySha256(localPath, expected) {
|
|
14
|
+
const actual = await sha256File(localPath);
|
|
15
|
+
if (actual.toLowerCase() !== expected.toLowerCase()) {
|
|
16
|
+
throw new Error(`bundle_sha256 mismatch for ${basename(localPath)}: ` +
|
|
17
|
+
`expected ${expected}, got ${actual}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fetch a remote bundle to a temp file. Uses the global `fetch` (Node 18+).
|
|
22
|
+
* Exported for tests that stub the fetcher.
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchToTemp(url, fetchImpl = fetch) {
|
|
25
|
+
const res = await fetchImpl(url);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error(`Failed to fetch bundle ${url}: HTTP ${res.status}`);
|
|
28
|
+
}
|
|
29
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), "sdd-cli-bundle-src-"));
|
|
31
|
+
const dest = join(dir, basename(new URL(url).pathname) || "bundle.tar.gz");
|
|
32
|
+
await writeFile(dest, buf);
|
|
33
|
+
return dest;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Materialise a config bundle entry to a verified local tarball path.
|
|
37
|
+
* Callers MUST invoke the returned `cleanup` after the sync completes.
|
|
38
|
+
*/
|
|
39
|
+
export async function materialiseBundle(entry, fetchImpl = fetch) {
|
|
40
|
+
if (isUrl(entry.source)) {
|
|
41
|
+
const localPath = await fetchToTemp(entry.source, fetchImpl);
|
|
42
|
+
const cleanup = async () => {
|
|
43
|
+
await rm(localPath, { force: true });
|
|
44
|
+
await rm(join(localPath, ".."), { recursive: true, force: true });
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
if (entry.sha256)
|
|
48
|
+
await verifySha256(localPath, entry.sha256);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
await cleanup();
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return { localPath, cleanup };
|
|
55
|
+
}
|
|
56
|
+
// Local source: verify in place, no temp resources to release.
|
|
57
|
+
if (entry.sha256)
|
|
58
|
+
await verifySha256(entry.source, entry.sha256);
|
|
59
|
+
return { localPath: entry.source, cleanup: async () => { } };
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=bundle-source.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
function isUrl(s) {
|
|
5
|
+
return /^https?:\/\//i.test(s);
|
|
6
|
+
}
|
|
7
|
+
export async function loadSyncConfig(configPath) {
|
|
8
|
+
const absPath = isAbsolute(configPath) ? configPath : resolve(configPath);
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await readFile(absPath, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
throw new Error(`Cannot read sync config ${absPath}: ${err.message}`);
|
|
15
|
+
}
|
|
16
|
+
const parsed = parseYaml(raw);
|
|
17
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
18
|
+
throw new Error(`Sync config ${absPath} is not a YAML object`);
|
|
19
|
+
}
|
|
20
|
+
const version = typeof parsed.version === "string" ? parsed.version : undefined;
|
|
21
|
+
if (!version) {
|
|
22
|
+
throw new Error(`Sync config ${absPath} missing required field: version`);
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(parsed.bundles)) {
|
|
25
|
+
throw new Error(`Sync config ${absPath} field "bundles" must be a list`);
|
|
26
|
+
}
|
|
27
|
+
if (parsed.bundles.length === 0) {
|
|
28
|
+
throw new Error(`Sync config ${absPath} field "bundles" must not be empty`);
|
|
29
|
+
}
|
|
30
|
+
const configDir = dirname(absPath);
|
|
31
|
+
const bundleSource = typeof parsed.bundle_source === "string" ? parsed.bundle_source : undefined;
|
|
32
|
+
// Pre-resolve the base for name/version resolution: a URL stays as-is;
|
|
33
|
+
// a local dir is resolved relative to the config file.
|
|
34
|
+
const resolvedBase = bundleSource === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: isUrl(bundleSource) || isAbsolute(bundleSource)
|
|
37
|
+
? bundleSource
|
|
38
|
+
: resolve(configDir, bundleSource);
|
|
39
|
+
const bundles = [];
|
|
40
|
+
for (const [i, entry] of parsed.bundles.entries()) {
|
|
41
|
+
const rawBundle = entry;
|
|
42
|
+
const src = typeof rawBundle.source === "string" ? rawBundle.source : undefined;
|
|
43
|
+
const name = typeof rawBundle.name === "string" ? rawBundle.name : undefined;
|
|
44
|
+
const ver = typeof rawBundle.version === "string" ? rawBundle.version : undefined;
|
|
45
|
+
const sha256 = typeof rawBundle.sha256 === "string" ? rawBundle.sha256 : undefined;
|
|
46
|
+
let source;
|
|
47
|
+
if (src) {
|
|
48
|
+
// Explicit source: a URL stays as-is; a path is resolved against the
|
|
49
|
+
// config dir (absolute paths pass through).
|
|
50
|
+
source = isUrl(src) || isAbsolute(src) ? src : resolve(configDir, src);
|
|
51
|
+
}
|
|
52
|
+
else if (name && ver) {
|
|
53
|
+
if (resolvedBase === undefined) {
|
|
54
|
+
throw new Error(`Sync config ${absPath} bundles[${i}] uses name+version but no ` +
|
|
55
|
+
`top-level "bundle_source" base is set to resolve it against.`);
|
|
56
|
+
}
|
|
57
|
+
const rel = `${name}/${name}-${ver}.tar.gz`;
|
|
58
|
+
source = isUrl(resolvedBase)
|
|
59
|
+
? `${resolvedBase.replace(/\/$/, "")}/${rel}`
|
|
60
|
+
: resolve(resolvedBase, name, `${name}-${ver}.tar.gz`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
throw new Error(`Sync config ${absPath} bundles[${i}] missing required field: source ` +
|
|
64
|
+
`(or a name+version pair resolved against bundle_source)`);
|
|
65
|
+
}
|
|
66
|
+
bundles.push(sha256 === undefined ? { source } : { source, sha256 });
|
|
67
|
+
}
|
|
68
|
+
return bundleSource === undefined
|
|
69
|
+
? { version, bundles }
|
|
70
|
+
: { version, bundleSource, bundles };
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.github/hooks/*.json` merge pass during `sdd-cli sync` when the
|
|
3
|
+
* adopter opted into `--agent-harness github-copilot-cli`.
|
|
4
|
+
*
|
|
5
|
+
* Method-managed event entries are sentinel-tagged via their
|
|
6
|
+
* `command.bash` / `command.powershell` strings. Adopter-added events
|
|
7
|
+
* in the same `events[]` array survive sync. Drift inside a method-
|
|
8
|
+
* managed event refuses without `--allow-overwrite`, symmetric to the
|
|
9
|
+
* Claude Code settings-merge path.
|
|
10
|
+
*/
|
|
11
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { buildMethodManagedCopilotHooks, detectCopilotHookDrift, upsertCopilotHookFile, } from "../hooks-seed/copilot.js";
|
|
14
|
+
export async function syncMethodManagedCopilotHooks(opts) {
|
|
15
|
+
const surface = buildMethodManagedCopilotHooks();
|
|
16
|
+
const entries = Object.entries(surface);
|
|
17
|
+
// First pass: detect drift across all files. If any drifted file
|
|
18
|
+
// would be refused (no --allow-overwrite), we refuse the whole
|
|
19
|
+
// merge without writing — same all-or-nothing posture as the
|
|
20
|
+
// Claude Code settings merge.
|
|
21
|
+
const reads = [];
|
|
22
|
+
for (const [portableClass, { filePath, events }] of entries) {
|
|
23
|
+
const absPath = join(opts.targetRepo, filePath);
|
|
24
|
+
const existing = await readHookFile(absPath);
|
|
25
|
+
const drifted = detectCopilotHookDrift(existing, events);
|
|
26
|
+
reads.push({
|
|
27
|
+
portableClass,
|
|
28
|
+
relPath: filePath,
|
|
29
|
+
absPath,
|
|
30
|
+
existing,
|
|
31
|
+
owned: events,
|
|
32
|
+
drifted,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const driftedFiles = reads.filter((r) => r.drifted).map((r) => r.relPath);
|
|
36
|
+
if (driftedFiles.length > 0 && !opts.allowOverwrite) {
|
|
37
|
+
return {
|
|
38
|
+
files: reads.map((r) => ({
|
|
39
|
+
relPath: r.relPath,
|
|
40
|
+
portableClass: r.portableClass,
|
|
41
|
+
drifted: r.drifted,
|
|
42
|
+
wrote: false,
|
|
43
|
+
})),
|
|
44
|
+
driftedRefused: driftedFiles,
|
|
45
|
+
refused: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (opts.checkOnly) {
|
|
49
|
+
return {
|
|
50
|
+
files: reads.map((r) => ({
|
|
51
|
+
relPath: r.relPath,
|
|
52
|
+
portableClass: r.portableClass,
|
|
53
|
+
drifted: r.drifted,
|
|
54
|
+
wrote: false,
|
|
55
|
+
})),
|
|
56
|
+
driftedRefused: [],
|
|
57
|
+
refused: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Second pass: upsert + write.
|
|
61
|
+
const fileResults = [];
|
|
62
|
+
for (const r of reads) {
|
|
63
|
+
const merge = upsertCopilotHookFile(r.existing, r.owned);
|
|
64
|
+
let wrote = false;
|
|
65
|
+
if (merge.changed || r.existing === null) {
|
|
66
|
+
await mkdir(dirname(r.absPath), { recursive: true });
|
|
67
|
+
await writeFile(r.absPath, JSON.stringify(merge.file, null, 2) + "\n", "utf8");
|
|
68
|
+
wrote = true;
|
|
69
|
+
}
|
|
70
|
+
fileResults.push({
|
|
71
|
+
relPath: r.relPath,
|
|
72
|
+
portableClass: r.portableClass,
|
|
73
|
+
drifted: r.drifted,
|
|
74
|
+
wrote,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return { files: fileResults, driftedRefused: [], refused: false };
|
|
78
|
+
}
|
|
79
|
+
async function readHookFile(absPath) {
|
|
80
|
+
if (!(await fileExists(absPath)))
|
|
81
|
+
return null;
|
|
82
|
+
const raw = await readFile(absPath, "utf8");
|
|
83
|
+
if (raw.trim().length === 0)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
88
|
+
const obj = parsed;
|
|
89
|
+
if (Array.isArray(obj["events"])) {
|
|
90
|
+
return obj;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* fall through — treat malformed as empty so the next write reseats it */
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
async function fileExists(path) {
|
|
100
|
+
try {
|
|
101
|
+
await access(path);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=copilot-hooks-merge.js.map
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const PROTECTED_PREFIXES = ["content/"];
|
|
5
|
+
/** True when every conflict is provably untouched since the last sync. */
|
|
6
|
+
export function allPristine(c) {
|
|
7
|
+
return (c.pristine.length > 0 &&
|
|
8
|
+
c.edited.length === 0 &&
|
|
9
|
+
c.unclassifiable.length === 0);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Classify each conflicting path against the digests the previous sync recorded.
|
|
13
|
+
*
|
|
14
|
+
* Compares the file ON DISK to the digest of what was last APPLIED — not to the
|
|
15
|
+
* incoming payload, which `findConflicts` has already established differs. A
|
|
16
|
+
* match therefore means "untouched since the last sync", which is precisely the
|
|
17
|
+
* question `--allow-overwrite` should have been asking.
|
|
18
|
+
*/
|
|
19
|
+
export async function classifyConflicts(conflicts, targetRepo, priorDigests) {
|
|
20
|
+
const pristine = [];
|
|
21
|
+
const edited = [];
|
|
22
|
+
const unclassifiable = [];
|
|
23
|
+
for (const p of conflicts) {
|
|
24
|
+
const recorded = priorDigests?.[p];
|
|
25
|
+
if (!recorded) {
|
|
26
|
+
unclassifiable.push(p);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let actual;
|
|
30
|
+
try {
|
|
31
|
+
actual = sha256Buffer(await readFile(join(targetRepo, p)));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Unreadable is not "pristine". Anything we cannot hash is undecidable.
|
|
35
|
+
if (err.code === "ENOENT")
|
|
36
|
+
continue;
|
|
37
|
+
unclassifiable.push(p);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (actual === recorded)
|
|
41
|
+
pristine.push(p);
|
|
42
|
+
else
|
|
43
|
+
edited.push(p);
|
|
44
|
+
}
|
|
45
|
+
return { pristine, edited, unclassifiable };
|
|
46
|
+
}
|
|
47
|
+
/** sha256 of each path's content under `root`, keyed by path — what a sync records. */
|
|
48
|
+
export async function digestManagedPaths(paths, root) {
|
|
49
|
+
const digests = {};
|
|
50
|
+
for (const p of paths) {
|
|
51
|
+
try {
|
|
52
|
+
digests[p] = sha256Buffer(await readFile(join(root, p)));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// A path with no readable file records no digest, rather than a wrong one.
|
|
56
|
+
if (err.code !== "ENOENT")
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return digests;
|
|
61
|
+
}
|
|
62
|
+
function sha256Buffer(buf) {
|
|
63
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
export function findProtectedPaths(paths) {
|
|
66
|
+
return paths.filter((p) => PROTECTED_PREFIXES.some((prefix) => p.startsWith(prefix)));
|
|
67
|
+
}
|
|
68
|
+
export async function findMissingPayloadFiles(incomingPaths, payloadDir) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
for (const p of incomingPaths) {
|
|
71
|
+
try {
|
|
72
|
+
await readFile(join(payloadDir, p));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err.code === "ENOENT")
|
|
76
|
+
missing.push(p);
|
|
77
|
+
else
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return missing;
|
|
82
|
+
}
|
|
83
|
+
export async function findConflicts(incomingPaths, payloadDir, targetRepo) {
|
|
84
|
+
const conflicts = [];
|
|
85
|
+
for (const p of incomingPaths) {
|
|
86
|
+
const targetPath = join(targetRepo, p);
|
|
87
|
+
let targetContent;
|
|
88
|
+
try {
|
|
89
|
+
targetContent = await readFile(targetPath);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err.code === "ENOENT")
|
|
93
|
+
continue;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const bundleContent = await readFile(join(payloadDir, p));
|
|
97
|
+
if (!targetContent.equals(bundleContent)) {
|
|
98
|
+
conflicts.push(p);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return conflicts;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Paths this CLI GENERATES or MERGES rather than materialising from a payload,
|
|
105
|
+
* and which must therefore never be removed by omission from a payload.
|
|
106
|
+
*
|
|
107
|
+
* WHY THIS EXISTS — measured, not anticipated
|
|
108
|
+
* `.claude/settings.json` used to be payload-shipped and is now merge-managed.
|
|
109
|
+
* A repo whose last sync recorded it in `managed_paths` therefore hits this:
|
|
110
|
+
* the path is absent from the incoming `MANAGED_PATHS.txt`, so `computeRemovals`
|
|
111
|
+
* schedules it for deletion — and `removeManagedFiles` runs BEFORE
|
|
112
|
+
* `syncMethodManagedSettings`, whose `readSettings` returns `{}` for a file that
|
|
113
|
+
* is not there. So the merge's documented contract ("adopter-added entries and
|
|
114
|
+
* other top-level keys are preserved") is void on exactly the transition where
|
|
115
|
+
* ownership moved, and the adopter's `permissions`, `env` and their own
|
|
116
|
+
* `mcpServers` entries are lost silently, with exit 0 and a "Refreshed" log line
|
|
117
|
+
* that says "added" because it was seeding from empty.
|
|
118
|
+
*
|
|
119
|
+
* That was measured on a real repo copy in a 30-repo estate, one apply short of
|
|
120
|
+
* shipping. 30 of 65 repos were scheduled for the deletion.
|
|
121
|
+
*
|
|
122
|
+
* THE INVARIANT — a merge-managed path is never payload-managed, so its absence
|
|
123
|
+
* from a payload carries NO information about whether it should exist. Only the
|
|
124
|
+
* component that owns it may decide that. Enforced here rather than at the call
|
|
125
|
+
* site so a second caller cannot reintroduce the defect: this is a property of
|
|
126
|
+
* the paths, not of any one sync flow.
|
|
127
|
+
*
|
|
128
|
+
* ⚠ Ownership migrations (payload → generated) are the general hazard. Any future
|
|
129
|
+
* path that moves this way must be added here IN THE SAME CHANGE, or the first
|
|
130
|
+
* adopter to sync across the migration silently loses whatever they had.
|
|
131
|
+
*/
|
|
132
|
+
export const GENERATED_NEVER_REMOVE = [
|
|
133
|
+
// merge-managed: settings-merge.ts (adopter keys preserved by contract)
|
|
134
|
+
".claude/settings.json",
|
|
135
|
+
// rendered: init/ci-workflow.ts
|
|
136
|
+
".github/workflows/method-validation.yml",
|
|
137
|
+
// merge-managed: briefing-merge.ts (wrapper keeps adopter content below markers)
|
|
138
|
+
"CLAUDE.md",
|
|
139
|
+
"AGENTS.md",
|
|
140
|
+
"agent-instructions.md",
|
|
141
|
+
];
|
|
142
|
+
/** `.github/hooks/<event>.json` — rendered per-event by hooks-seed/copilot.ts. */
|
|
143
|
+
const GENERATED_PREFIXES = [".github/hooks/"];
|
|
144
|
+
/** True when this path's existence is owned by a generator, not by a payload. */
|
|
145
|
+
export function isGeneratedPath(p) {
|
|
146
|
+
return (GENERATED_NEVER_REMOVE.includes(p) ||
|
|
147
|
+
GENERATED_PREFIXES.some((prefix) => p.startsWith(prefix)));
|
|
148
|
+
}
|
|
149
|
+
export function computeRemovals(prevPaths, incomingPaths, options) {
|
|
150
|
+
if (options.noDelete)
|
|
151
|
+
return [];
|
|
152
|
+
const incoming = new Set(incomingPaths);
|
|
153
|
+
return prevPaths.filter((p) => !incoming.has(p) && !isGeneratedPath(p));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* SHADOW COLLISIONS — an incoming managed file whose basename already exists at
|
|
157
|
+
* a SHALLOWER, unmanaged path in the adopter's repo (ledger §3ap).
|
|
158
|
+
*
|
|
159
|
+
* When the distributed surface MOVES a file, copies previously delivered at the
|
|
160
|
+
* old location are stranded as unmanaged files. They keep resolving, so nothing
|
|
161
|
+
* breaks loudly, and they go stale silently as their managed twins evolve. One
|
|
162
|
+
* estate carried six such shadows, every one stale — including a rubric its own
|
|
163
|
+
* scoring tooling was still reading, so a repaired anchor shipped and was
|
|
164
|
+
* simultaneously unreachable through the tools meant to apply it.
|
|
165
|
+
*
|
|
166
|
+
* The quiet failure is the one that needs machinery: a rename that breaks its
|
|
167
|
+
* citations announces itself, and this one does not. Flagging the collision as
|
|
168
|
+
* it is created beats a per-estate census finding it months later.
|
|
169
|
+
*
|
|
170
|
+
* ⚠ BASENAME EQUALITY IS NECESSARY AND NOWHERE NEAR SUFFICIENT — learned on
|
|
171
|
+
* this detector's FIRST LIVE RUN, from the estate that reported the class.
|
|
172
|
+
* It fired on a repo's own top-level `README.md` against the managed
|
|
173
|
+
* `docs/method/README.md`: 24KB "# {Adopter Product}" versus 10KB "# `docs/method/`",
|
|
174
|
+
* two lines in common, two documents that merely share a filename. That is not
|
|
175
|
+
* a stranded copy and never could be, and it would have fired in 24 of 40 repos.
|
|
176
|
+
*
|
|
177
|
+
* The defect was building the detector from the CLASS'S SYMPTOM instead of its
|
|
178
|
+
* cause. In every real instance the shadow was a FORMER COPY of the managed
|
|
179
|
+
* file — same basename AND same subject — left behind by a move. So common
|
|
180
|
+
* ancestry is the discriminator, and `looksRelated` supplies it: a candidate
|
|
181
|
+
* must resemble the managed file's content, not merely echo its name.
|
|
182
|
+
*
|
|
183
|
+
* `exists` and `read` are injected so this is testable without a filesystem,
|
|
184
|
+
* and so the rule itself can be read without reading a sync flow.
|
|
185
|
+
*/
|
|
186
|
+
export function detectShadowCollisions(managedPaths, exists, read) {
|
|
187
|
+
const managedSet = new Set(managedPaths);
|
|
188
|
+
const seenBasename = new Map();
|
|
189
|
+
for (const rel of managedPaths) {
|
|
190
|
+
const base = rel.slice(rel.lastIndexOf("/") + 1);
|
|
191
|
+
if (!seenBasename.has(base))
|
|
192
|
+
seenBasename.set(base, rel);
|
|
193
|
+
}
|
|
194
|
+
const shadows = [];
|
|
195
|
+
for (const [base, managedRel] of seenBasename) {
|
|
196
|
+
const parts = managedRel.split("/");
|
|
197
|
+
// Walk the managed path's own ancestors, shallowest first: a shadow is the
|
|
198
|
+
// same basename ABOVE the file that now owns the name. A deeper twin is a
|
|
199
|
+
// different artefact, not a stranded copy.
|
|
200
|
+
for (let cut = 0; cut < parts.length - 1; cut++) {
|
|
201
|
+
const candidate = [...parts.slice(0, cut), base].join("/");
|
|
202
|
+
if (candidate === managedRel)
|
|
203
|
+
continue;
|
|
204
|
+
// A path the incoming bundle also manages is not a shadow — it is
|
|
205
|
+
// delivered content, and sync owns both.
|
|
206
|
+
if (managedSet.has(candidate))
|
|
207
|
+
continue;
|
|
208
|
+
if (exists(candidate) && looksRelated(candidate, managedRel, read)) {
|
|
209
|
+
shadows.push({ shadow: candidate, managed: managedRel });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return shadows;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Do these two files look like copies of one another?
|
|
218
|
+
*
|
|
219
|
+
* Deliberately crude and deliberately CONSERVATIVE: a stranded copy diverges by
|
|
220
|
+
* drift, so it keeps most of its lines; two unrelated files sharing a name share
|
|
221
|
+
* almost none. Without a reader (no filesystem access) it returns FALSE — the
|
|
222
|
+
* detector stays silent rather than guessing, because a false shadow costs a
|
|
223
|
+
* reader's attention on every sync while a missed one is found by the standing
|
|
224
|
+
* per-estate census.
|
|
225
|
+
*/
|
|
226
|
+
function looksRelated(shadowPath, managedPath, read) {
|
|
227
|
+
if (!read)
|
|
228
|
+
return false;
|
|
229
|
+
const a = read(shadowPath);
|
|
230
|
+
const b = read(managedPath);
|
|
231
|
+
if (a === null || b === null)
|
|
232
|
+
return false;
|
|
233
|
+
const lines = (t) => new Set(t
|
|
234
|
+
.split("\n")
|
|
235
|
+
.map((l) => l.trim())
|
|
236
|
+
.filter((l) => l.length > 20));
|
|
237
|
+
const A = lines(a);
|
|
238
|
+
const B = lines(b);
|
|
239
|
+
if (A.size === 0 || B.size === 0)
|
|
240
|
+
return false;
|
|
241
|
+
let shared = 0;
|
|
242
|
+
for (const l of A)
|
|
243
|
+
if (B.has(l))
|
|
244
|
+
shared++;
|
|
245
|
+
// Share of the SMALLER file's substantive lines. A stale copy of a file that
|
|
246
|
+
// has since grown is still a copy, so the smaller side is the right
|
|
247
|
+
// denominator — using the larger one would miss exactly the drifted shadows
|
|
248
|
+
// this exists to catch.
|
|
249
|
+
return shared / Math.min(A.size, B.size) >= 0.3;
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=diff.js.map
|