@sdd-method/author-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/author-bin.js +8 -0
- package/dist/author-program.js +99 -0
- package/dist/lib/adr-origin.js +59 -0
- package/dist/lib/agent-harness.js +37 -0
- package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +18 -0
- package/dist/lib/api-surface/analyzers/library.js +95 -0
- package/dist/lib/api-surface/analyzers/serverless.js +336 -0
- package/dist/lib/api-surface/analyzers/spring.js +237 -0
- package/dist/lib/api-surface/analyzers/terraform.js +109 -0
- package/dist/lib/api-surface/detect.js +25 -0
- package/dist/lib/api-surface/emit.js +109 -0
- package/dist/lib/api-surface/file-set.js +43 -0
- package/dist/lib/api-surface/index.js +134 -0
- package/dist/lib/api-surface/types.js +17 -0
- package/dist/lib/briefing/index.js +13 -0
- package/dist/lib/briefing/render.js +132 -0
- package/dist/lib/briefing/variables.js +92 -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/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-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 +70 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +346 -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/conformance/binding.js +70 -0
- package/dist/lib/conformance/finding.js +69 -0
- package/dist/lib/conformance/index.js +578 -0
- package/dist/lib/context-pull/index.js +191 -0
- package/dist/lib/context-pull/resolve.js +70 -0
- package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
- package/dist/lib/contracts-availability/index.js +267 -0
- package/dist/lib/generate-briefing/index.js +77 -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/pack/log.js +18 -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/plan/index.js +69 -0
- package/dist/lib/plan/read-sdd.js +102 -0
- package/dist/lib/plan/render-template.js +84 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scaffold/domain.js +234 -0
- package/dist/lib/scaffold/registry.js +136 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/sdd-index/repo-config.js +245 -0
- package/dist/lib/sdd-reader.js +91 -0
- package/dist/lib/snapshot/advance.js +454 -0
- package/dist/lib/snapshot/diff.js +190 -0
- package/dist/lib/snapshot/fold.js +208 -0
- package/dist/lib/snapshot/land.js +178 -0
- package/dist/lib/snapshot/manifest.js +206 -0
- package/dist/lib/snapshot/targets.js +169 -0
- package/dist/lib/snapshot/verify.js +91 -0
- package/dist/lib/sync/briefing-merge.js +106 -0
- package/dist/lib/sync/bundle-cache.js +109 -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-version.json +4 -0
- package/dist/verbs/api-surface.js +71 -0
- package/dist/verbs/catalogue-check.js +28 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/conformance.js +34 -0
- package/dist/verbs/context.js +39 -0
- package/dist/verbs/contracts.js +34 -0
- package/dist/verbs/generate-briefing.js +29 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/plan.js +42 -0
- package/dist/verbs/render.js +87 -0
- package/dist/verbs/scaffold.js +83 -0
- package/dist/verbs/snapshot.js +134 -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,454 @@
|
|
|
1
|
+
// `sdd-cli snapshot advance` — move each member's evidence pin to T1 and
|
|
2
|
+
// append one history entry (delta-reverse.md §8).
|
|
3
|
+
//
|
|
4
|
+
// Refusals, each a failure a hand run made:
|
|
5
|
+
// - no delta.yaml for T1 (the pin advanced before the diff existed);
|
|
6
|
+
// - no `impact` recorded (step 5a not done);
|
|
7
|
+
// - a legacy `entries:` history, unless --migrate-history converts it first;
|
|
8
|
+
// - a `phase:` key or a `reverse_status` outside {classified, reversed};
|
|
9
|
+
// - a source_path with no dated baseline segment (the area is not folded);
|
|
10
|
+
// - a finding of the member still unclassified (step 5 before step 8);
|
|
11
|
+
// - a pin already at the target revision, moved by hand before the tool
|
|
12
|
+
// existed (--record-only writes the row without moving it), or at a
|
|
13
|
+
// revision that is neither the prior baseline's nor the target's.
|
|
14
|
+
// `impact: none` is decided from the two MANIFESTS (same revision at T0 and
|
|
15
|
+
// T1), never from the pin: a pin moved by hand already holds T1's revision, and
|
|
16
|
+
// reading it would record a changed member as unchanged.
|
|
17
|
+
// It checks everything before it writes anything, and it only ever APPENDS:
|
|
18
|
+
// after writing it re-reads each history and asserts the prior entries are
|
|
19
|
+
// unchanged and exactly one was added. Entries are never cut or rewritten —
|
|
20
|
+
// for an unchanged member the T0 and T1 entries share folder name and
|
|
21
|
+
// revision, so any edit keyed on those removes both.
|
|
22
|
+
import { isDeepStrictEqual } from "node:util";
|
|
23
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
24
|
+
import { join, relative, sep } from "node:path";
|
|
25
|
+
import { citesPath } from "./fold.js";
|
|
26
|
+
import { parse, parseDocument, stringify } from "yaml";
|
|
27
|
+
// Both writers below edit TEXT, not a re-serialised document: re-serialising
|
|
28
|
+
// reflows every long string in the file, so an evidence pin would arrive with
|
|
29
|
+
// unrelated churn in lines the tool had no business touching.
|
|
30
|
+
import { deltaPath } from "./diff.js";
|
|
31
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
32
|
+
import { SnapshotError, credentialNotice, exists, isIsoDate, readManifest, splitFolder, } from "./manifest.js";
|
|
33
|
+
export const REVERSE_STATUSES = ["classified", "reversed"];
|
|
34
|
+
export const IMPACTS = ["none", "repo-minor", "repo-major", "product-major"];
|
|
35
|
+
/**
|
|
36
|
+
* Hand-written prose in a member's evidence files that still names the prior
|
|
37
|
+
* baseline or its folder: comment lines anywhere, and non-field lines of the
|
|
38
|
+
* metadata. The tool never rewrites prose — this is a list, not a control.
|
|
39
|
+
*/
|
|
40
|
+
export async function staleProse(dir, priorId, priorFolder) {
|
|
41
|
+
const needles = [priorId, priorFolder].filter((x) => typeof x === "string" && x !== "");
|
|
42
|
+
if (needles.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const name of ["reverse-metadata.yaml", "reverse-history.yaml"]) {
|
|
46
|
+
const file = join(dir, name);
|
|
47
|
+
if (!(await exists(file)))
|
|
48
|
+
continue;
|
|
49
|
+
const lines = (await readFile(file, "utf8")).split("\n");
|
|
50
|
+
// History ENTRIES name the prior baseline by design; only its comments are prose.
|
|
51
|
+
lines.forEach((line, i) => {
|
|
52
|
+
const prose = name === "reverse-history.yaml" ? /^\s*#/.test(line) : !/^[A-Za-z_][\w-]*:\s*\S*\s*$/.test(line) || /^notes:/.test(line);
|
|
53
|
+
// A history header describes the pin's state in words ("the T1 entry is appended
|
|
54
|
+
// when…"), so there a T0/T1 mention is enough; elsewhere it must name the baseline.
|
|
55
|
+
const header = name === "reverse-history.yaml" && /\bT[01]\b/.test(line);
|
|
56
|
+
if (prose && (header || needles.some((n) => line.includes(n))))
|
|
57
|
+
out.push(`${name}:${i + 1}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const BRANCH = /-(master|main|develop|trunk)$/;
|
|
63
|
+
/**
|
|
64
|
+
* The snapshot identity of a metadata file, deriving what the folder-name
|
|
65
|
+
* convention `<repo>-<branch>@<rev>` makes certain: the revision always, the
|
|
66
|
+
* branch only when the suffix is a known branch name. What was derived is
|
|
67
|
+
* returned so the caller can say so.
|
|
68
|
+
*/
|
|
69
|
+
export function identityOf(metadata) {
|
|
70
|
+
const str = (v) => (v === undefined || v === null || v === "" ? undefined : String(v));
|
|
71
|
+
const folder = str(metadata.exported_folder_name);
|
|
72
|
+
const split = folder ? splitFolder(folder) : null;
|
|
73
|
+
const derived = [];
|
|
74
|
+
let rev = str(metadata.source_revision);
|
|
75
|
+
if (!rev && split) {
|
|
76
|
+
rev = split.revision;
|
|
77
|
+
derived.push("source_revision");
|
|
78
|
+
}
|
|
79
|
+
let branch = str(metadata.exported_branch);
|
|
80
|
+
if (!branch && split) {
|
|
81
|
+
branch = split.stem.match(BRANCH)?.[1];
|
|
82
|
+
if (branch)
|
|
83
|
+
derived.push("exported_branch");
|
|
84
|
+
}
|
|
85
|
+
const fields = {};
|
|
86
|
+
const missing = [];
|
|
87
|
+
for (const [k, v] of [["exported_folder_name", folder], ["exported_branch", branch], ["source_revision", rev], ["source_path", str(metadata.source_path)]]) {
|
|
88
|
+
if (v)
|
|
89
|
+
fields[k] = v;
|
|
90
|
+
else
|
|
91
|
+
missing.push(k);
|
|
92
|
+
}
|
|
93
|
+
return { fields, derived, missing };
|
|
94
|
+
}
|
|
95
|
+
export async function runSnapshotAdvance(inputs, write) {
|
|
96
|
+
try {
|
|
97
|
+
const plans = await advance(inputs, write);
|
|
98
|
+
write(`advanced ${plans.length} member(s) to ${inputs.to}\n`);
|
|
99
|
+
return { exitCode: 0 };
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e instanceof SnapshotError) {
|
|
103
|
+
write(`REFUSED (nothing written): ${e.message}\n`);
|
|
104
|
+
return { exitCode: 1 };
|
|
105
|
+
}
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function evidenceDirs(sddPath) {
|
|
110
|
+
const out = new Map();
|
|
111
|
+
const repos = join(sddPath, "repos");
|
|
112
|
+
if (!(await exists(repos)))
|
|
113
|
+
return out;
|
|
114
|
+
for (const e of await readdir(repos, { withFileTypes: true })) {
|
|
115
|
+
if (!e.isDirectory())
|
|
116
|
+
continue;
|
|
117
|
+
const md = join(repos, e.name, "reverse-metadata.yaml");
|
|
118
|
+
if (!(await exists(md)))
|
|
119
|
+
continue;
|
|
120
|
+
const key = parse(await readFile(md, "utf8"))?.canonical_repo_key;
|
|
121
|
+
out.set(key ?? e.name, join(repos, e.name));
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Convert a legacy `entries:` history. Only `phase: scan` rows convert mechanically. */
|
|
126
|
+
export function migrateLegacy(legacy, metadata) {
|
|
127
|
+
const entries = (legacy.entries ?? []);
|
|
128
|
+
const other = entries.filter((e) => e.phase !== undefined && e.phase !== "scan");
|
|
129
|
+
if (other.length > 0) {
|
|
130
|
+
throw new SnapshotError(`legacy history has ${other.length} row(s) with phase other than 'scan' (${[...new Set(other.map((e) => String(e.phase)))].join(", ")}): ` +
|
|
131
|
+
"which snapshot each describes cannot be derived, so convert those by hand");
|
|
132
|
+
}
|
|
133
|
+
const id = identityOf(metadata);
|
|
134
|
+
if (id.missing.length > 0) {
|
|
135
|
+
throw new SnapshotError(`cannot migrate: reverse-metadata.yaml has no ${id.missing.join(", ")}, and the folder name does not supply it` +
|
|
136
|
+
(id.missing.includes("exported_folder_name") || id.missing.includes("source_path")
|
|
137
|
+
? " — if the area was folded, re-point the evidence pins first (`snapshot fold --sdd-path`, or by hand)"
|
|
138
|
+
: ""));
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
derived: id.derived,
|
|
142
|
+
canonical_repo_key: metadata.canonical_repo_key,
|
|
143
|
+
history: entries.map((e) => ({
|
|
144
|
+
...id.fields,
|
|
145
|
+
scanned_at: e.date,
|
|
146
|
+
reverse_status: "reversed",
|
|
147
|
+
notes: `${String(e.notes ?? "").trim()} (Migrated from the legacy entries: shape — originally phase: ${String(e.phase ?? "unset")}, actor: ${String(e.actor ?? "unset")}; identity fields taken from reverse-metadata.yaml at migration${id.derived.length > 0 ? `, ${id.derived.join(" and ")} derived from the folder name` : ""}.)`.trim(),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function checkHistory(file, h) {
|
|
152
|
+
const problems = [];
|
|
153
|
+
const hist = h.history;
|
|
154
|
+
if (!Array.isArray(hist))
|
|
155
|
+
return [`${file}: no history[] list`];
|
|
156
|
+
for (const [i, e] of hist.entries()) {
|
|
157
|
+
if (e.phase !== undefined)
|
|
158
|
+
problems.push(`${file}: history[${i}] has a 'phase' key — not a field of the convention`);
|
|
159
|
+
const s = e.reverse_status;
|
|
160
|
+
if (s !== undefined && !REVERSE_STATUSES.includes(s)) {
|
|
161
|
+
problems.push(`${file}: history[${i}].reverse_status '${String(s)}' is not one of ${REVERSE_STATUSES.join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return problems;
|
|
165
|
+
}
|
|
166
|
+
/** Re-point a source_path at another baseline and folder; null if it has no dated segment. */
|
|
167
|
+
export function repoint(sourcePath, newBaseline, newFolder) {
|
|
168
|
+
const parts = sourcePath.split("/");
|
|
169
|
+
const i = parts.findIndex((p) => isIsoDate(p));
|
|
170
|
+
if (i < 0)
|
|
171
|
+
return null;
|
|
172
|
+
return [...parts.slice(0, i), newBaseline, newFolder].join("/");
|
|
173
|
+
}
|
|
174
|
+
export async function advance(inputs, write) {
|
|
175
|
+
const { sddPath, areaRoot, to } = inputs;
|
|
176
|
+
const t1 = await readManifest(areaRoot, to);
|
|
177
|
+
const dispositions = new Set(((await loadCanonicalSchema()).enums["delta_disposition"]?.values ?? []).map((v) => v.value));
|
|
178
|
+
const dp = deltaPath(areaRoot, to);
|
|
179
|
+
if (!(await exists(dp))) {
|
|
180
|
+
throw new SnapshotError(`no ${dp}: the pin advances after the delta is recorded (run snapshot diff, then classify)`);
|
|
181
|
+
}
|
|
182
|
+
const delta = parse(await readFile(dp, "utf8"));
|
|
183
|
+
const provenance = delta.t0_provenance?.result;
|
|
184
|
+
const provenanceNote = provenance ? ` T0 provenance: ${provenance}${delta.t0_provenance?.waiver?.who ? ` (waiver: ${delta.t0_provenance.waiver.who})` : ""}.` : "";
|
|
185
|
+
if (provenance && !/^pass$/.test(provenance)) {
|
|
186
|
+
write(`T0 PROVENANCE is not a clean pass for ${delta.from_snapshot ?? "the prior baseline"}: ${provenance} — delta-reverse §3: docs are read as stale only if T0 was valid\n`);
|
|
187
|
+
}
|
|
188
|
+
const fromId = delta.from_snapshot ?? t1.prior_snapshot;
|
|
189
|
+
const t0 = fromId ? await readManifest(areaRoot, fromId) : null;
|
|
190
|
+
const t0Rev = new Map((t0?.members ?? []).map((x) => [x.canonical_repo_key, x.source_revision]));
|
|
191
|
+
const notice = credentialNotice(t1);
|
|
192
|
+
if (notice)
|
|
193
|
+
write(`${notice}\n`);
|
|
194
|
+
if (!delta.impact)
|
|
195
|
+
throw new SnapshotError(`${dp} has no impact: classify first (delta-reverse.md §5a)`);
|
|
196
|
+
const impactOk = (v) => IMPACTS.includes(v);
|
|
197
|
+
if (!impactOk(delta.impact))
|
|
198
|
+
throw new SnapshotError(`${dp}: impact '${delta.impact}' is not one of ${IMPACTS.join(", ")}`);
|
|
199
|
+
const changed = new Map((delta.members_changed ?? []).map((c) => [c.canonical_repo_key, c]));
|
|
200
|
+
const dirs = await evidenceDirs(sddPath);
|
|
201
|
+
const wanted = inputs.members ? new Set(inputs.members) : null;
|
|
202
|
+
const problems = [];
|
|
203
|
+
const plans = [];
|
|
204
|
+
for (const m of t1.members) {
|
|
205
|
+
if (wanted && !wanted.has(m.canonical_repo_key))
|
|
206
|
+
continue;
|
|
207
|
+
if (m.carried_from !== undefined)
|
|
208
|
+
continue; // carried: no new entry — the manifest records where its bytes are
|
|
209
|
+
const key = m.canonical_repo_key;
|
|
210
|
+
const dir = dirs.get(key);
|
|
211
|
+
if (!dir) {
|
|
212
|
+
problems.push(`${key}: no repos/*/reverse-metadata.yaml with this canonical_repo_key — a new member is seeded (step 7) before its pin advances`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const hFile = join(dir, "reverse-history.yaml");
|
|
216
|
+
const mFile = join(dir, "reverse-metadata.yaml");
|
|
217
|
+
const metadataText = await readFile(mFile, "utf8");
|
|
218
|
+
const metadata = parse(metadataText);
|
|
219
|
+
let history = (await exists(hFile)) ? parse(await readFile(hFile, "utf8")) : { canonical_repo_key: key, history: [] };
|
|
220
|
+
let migrated = false;
|
|
221
|
+
if (Array.isArray(history.entries)) {
|
|
222
|
+
if (!inputs.migrateHistory) {
|
|
223
|
+
problems.push(`${hFile}: legacy entries: history — pass --migrate-history to convert it first`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
// Legacy rows describe the snapshot the pin held when they were written. If
|
|
228
|
+
// the pin was moved to T1 by hand, that was T0 — so T0's identity, never
|
|
229
|
+
// the current pin's, or a pre-T1 scan row would claim T1's revision.
|
|
230
|
+
let source = metadata;
|
|
231
|
+
const t0m = t0?.members.find((x) => x.canonical_repo_key === key);
|
|
232
|
+
const pinnedNow = identityOf(metadata).fields.source_revision;
|
|
233
|
+
if (t0m && pinnedNow === m.source_revision && t0m.source_revision !== m.source_revision) {
|
|
234
|
+
const t0Path = repoint(String(metadata.source_path ?? ""), t0m.carried_from ?? t0.snapshot_id, t0m.exported_folder_name);
|
|
235
|
+
source = { ...metadata, exported_folder_name: t0m.exported_folder_name, source_revision: t0m.source_revision, exported_branch: undefined, source_path: t0Path ?? undefined };
|
|
236
|
+
write(`${key}: pin was moved by hand, so its legacy rows take ${fromId}'s identity (${t0m.exported_folder_name})\n`);
|
|
237
|
+
}
|
|
238
|
+
const mig = migrateLegacy(history, source);
|
|
239
|
+
// A row cannot describe a baseline observed after it was written.
|
|
240
|
+
const rows = mig.history;
|
|
241
|
+
const baseline = String(rows[0]?.source_path ?? "").split("/").find((p) => isIsoDate(p));
|
|
242
|
+
const early = rows.filter((e) => baseline !== undefined && String(e.scanned_at) < baseline);
|
|
243
|
+
if (early.length > 0) {
|
|
244
|
+
throw new SnapshotError(`${early.length} legacy row(s) dated before baseline ${baseline}, the one their identity would name — which snapshot they describe must be set by hand`);
|
|
245
|
+
}
|
|
246
|
+
const { derived, ...doc } = mig;
|
|
247
|
+
// The key the evidence dir was matched by, where the metadata does not carry one.
|
|
248
|
+
history = { ...doc, canonical_repo_key: doc.canonical_repo_key ?? key };
|
|
249
|
+
migrated = true;
|
|
250
|
+
for (const d of derived)
|
|
251
|
+
write(`${key}: ${d} derived from folder name ${metadata.exported_folder_name}\n`);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
problems.push(`${hFile}: ${e.message}`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
problems.push(...checkHistory(hFile, history));
|
|
259
|
+
const hist = history.history;
|
|
260
|
+
const last = hist[hist.length - 1];
|
|
261
|
+
const scannedAt = m.observed_at ?? t1.observed_at;
|
|
262
|
+
// "Already at" means already RECORDED as reversed there. A seed row (`classified`)
|
|
263
|
+
// for the T1 folder is the start of that member's T1 work, not the end of it —
|
|
264
|
+
// skipping on it left seeded members without their reversed row.
|
|
265
|
+
if (last && last.exported_folder_name === m.exported_folder_name && last.scanned_at === scannedAt && last.reverse_status === "reversed") {
|
|
266
|
+
write(`${key}: already at ${to} — skipped\n`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const newPath = repoint(String(metadata.source_path ?? ""), to, m.exported_folder_name);
|
|
270
|
+
if (newPath === null) {
|
|
271
|
+
problems.push(`${mFile}: source_path '${metadata.source_path}' has no dated baseline segment — fold the area first`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const priorRev = t0Rev.get(key);
|
|
275
|
+
const pinned = identityOf(metadata).fields.source_revision;
|
|
276
|
+
let movePin = true;
|
|
277
|
+
if (priorRev === undefined) {
|
|
278
|
+
// A NEW member has no prior baseline, so its pin is never "moved by hand": step 7
|
|
279
|
+
// seeded it at T1, whatever date the seed row carries. Recorded as reversed → done;
|
|
280
|
+
// seeded only → append its reversed row (the pin is already at T1); unseeded → refuse.
|
|
281
|
+
const t1Rows = hist.filter((e) => e.exported_folder_name === m.exported_folder_name);
|
|
282
|
+
if (t1Rows.some((e) => e.reverse_status === "reversed")) {
|
|
283
|
+
write(`${key}: new member, already recorded as reversed at ${to} — skipped\n`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (t1Rows.length === 0) {
|
|
287
|
+
problems.push(`${key}: new member with no history row for ${m.exported_folder_name} — seed it (step 7) before its pin advances`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
movePin = false;
|
|
291
|
+
}
|
|
292
|
+
const sameRevision = priorRev !== undefined && priorRev === m.source_revision;
|
|
293
|
+
if (priorRev === undefined) {
|
|
294
|
+
// handled above: the seed put the pin at T1
|
|
295
|
+
}
|
|
296
|
+
else if (pinned === m.source_revision && !sameRevision) {
|
|
297
|
+
if (!inputs.recordOnly) {
|
|
298
|
+
problems.push(`${key}: pin is already at ${to} (${m.source_revision}), moved before this run — pass --record-only to write its history row without moving it`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
movePin = false;
|
|
302
|
+
}
|
|
303
|
+
else if (priorRev !== undefined && pinned !== undefined && pinned !== priorRev && pinned !== m.source_revision) {
|
|
304
|
+
problems.push(`${key}: pin is at ${pinned}, which is neither ${fromId} (${priorRev}) nor ${to} (${m.source_revision})`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const impact = sameRevision ? "none" : (changed.get(key)?.impact ?? delta.impact);
|
|
308
|
+
if (!impactOk(impact))
|
|
309
|
+
problems.push(`${dp}: ${key} impact '${impact}' is not one of ${IMPACTS.join(", ")}`);
|
|
310
|
+
const mine = (delta.findings ?? []).filter((f) => f.member === key);
|
|
311
|
+
const unclassified = mine.filter((f) => f.classification === null || f.classification === undefined).map((f) => f.id);
|
|
312
|
+
if (unclassified.length > 0) {
|
|
313
|
+
problems.push(`${key}: ${unclassified.length} finding(s) not yet classified (${unclassified.join(", ")}) — classify before the pin advances (delta-reverse.md §5)`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
// A classification is one of the bound vocabulary (M-ADR 0214 §4.5), read from the
|
|
317
|
+
// schema rather than restated here: a typo is not a classification.
|
|
318
|
+
const outOfVocabulary = mine.filter((f) => !dispositions.has(String(f.classification)));
|
|
319
|
+
if (outOfVocabulary.length > 0) {
|
|
320
|
+
problems.push(`${key}: ${outOfVocabulary.map((f) => `${f.id} '${String(f.classification)}'`).join(", ")} — not a delta_disposition (${[...dispositions].join(", ")}; M-ADR 0214 §4.5)`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const findingNotes = mine.map((f) => `${f.id} [${f.classification}]${f.summary ? `: ${f.summary}` : ""}${f.spec_impact ? ` — spec: ${f.spec_impact}` : ""}`);
|
|
324
|
+
const extra = inputs.notes?.get(key);
|
|
325
|
+
const branch = splitFolder(m.exported_folder_name)?.stem.match(BRANCH)?.[1] ?? metadata.exported_branch;
|
|
326
|
+
const entry = {
|
|
327
|
+
exported_folder_name: m.exported_folder_name,
|
|
328
|
+
...(branch ? { exported_branch: branch } : {}),
|
|
329
|
+
source_revision: m.source_revision,
|
|
330
|
+
source_path: newPath,
|
|
331
|
+
scanned_at: scannedAt,
|
|
332
|
+
reverse_status: "reversed",
|
|
333
|
+
impact,
|
|
334
|
+
...(sameRevision
|
|
335
|
+
? { impact_rationale: `re-observed at the same revision (${describeReverification(m)})` }
|
|
336
|
+
: (changed.get(key)?.impact_rationale ?? delta.impact_rationale)
|
|
337
|
+
? { impact_rationale: changed.get(key)?.impact_rationale ?? delta.impact_rationale }
|
|
338
|
+
: {}),
|
|
339
|
+
notes: `${priorRev === undefined ? `recorded as reversed at ${to} (new member, seeded at this baseline — it has no ${fromId} entry)` : movePin ? `advanced to ${to} from ${fromId}` : `recorded at ${to} from ${fromId} (pin moved by hand before this run)`} by sdd-cli snapshot advance.${provenanceNote}` +
|
|
340
|
+
`${extra ? ` ${extra}` : ""} Findings: ${findingNotes.length > 0 ? findingNotes.join("; ") : "none"}.`,
|
|
341
|
+
};
|
|
342
|
+
plans.push({
|
|
343
|
+
key,
|
|
344
|
+
dir,
|
|
345
|
+
history,
|
|
346
|
+
migrated,
|
|
347
|
+
metadataText,
|
|
348
|
+
entry,
|
|
349
|
+
metadata: {
|
|
350
|
+
source_path: newPath,
|
|
351
|
+
exported_folder_name: m.exported_folder_name,
|
|
352
|
+
source_revision: m.source_revision,
|
|
353
|
+
scanned_at: scannedAt,
|
|
354
|
+
...(branch ? { exported_branch: branch } : {}),
|
|
355
|
+
},
|
|
356
|
+
movePin,
|
|
357
|
+
...(t0?.members.find((x) => x.canonical_repo_key === key)?.exported_folder_name
|
|
358
|
+
? { priorFolder: t0.members.find((x) => x.canonical_repo_key === key).exported_folder_name }
|
|
359
|
+
: {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (problems.length > 0)
|
|
363
|
+
throw new SnapshotError(`${problems.length} problem(s):\n - ${problems.join("\n - ")}`);
|
|
364
|
+
for (const p of plans) {
|
|
365
|
+
const hFile = join(p.dir, "reverse-history.yaml");
|
|
366
|
+
const before = structuredClone(p.history.history ?? []);
|
|
367
|
+
if (p.migrated || !(await exists(hFile))) {
|
|
368
|
+
const h = { ...p.history, history: [...before, p.entry] };
|
|
369
|
+
await writeFile(hFile, stringify(h, { lineWidth: 0 }));
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
await writeFile(hFile, appendHistoryEntry(await readFile(hFile, "utf8"), p.entry));
|
|
373
|
+
}
|
|
374
|
+
const after = parse(await readFile(hFile, "utf8")).history;
|
|
375
|
+
if (after.length !== before.length + 1 || !isDeepStrictEqual(after.slice(0, before.length), before)) {
|
|
376
|
+
throw new Error(`${hFile}: post-write check failed — prior entries changed or not exactly one appended`);
|
|
377
|
+
}
|
|
378
|
+
if (p.movePin)
|
|
379
|
+
await writeFile(join(p.dir, "reverse-metadata.yaml"), setTopLevelScalars(p.metadataText, p.metadata));
|
|
380
|
+
write(`${p.key}: ${p.migrated ? "history migrated, " : ""}${p.movePin ? "pin →" : "row recorded, pin already at"} ${p.metadata.exported_folder_name} (impact ${String(p.entry.impact)})\n`);
|
|
381
|
+
for (const s of await staleProse(p.dir, fromId, p.priorFolder))
|
|
382
|
+
write(` prose may be stale — ${s}\n`);
|
|
383
|
+
}
|
|
384
|
+
const moved = plans.filter((p) => p.movePin && p.priorFolder).map((p) => p.priorFolder);
|
|
385
|
+
if (moved.length > 0) {
|
|
386
|
+
const cites = await citationsOf(sddPath, moved);
|
|
387
|
+
if (cites.length > 0) {
|
|
388
|
+
write(`${cites.length} line(s) elsewhere in the SDD still cite a folder whose pin just moved — review each (the tool edits only the pins):\n`);
|
|
389
|
+
for (const c of cites)
|
|
390
|
+
write(` ${c}\n`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return plans;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Path citations (citesPath) of the given folders anywhere in the SDD — the product
|
|
397
|
+
* map, the implementation binding, design docs — outside the members' own history
|
|
398
|
+
* files, whose entries name the prior folder by design. A list, not a control.
|
|
399
|
+
*/
|
|
400
|
+
export async function citationsOf(sddPath, folders) {
|
|
401
|
+
const out = [];
|
|
402
|
+
const skip = new Set([".git", "node_modules", "catalogues", "bundle-cache"]);
|
|
403
|
+
async function walk(dir) {
|
|
404
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
405
|
+
const full = join(dir, e.name);
|
|
406
|
+
const rel = relative(sddPath, full).split(sep).join("/");
|
|
407
|
+
if (e.isDirectory()) {
|
|
408
|
+
if (!skip.has(e.name) && rel !== "docs/method")
|
|
409
|
+
await walk(full);
|
|
410
|
+
}
|
|
411
|
+
else if (e.isFile() && /\.(ya?ml|md|json)$/.test(e.name) && !/^repos\/[^/]+\/reverse-history\.yaml$/.test(rel)) {
|
|
412
|
+
(await readFile(full, "utf8")).split("\n").forEach((line, i) => {
|
|
413
|
+
if (folders.some((f) => citesPath(line, f)))
|
|
414
|
+
out.push(`${rel}:${i + 1}`);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
await walk(sddPath);
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
function describeReverification(m) {
|
|
423
|
+
return m.reverification ?? `revision ${m.source_revision} unchanged`;
|
|
424
|
+
}
|
|
425
|
+
/** Replace (or append) top-level `key: value` lines, leaving every other byte alone. */
|
|
426
|
+
export function setTopLevelScalars(text, values) {
|
|
427
|
+
let out = text;
|
|
428
|
+
for (const [k, v] of Object.entries(values)) {
|
|
429
|
+
const line = `${k}: ${stringify(v).trimEnd()}`;
|
|
430
|
+
const re = new RegExp(`^${k}:[^\\n]*$`, "m");
|
|
431
|
+
out = re.test(out) ? out.replace(re, line) : `${out.replace(/\n*$/, "\n")}${line}\n`;
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Append one entry to a `history:` list that ends the file, at the list's own
|
|
437
|
+
* indentation. Falls back to a document edit when `history:` is not the last
|
|
438
|
+
* top-level key; the caller's post-write check covers both paths.
|
|
439
|
+
*/
|
|
440
|
+
export function appendHistoryEntry(text, entry) {
|
|
441
|
+
const lines = text.replace(/\n*$/, "").split("\n");
|
|
442
|
+
const h = lines.findIndex((l) => /^history:\s*$/.test(l));
|
|
443
|
+
const laterTopLevel = h >= 0 && lines.slice(h + 1).some((l) => /^[A-Za-z_][\w-]*:/.test(l));
|
|
444
|
+
const item = lines.slice(h + 1).find((l) => /^\s*- /.test(l));
|
|
445
|
+
if (h < 0 || laterTopLevel || item === undefined) {
|
|
446
|
+
const doc = parseDocument(text);
|
|
447
|
+
doc.addIn(["history"], doc.createNode(entry));
|
|
448
|
+
return doc.toString({ lineWidth: 0 });
|
|
449
|
+
}
|
|
450
|
+
const indent = item.match(/^(\s*)- /)[1];
|
|
451
|
+
const body = stringify([entry], { lineWidth: 0 }).trimEnd().split("\n").map((l) => indent + l).join("\n");
|
|
452
|
+
return `${lines.join("\n")}\n${body}\n`;
|
|
453
|
+
}
|
|
454
|
+
//# sourceMappingURL=advance.js.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `sdd-cli snapshot diff` — the member and file delta between two baselines,
|
|
2
|
+
// written as delta.yaml's skeleton (delta-reverse.md §4 and §6).
|
|
3
|
+
//
|
|
4
|
+
// Members are classified from the two MANIFESTS, never the folder trees: a
|
|
5
|
+
// carried member is absent from T1's tree and present in the set, and a tree
|
|
6
|
+
// diff calls it removed. A prior member that T1's manifest neither lists nor
|
|
7
|
+
// records in members_removed is refused — the manifest is incomplete.
|
|
8
|
+
//
|
|
9
|
+
// Findings are one per changed member × evidence class, at `status: proposed`,
|
|
10
|
+
// with classification / spec_impact / targets left for judgement. Once any
|
|
11
|
+
// finding carries judgement, diff refuses to rewrite the file.
|
|
12
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parse, stringify } from "yaml";
|
|
15
|
+
import { SnapshotError, diffTrees, exists, hashTree, memberBytesDir, readManifest, } from "./manifest.js";
|
|
16
|
+
/**
|
|
17
|
+
* A deliberately coarse, documented path classifier. It groups files so each
|
|
18
|
+
* finding is reviewable; it does not decide what a change means (that is
|
|
19
|
+
* classification, which is judgement). First match wins.
|
|
20
|
+
*/
|
|
21
|
+
export const EVIDENCE_CLASSES = [
|
|
22
|
+
["contracts", /\.(avsc|proto|graphql|wsdl|xsd)$|(^|\/)(openapi|swagger|asyncapi)[^/]*\.(ya?ml|json)$/i],
|
|
23
|
+
["tests", /(^|\/)(test|tests|__tests__|spec|it|e2e)\/|\.(test|spec)\.[a-z]+$|Tests?\.[a-z]+$/i],
|
|
24
|
+
["infrastructure", /\.(tf|tfvars|bicep)$|(^|\/)(helm|charts|k8s|kubernetes|deploy|deployment|terraform)\//i],
|
|
25
|
+
["build", /(^|\/)(pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.mod|go\.sum|Cargo\.toml|Dockerfile[^/]*|Makefile|\.csproj|.*\.sln)$|(^|\/)\.(github|gitlab-ci|circleci)|Jenkinsfile$|bitbucket-pipelines\.yml$/i],
|
|
26
|
+
["configuration", /\.(ya?ml|properties|json|toml|ini|conf|env|config)$/i],
|
|
27
|
+
["documentation", /\.(md|adoc|rst|txt)$/i],
|
|
28
|
+
["source", /./],
|
|
29
|
+
];
|
|
30
|
+
export function evidenceClass(path) {
|
|
31
|
+
for (const [name, re] of EVIDENCE_CLASSES)
|
|
32
|
+
if (re.test(path))
|
|
33
|
+
return name;
|
|
34
|
+
return "source";
|
|
35
|
+
}
|
|
36
|
+
/** The prior baseline's provenance as `snapshot verify` recorded it, or an explicit "not verified". */
|
|
37
|
+
export async function provenanceOf(areaRoot, id) {
|
|
38
|
+
const p = join(areaRoot, id, "provenance.yaml");
|
|
39
|
+
if (!(await exists(p)))
|
|
40
|
+
return { result: "not verified — run `sdd-cli snapshot verify` on this baseline (delta-reverse §3)" };
|
|
41
|
+
const r = (parse(await readFile(p, "utf8")) ?? {});
|
|
42
|
+
return { result: String(r.result ?? "unknown"), ...(r.verified_at ? { verified_at: String(r.verified_at) } : {}), ...(r.waiver ? { waiver: r.waiver } : {}) };
|
|
43
|
+
}
|
|
44
|
+
export function deltaPath(areaRoot, id) {
|
|
45
|
+
return join(areaRoot, id, "delta.yaml");
|
|
46
|
+
}
|
|
47
|
+
/** Findings that already carry judgement — the reason diff will not rewrite. */
|
|
48
|
+
export function judgedFindings(existing) {
|
|
49
|
+
const findings = existing?.findings;
|
|
50
|
+
if (!Array.isArray(findings))
|
|
51
|
+
return [];
|
|
52
|
+
return findings.flatMap((f, i) => {
|
|
53
|
+
const x = f;
|
|
54
|
+
const judged = (x.status !== undefined && x.status !== "proposed") ||
|
|
55
|
+
(x.classification !== undefined && x.classification !== null) ||
|
|
56
|
+
(x.spec_impact !== undefined && x.spec_impact !== null);
|
|
57
|
+
return judged ? [String(x.id ?? `findings[${i}]`)] : [];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function runSnapshotDiff(inputs, write) {
|
|
61
|
+
try {
|
|
62
|
+
const d = await snapshotDiff(inputs);
|
|
63
|
+
write(`${d.from_snapshot} → ${d.to_snapshot}: ${d.members_changed.length} changed, ${d.members_unchanged} unchanged, ` +
|
|
64
|
+
`${d.members_added.length} added, ${d.members_removed.length} removed, ${d.members_carried.length} carried; ` +
|
|
65
|
+
`${d.findings.length} finding(s) at status proposed → ${deltaPath(inputs.areaRoot, inputs.to)}\n`);
|
|
66
|
+
return { exitCode: 0 };
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (e instanceof SnapshotError) {
|
|
70
|
+
write(`REFUSED: ${e.message}\n`);
|
|
71
|
+
return { exitCode: 1 };
|
|
72
|
+
}
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function snapshotDiff(inputs) {
|
|
77
|
+
const { areaRoot, to } = inputs;
|
|
78
|
+
const t1 = await readManifest(areaRoot, to);
|
|
79
|
+
const fromId = inputs.from ?? t1.prior_snapshot;
|
|
80
|
+
if (!fromId)
|
|
81
|
+
throw new SnapshotError(`${to} has no prior_snapshot; pass --from`);
|
|
82
|
+
const t0 = await readManifest(areaRoot, fromId);
|
|
83
|
+
const out = deltaPath(areaRoot, to);
|
|
84
|
+
if (await exists(out)) {
|
|
85
|
+
const existing = parse(await readFile(out, "utf8"));
|
|
86
|
+
const judged = judgedFindings(existing);
|
|
87
|
+
if (Array.isArray(existing?.stale_scores) && existing.stale_scores.length > 0)
|
|
88
|
+
judged.push("stale_scores");
|
|
89
|
+
if (judged.length > 0) {
|
|
90
|
+
throw new SnapshotError(`${out} already holds judgement on ${judged.length} finding(s) (${judged.slice(0, 5).join(", ")}${judged.length > 5 ? ", …" : ""}) — diff will not overwrite it`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const delta = await computeDelta(areaRoot, t0, t1);
|
|
94
|
+
const header = "# Snapshot delta -- reverse/workflows/delta-reverse.md §6. Skeleton written by `sdd-cli snapshot diff`;\n" +
|
|
95
|
+
"# classification, spec_impact and targets are judgement (steps 5-6). Once any is set, diff will not rewrite this file.\n";
|
|
96
|
+
await writeFile(out, header + stringify(delta, { lineWidth: 0 }));
|
|
97
|
+
return delta;
|
|
98
|
+
}
|
|
99
|
+
export async function computeDelta(areaRoot, t0, t1) {
|
|
100
|
+
const t0ByKey = new Map(t0.members.map((m) => [m.canonical_repo_key, m]));
|
|
101
|
+
const t1Keys = new Set(t1.members.map((m) => m.canonical_repo_key));
|
|
102
|
+
const removedKeys = new Set((t1.members_removed ?? []).map((r) => r.canonical_repo_key));
|
|
103
|
+
const unaccounted = t0.members.filter((m) => !t1Keys.has(m.canonical_repo_key) && !removedKeys.has(m.canonical_repo_key));
|
|
104
|
+
if (unaccounted.length > 0) {
|
|
105
|
+
throw new SnapshotError(`${t1.snapshot_id}'s manifest does not account for ${unaccounted.map((m) => m.canonical_repo_key).join(", ")} from ${t0.snapshot_id} — ` +
|
|
106
|
+
"each must be a member (carried or re-observed) or in members_removed[]");
|
|
107
|
+
}
|
|
108
|
+
const delta = {
|
|
109
|
+
implementation: t1.implementation,
|
|
110
|
+
from_snapshot: t0.snapshot_id,
|
|
111
|
+
to_snapshot: t1.snapshot_id,
|
|
112
|
+
members_changed: [],
|
|
113
|
+
members_added: [],
|
|
114
|
+
members_removed: (t1.members_removed ?? []).map((r) => ({ canonical_repo_key: r.canonical_repo_key, last_revision: r.last_revision, reason: r.reason })),
|
|
115
|
+
members_carried: [],
|
|
116
|
+
members_unchanged: 0,
|
|
117
|
+
disposition: null,
|
|
118
|
+
impact: null,
|
|
119
|
+
t0_provenance: await provenanceOf(areaRoot, t0.snapshot_id),
|
|
120
|
+
stale_scores: [],
|
|
121
|
+
findings: [],
|
|
122
|
+
open_questions: [],
|
|
123
|
+
};
|
|
124
|
+
for (const m of t1.members) {
|
|
125
|
+
const p = t0ByKey.get(m.canonical_repo_key);
|
|
126
|
+
if (!p) {
|
|
127
|
+
delta.members_added.push({ canonical_repo_key: m.canonical_repo_key, to_revision: m.source_revision });
|
|
128
|
+
// A new member gets the full seeding sequence, not a delta — but what the
|
|
129
|
+
// seeding writes must still trace to a finding, so it gets one.
|
|
130
|
+
delta.findings.push(memberFinding(m.canonical_repo_key, "member-added", `new member at ${m.source_revision}: seed it (full sequence), then target what it grounds`));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (m.carried_from !== undefined) {
|
|
134
|
+
delta.members_carried.push({ canonical_repo_key: m.canonical_repo_key, carried_from: m.carried_from, carried_reason: m.carried_reason ?? "" });
|
|
135
|
+
delta.members_unchanged++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (p.source_revision === m.source_revision) {
|
|
139
|
+
delta.members_unchanged++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const files = diffTrees(await hashTree(memberBytesDir(areaRoot, t0, p)), await hashTree(memberBytesDir(areaRoot, t1, m)));
|
|
143
|
+
delta.members_changed.push({
|
|
144
|
+
canonical_repo_key: m.canonical_repo_key,
|
|
145
|
+
from_revision: p.source_revision,
|
|
146
|
+
to_revision: m.source_revision,
|
|
147
|
+
files: { added: files.added.length, removed: files.removed.length, modified: files.modified.length },
|
|
148
|
+
});
|
|
149
|
+
const byClass = new Map();
|
|
150
|
+
for (const kind of ["added", "removed", "modified"]) {
|
|
151
|
+
for (const f of files[kind]) {
|
|
152
|
+
const c = evidenceClass(f);
|
|
153
|
+
if (!byClass.has(c))
|
|
154
|
+
byClass.set(c, { added: [], removed: [], modified: [] });
|
|
155
|
+
byClass.get(c)[kind].push(f);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const [c, fs] of [...byClass].sort(([a], [b]) => a.localeCompare(b))) {
|
|
159
|
+
delta.findings.push({
|
|
160
|
+
id: `${m.canonical_repo_key}.${c}`,
|
|
161
|
+
member: m.canonical_repo_key,
|
|
162
|
+
evidence_class: c,
|
|
163
|
+
status: "proposed",
|
|
164
|
+
classification: null,
|
|
165
|
+
spec_impact: null,
|
|
166
|
+
summary: `${fs.added.length} added, ${fs.removed.length} removed, ${fs.modified.length} modified ${c} file(s)`,
|
|
167
|
+
files: fs,
|
|
168
|
+
targets: [],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const r of t1.members_removed ?? []) {
|
|
173
|
+
delta.findings.push(memberFinding(r.canonical_repo_key, "member-removed", `left the set (${r.reason}); a removed member is not a removed capability until you have checked where the capability went`));
|
|
174
|
+
}
|
|
175
|
+
return delta;
|
|
176
|
+
}
|
|
177
|
+
function memberFinding(member, kind, summary) {
|
|
178
|
+
return {
|
|
179
|
+
id: `${member}.${kind}`,
|
|
180
|
+
member,
|
|
181
|
+
evidence_class: kind,
|
|
182
|
+
status: "proposed",
|
|
183
|
+
classification: null,
|
|
184
|
+
spec_impact: null,
|
|
185
|
+
summary,
|
|
186
|
+
files: { added: [], removed: [], modified: [] },
|
|
187
|
+
targets: [],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=diff.js.map
|