@intentius/chant 0.100.0 → 0.101.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/dist/build.d.ts +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/build-options.d.ts +2 -0
- package/dist/cli/build-options.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +8 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +24 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +26 -5
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lint/config.d.ts +4 -4
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +2 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/serializer.d.ts +8 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/telemetry-attribution.d.ts +77 -0
- package/dist/telemetry-attribution.d.ts.map +1 -0
- package/dist/workspace/agent-cli.d.ts +83 -0
- package/dist/workspace/agent-cli.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes.d.ts +8 -1
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/checks/links.d.ts +1 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/live.d.ts +40 -0
- package/dist/workspace/checks/live.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +21 -2
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +63 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +1 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +32 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +138 -3
- package/dist/workspace/export-cli.d.ts +12 -0
- package/dist/workspace/export-cli.d.ts.map +1 -0
- package/dist/workspace/export.d.ts +145 -0
- package/dist/workspace/export.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/import.d.ts +73 -0
- package/dist/workspace/import.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +6 -2
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
- package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-adopt.d.ts +106 -0
- package/dist/workspace/lineage-adopt.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +9 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +6 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-hash-index.d.ts +110 -0
- package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +10 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +147 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.d.ts +15 -3
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-provenance.d.ts +18 -0
- package/dist/workspace/lineage-provenance.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +30 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-versions.d.ts +116 -0
- package/dist/workspace/lineage-versions.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +31 -5
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/ls-generated.d.ts +37 -0
- package/dist/workspace/ls-generated.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/nested-graph.d.ts +56 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -0
- package/dist/workspace/nesting.d.ts +21 -0
- package/dist/workspace/nesting.d.ts.map +1 -0
- package/dist/workspace/pin-cli.d.ts +10 -0
- package/dist/workspace/pin-cli.d.ts.map +1 -0
- package/dist/workspace/pin-integrity.d.ts +51 -0
- package/dist/workspace/pin-integrity.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +7 -11
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +30 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +5 -2
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +22 -4
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +43 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/returns.d.ts +129 -0
- package/dist/workspace/returns.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/template-manifest.d.ts +11 -3
- package/dist/workspace/template-manifest.d.ts.map +1 -1
- package/dist/workspace/trust/attestor.d.ts +8 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -1
- package/dist/workspace/trust/dsse.d.ts +58 -0
- package/dist/workspace/trust/dsse.d.ts.map +1 -0
- package/dist/workspace/trust/evidence-cli.d.ts +66 -0
- package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
- package/dist/workspace/trust/evidence.d.ts +93 -0
- package/dist/workspace/trust/evidence.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +54 -1
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/provenance.d.ts +21 -1
- package/dist/workspace/trust/provenance.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.d.ts +132 -0
- package/dist/workspace/trust/rotation.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/signers-cli.d.ts +49 -0
- package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +12 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/test-repo.d.ts +13 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -1
- package/dist/workspace/trust/verify.d.ts +15 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +199 -0
- package/dist/workspace/write-scope.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/cli/build-options.ts +4 -1
- package/src/cli/commands/build.ts +2 -0
- package/src/cli/commands/import-live.test.ts +69 -1
- package/src/cli/commands/import.ts +48 -22
- package/src/cli/handlers/fan-out.test.ts +6 -6
- package/src/cli/handlers/fan-out.ts +2 -1
- package/src/cli/handlers/graph.test.ts +42 -0
- package/src/cli/handlers/graph.ts +22 -0
- package/src/cli/handlers/operator.ts +1 -1
- package/src/cli/main.test.ts +32 -0
- package/src/cli/main.ts +103 -6
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +33 -2
- package/src/cli/registry.ts +22 -0
- package/src/cli/serve-mcp-workspace.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +5 -1
- package/src/components/fan-out-output.test.ts +1 -1
- package/src/components/fan-out.test.ts +1 -1
- package/src/components/promote.test.ts +1 -1
- package/src/config.ts +12 -0
- package/src/content-digest.test.ts +2 -2
- package/src/lexicon.ts +25 -1
- package/src/lifecycle/gate-ledger.test.ts +14 -0
- package/src/lifecycle/gate-ledger.ts +2 -1
- package/src/lifecycle/plan-digest.test.ts +54 -3
- package/src/lifecycle/plan-digest.ts +38 -8
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/propose-upgrade.ts +8 -5
- package/src/op/gate-approval.test.ts +17 -0
- package/src/op/gate.ts +3 -3
- package/src/op/index.ts +1 -1
- package/src/serializer.ts +9 -0
- package/src/telemetry-attribution.test.ts +91 -0
- package/src/telemetry-attribution.ts +145 -0
- package/src/workspace/agent-cli.ts +134 -0
- package/src/workspace/agent.schema.json +356 -0
- package/src/workspace/behold-kinds.test.ts +1 -1
- package/src/workspace/changes-cli.ts +5 -0
- package/src/workspace/changes.schema.json +179 -1
- package/src/workspace/changes.ts +58 -4
- package/src/workspace/check-live.test.ts +192 -0
- package/src/workspace/check.schema.json +64 -0
- package/src/workspace/checks/links.ts +23 -2
- package/src/workspace/checks/live.ts +113 -0
- package/src/workspace/checks.test.ts +2 -2
- package/src/workspace/checks.ts +20 -3
- package/src/workspace/compose-graph.test.ts +47 -0
- package/src/workspace/compose-graph.ts +120 -3
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
- package/src/workspace/declaration.schema.json +138 -3
- package/src/workspace/declaration.ts +106 -1
- package/src/workspace/declared-kinds.test.ts +33 -0
- package/src/workspace/evidence.schema.json +279 -0
- package/src/workspace/export-cli.ts +180 -0
- package/src/workspace/export-import.test.ts +282 -0
- package/src/workspace/export.ts +486 -0
- package/src/workspace/graph-cli.ts +47 -1
- package/src/workspace/graph-contract.test.ts +89 -4
- package/src/workspace/graph.schema.json +206 -1
- package/src/workspace/import.ts +325 -0
- package/src/workspace/kinds.test.ts +5 -5
- package/src/workspace/kinds.ts +25 -3
- package/src/workspace/lineage-adopt-cli.ts +103 -0
- package/src/workspace/lineage-adopt.test.ts +552 -0
- package/src/workspace/lineage-adopt.ts +452 -0
- package/src/workspace/lineage-check.ts +26 -5
- package/src/workspace/lineage-cli.ts +12 -2
- package/src/workspace/lineage-hash-index.ts +305 -0
- package/src/workspace/lineage-init.test.ts +9 -0
- package/src/workspace/lineage-init.ts +29 -9
- package/src/workspace/lineage-lock.ts +54 -0
- package/src/workspace/lineage-migrations.test.ts +27 -0
- package/src/workspace/lineage-migrations.ts +44 -14
- package/src/workspace/lineage-provenance.ts +40 -0
- package/src/workspace/lineage-upgrade-cli.ts +9 -5
- package/src/workspace/lineage-upgrade.test.ts +92 -1
- package/src/workspace/lineage-upgrade.ts +111 -16
- package/src/workspace/lineage-versions.ts +348 -0
- package/src/workspace/links.test.ts +121 -2
- package/src/workspace/links.ts +97 -6
- package/src/workspace/ls-contract.test.ts +84 -1
- package/src/workspace/ls-generated.ts +111 -0
- package/src/workspace/ls.schema.json +19 -0
- package/src/workspace/ls.ts +8 -1
- package/src/workspace/member-commands.ts +3 -1
- package/src/workspace/nested-graph.test.ts +176 -0
- package/src/workspace/nested-graph.ts +169 -0
- package/src/workspace/nesting.ts +37 -0
- package/src/workspace/pin-cli.test.ts +71 -0
- package/src/workspace/pin-cli.ts +57 -0
- package/src/workspace/pin-integrity.test.ts +121 -0
- package/src/workspace/pin-integrity.ts +104 -0
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/read-contract.test.ts +24 -0
- package/src/workspace/reason-codes.test.ts +10 -0
- package/src/workspace/reason-codes.ts +29 -2
- package/src/workspace/record-sessions.ts +12 -14
- package/src/workspace/records-amend.schema.json +4 -0
- package/src/workspace/records-cli.ts +93 -12
- package/src/workspace/records-close.schema.json +6 -2
- package/src/workspace/records-close.ts +10 -2
- package/src/workspace/records-formats.test.ts +13 -5
- package/src/workspace/records-new.schema.json +4 -0
- package/src/workspace/records-review.schema.json +4 -0
- package/src/workspace/records-sessions.test.ts +23 -6
- package/src/workspace/records-write.test.ts +65 -8
- package/src/workspace/records-write.ts +70 -6
- package/src/workspace/records.schema.json +55 -3
- package/src/workspace/records.ts +96 -5
- package/src/workspace/returns.ts +328 -0
- package/src/workspace/signers.schema.json +206 -0
- package/src/workspace/status-gates.ts +2 -1
- package/src/workspace/template-manifest.ts +22 -4
- package/src/workspace/trust/attestor.ts +15 -0
- package/src/workspace/trust/dsse.ts +134 -0
- package/src/workspace/trust/evidence-cli.ts +195 -0
- package/src/workspace/trust/evidence.test.ts +241 -0
- package/src/workspace/trust/evidence.ts +207 -0
- package/src/workspace/trust/policy.ts +110 -3
- package/src/workspace/trust/provenance.ts +41 -4
- package/src/workspace/trust/record-seal.test.ts +1 -1
- package/src/workspace/trust/rotation.test.ts +258 -0
- package/src/workspace/trust/rotation.ts +336 -0
- package/src/workspace/trust/seal.ts +11 -0
- package/src/workspace/trust/signers-cli.ts +178 -0
- package/src/workspace/trust/ssh-commit.ts +53 -4
- package/src/workspace/trust/test-repo.ts +18 -0
- package/src/workspace/trust/trust.test.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +1 -0
- package/src/workspace/trust/verify.ts +22 -0
- package/src/workspace/work-evidence.schema.json +4 -0
- package/src/workspace/write-scope.test.ts +340 -0
- package/src/workspace/write-scope.ts +448 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace adopt-lineage [<scope>] --from <repo>[@<ref>][#<member>]
|
|
3
|
+
* [--tags <glob>] [--index <file>] [--param k=v] [--dry-run] [--json]` and
|
|
4
|
+
* `chant workspace hash-index --from <repo>[#<member>] [--tags <glob>]
|
|
5
|
+
* [--output <file>]` (#2551).
|
|
6
|
+
*
|
|
7
|
+
* The first gives a scope a git lineage (see ./lineage-adopt.ts). The second
|
|
8
|
+
* computes the hash index a template's tags give, for the template's CI to
|
|
9
|
+
* publish as a cache that adopters pass with `--index`. Neither needs a
|
|
10
|
+
* `chant.workspace.json`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/format";
|
|
16
|
+
import type { CommandContext } from "../cli/registry";
|
|
17
|
+
import { adoptLineage, describeAdoption } from "./lineage-adopt";
|
|
18
|
+
import { computeHashIndex, readHashIndex, renderHashIndex, TemplateTags } from "./lineage-hash-index";
|
|
19
|
+
import { parseTemplateSource } from "./lineage-init";
|
|
20
|
+
import { LOCK_FILE, LockError } from "./lineage-lock";
|
|
21
|
+
import { parseParamArgs } from "./template-manifest";
|
|
22
|
+
|
|
23
|
+
const ADOPT_USAGE =
|
|
24
|
+
"chant workspace adopt-lineage [<scope>] --from <repo>[@<ref>][#<member>] [--tags <glob>] [--index <file>] [--param name=value] [--dry-run] [--json]";
|
|
25
|
+
const INDEX_USAGE = "chant workspace hash-index --from <repo>[#<member>] [--tags <glob>] [--output <file>]";
|
|
26
|
+
|
|
27
|
+
export async function runWorkspaceAdoptLineage(ctx: CommandContext): Promise<number> {
|
|
28
|
+
const { args } = ctx;
|
|
29
|
+
if (args.extraPositional2) {
|
|
30
|
+
console.error(formatError({ message: `unexpected argument: ${args.extraPositional2}`, hint: ADOPT_USAGE }));
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
if (!args.migrateFrom) {
|
|
34
|
+
console.error(formatError({ message: "adopt-lineage needs the template: --from <repo>[@<ref>][#<member>]", hint: ADOPT_USAGE }));
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const result = adoptLineage({
|
|
39
|
+
root: process.cwd(),
|
|
40
|
+
scope: args.extraPositional,
|
|
41
|
+
from: args.migrateFrom,
|
|
42
|
+
tags: args.tags,
|
|
43
|
+
cache: args.index ? readHashIndex(resolve(args.index)) : undefined,
|
|
44
|
+
params: args.param?.length ? parseParamArgs(args.param) : undefined,
|
|
45
|
+
dryRun: args.dryRun,
|
|
46
|
+
});
|
|
47
|
+
if (args.json) {
|
|
48
|
+
console.log(JSON.stringify(result, null, 2));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
console.log(describeAdoption(result).join("\n"));
|
|
52
|
+
if (result.lockIgnored) {
|
|
53
|
+
console.error(formatWarning({ message: `${LOCK_FILE} is covered by a .gitignore. Commit it with \`git add -f\` so the lineage travels with the project.` }));
|
|
54
|
+
}
|
|
55
|
+
if (!result.written) {
|
|
56
|
+
console.error(formatInfo("--dry-run: nothing written"));
|
|
57
|
+
} else if (result.by === "lineage") {
|
|
58
|
+
console.error(formatSuccess(`moved the lineage of "${result.scope}" onto ${result.template}@${result.chosen.tag} in ${LOCK_FILE}. Review and commit it; chant workspace upgrade --to <ref> now reads that repository.`));
|
|
59
|
+
} else {
|
|
60
|
+
console.error(
|
|
61
|
+
formatSuccess(
|
|
62
|
+
`recorded the lineage of "${result.scope}" in ${LOCK_FILE}${result.trust ? ` and the adopted range in ${result.trust.path}` : ""}. Review and commit both; the range counts once an admin merges it.`,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (!(err instanceof LockError)) throw err;
|
|
69
|
+
console.error(formatError({ message: err.message, hint: ADOPT_USAGE }));
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runWorkspaceHashIndex(ctx: CommandContext): Promise<number> {
|
|
75
|
+
const { args } = ctx;
|
|
76
|
+
const from = args.migrateFrom ?? args.extraPositional;
|
|
77
|
+
if (!from || (args.migrateFrom && args.extraPositional)) {
|
|
78
|
+
console.error(formatError({ message: "hash-index needs one template: --from <repo>[#<member>]", hint: INDEX_USAGE }));
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const spec = parseTemplateSource(from, process.cwd(), "--from");
|
|
83
|
+
if (spec.ref !== undefined) throw new LockError(`--from ${from}: the index covers every version tag; leave out "@${spec.ref}" and narrow with --tags`);
|
|
84
|
+
const tags = new TemplateTags(spec.url, spec.member, spec.member ? `${spec.repo}#${spec.member}` : spec.repo);
|
|
85
|
+
try {
|
|
86
|
+
const { index } = computeHashIndex(tags, spec.id, { pattern: args.tags });
|
|
87
|
+
const text = renderHashIndex(index);
|
|
88
|
+
if (args.output) {
|
|
89
|
+
writeFileSync(resolve(args.output), text);
|
|
90
|
+
console.error(formatSuccess(`wrote the hash index of ${spec.id} (${index.tags.length} version(s)) to ${args.output}`));
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write(text);
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
} finally {
|
|
96
|
+
tags.dispose();
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (!(err instanceof LockError)) throw err;
|
|
100
|
+
console.error(formatError({ message: err.message, hint: INDEX_USAGE }));
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2551 — adopt-lineage, the hash index, and bridge migrations, over
|
|
3
|
+
* real git repositories.
|
|
4
|
+
*
|
|
5
|
+
* The fixture is D9's scenario 6, a fork-born workspace: an upstream template
|
|
6
|
+
* with tagged releases, a fork of it that renamed a file and tagged releases
|
|
7
|
+
* of its own, and a workspace copied by hand from the fork at a tag, with no
|
|
8
|
+
* lock, then edited. The workspace adopts a lineage against the fork, and an
|
|
9
|
+
* upgrade with `--source` moves it onto the upstream template through the
|
|
10
|
+
* upstream's bridge migration.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import type { GateLedgerPort } from "../op/gate";
|
|
19
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
20
|
+
import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
|
|
21
|
+
import { adoptLineage } from "./lineage-adopt";
|
|
22
|
+
import { initFromCommand } from "./lineage-init";
|
|
23
|
+
import { lineageView } from "./lineage-cli";
|
|
24
|
+
import { computeHashIndex, readHashIndex, renderHashIndex, TemplateTags, type HashIndex } from "./lineage-hash-index";
|
|
25
|
+
import { LOCK_FILE, fileHash, readLock } from "./lineage-lock";
|
|
26
|
+
import { stageUpgrade, type ChantRunner } from "./lineage-upgrade";
|
|
27
|
+
import { upgradeCommand } from "./lineage-upgrade-cli";
|
|
28
|
+
import { workspaceVersions } from "./lineage-versions";
|
|
29
|
+
|
|
30
|
+
const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
31
|
+
|
|
32
|
+
let root: string;
|
|
33
|
+
let up: string;
|
|
34
|
+
let fork: string;
|
|
35
|
+
let ws: string;
|
|
36
|
+
|
|
37
|
+
function git(cwd: string, args: string[]): string {
|
|
38
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", env: ENV, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
39
|
+
}
|
|
40
|
+
function put(base: string, rel: string, content: string): void {
|
|
41
|
+
mkdirSync(dirname(join(base, rel)), { recursive: true });
|
|
42
|
+
writeFileSync(join(base, rel), content);
|
|
43
|
+
}
|
|
44
|
+
function read(base: string, rel: string): string {
|
|
45
|
+
return readFileSync(join(base, rel), "utf-8");
|
|
46
|
+
}
|
|
47
|
+
function release(repo: string, tag: string, files: Record<string, string | null>): void {
|
|
48
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
49
|
+
if (content === null) rmSync(join(repo, rel));
|
|
50
|
+
else put(repo, rel, content);
|
|
51
|
+
}
|
|
52
|
+
git(repo, ["add", "-A"]);
|
|
53
|
+
git(repo, ["commit", "-q", "--allow-empty", "-m", tag]);
|
|
54
|
+
git(repo, ["tag", tag]);
|
|
55
|
+
}
|
|
56
|
+
function commit(repo: string, message: string): void {
|
|
57
|
+
git(repo, ["add", "-A"]);
|
|
58
|
+
git(repo, ["commit", "-q", "-m", message]);
|
|
59
|
+
}
|
|
60
|
+
/** Copy a repository's files at a tag into a directory, the way a fork-born workspace was made: by hand, with no lock. */
|
|
61
|
+
function copyAt(repo: string, tag: string, dest: string): void {
|
|
62
|
+
mkdirSync(dest, { recursive: true });
|
|
63
|
+
const tar = execFileSync("git", ["archive", "--format=tar", tag], { cwd: repo });
|
|
64
|
+
execFileSync("tar", ["-x", "-C", dest], { input: tar });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ledger(): GateLedgerPort & { pending: PendingGateRecord[]; approve(digest: string, at: string): void } {
|
|
68
|
+
const pending: PendingGateRecord[] = [];
|
|
69
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
70
|
+
return {
|
|
71
|
+
pending,
|
|
72
|
+
approve(digest, at) {
|
|
73
|
+
resolutions.push({ version: 1, op: WORKSPACE_UPGRADE_GATE_OP, gate: ".", resolvedBy: "alice", timestamp: at, planDigest: digest });
|
|
74
|
+
},
|
|
75
|
+
async read() {
|
|
76
|
+
return { resolutions: [...resolutions], pending: [...pending] };
|
|
77
|
+
},
|
|
78
|
+
async appendPending(input) {
|
|
79
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
80
|
+
pending.push(record);
|
|
81
|
+
return { record, pushed: true };
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const passing: ChantRunner = async () => ({ exitCode: 0, output: "" });
|
|
87
|
+
|
|
88
|
+
const APP_V1 = "one\ntwo\nthree\nfour\nfive\nsix\nseven\n";
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-adopt-test-")));
|
|
92
|
+
up = join(root, "up");
|
|
93
|
+
fork = join(root, "fork");
|
|
94
|
+
ws = join(root, "ws");
|
|
95
|
+
|
|
96
|
+
// The upstream template.
|
|
97
|
+
mkdirSync(up);
|
|
98
|
+
git(up, ["init", "-q", "-b", "main"]);
|
|
99
|
+
release(up, "v1.0.0", { "README.md": "starter\n", "src/app.ts": APP_V1, "config.json": '{ "name": "starter" }\n' });
|
|
100
|
+
|
|
101
|
+
// The fork: cloned at v1.0.0, then its own layout and its own tags.
|
|
102
|
+
git(root, ["clone", "-q", "--branch", "v1.0.0", up, "fork"]);
|
|
103
|
+
git(fork, ["checkout", "-q", "-b", "main"]);
|
|
104
|
+
git(fork, ["tag", "-d", "v1.0.0"]);
|
|
105
|
+
git(fork, ["mv", "config.json", "settings.json"]);
|
|
106
|
+
release(fork, "v1.0.0", { "README.md": "starter (fork)\n" });
|
|
107
|
+
release(fork, "v1.1.0", { "src/app.ts": "one (fork)\ntwo\nthree\nfour\nfive\nsix\nseven\n", "src/extra.ts": "export const extra = 1;\n", "docs.md": "# docs\n" });
|
|
108
|
+
release(fork, "v1.2.0", { "README.md": "starter (fork, 1.2)\n" });
|
|
109
|
+
|
|
110
|
+
// The fork-born workspace: copied from the fork at v1.1.0, no lock, edited over two commits.
|
|
111
|
+
copyAt(fork, "v1.1.0", ws);
|
|
112
|
+
git(ws, ["init", "-q", "-b", "main"]);
|
|
113
|
+
commit(ws, "copied from the fork");
|
|
114
|
+
put(ws, "src/app.ts", "one (fork)\ntwo\nthree\nfour (ours)\nfive\nsix\nseven\n");
|
|
115
|
+
rmSync(join(ws, "docs.md"));
|
|
116
|
+
put(ws, "src/own.ts", "export const own = 1;\n");
|
|
117
|
+
commit(ws, "our changes");
|
|
118
|
+
|
|
119
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
120
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.restoreAllMocks();
|
|
124
|
+
rmSync(root, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("chant workspace adopt-lineage", () => {
|
|
128
|
+
test("finds the tag the scope was made from and records the lineage as adopted", () => {
|
|
129
|
+
const p = adoptLineage({ root: ws, from: fork });
|
|
130
|
+
expect(p.written).toBe(true);
|
|
131
|
+
expect(p.chosen).toMatchObject({ tag: "v1.1.0", files: 5, identical: 3, edited: 1, missing: 1 });
|
|
132
|
+
expect(p.compared).toBe(3);
|
|
133
|
+
expect(p.identical.sort()).toEqual(["README.md", "settings.json", "src/extra.ts"]);
|
|
134
|
+
expect(p.edited).toEqual(["src/app.ts"]);
|
|
135
|
+
expect(p.missing).toEqual(["docs.md"]);
|
|
136
|
+
// v1.0.0 and v1.2.0 each match two files; v1.0.0 accounts for its files better, so it ranks next.
|
|
137
|
+
expect(p.alternatives.map((a) => [a.tag, a.identical])).toEqual([
|
|
138
|
+
["v1.0.0", 2],
|
|
139
|
+
["v1.2.0", 2],
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const lineage = readLock(ws)!.scopes["."];
|
|
143
|
+
expect(lineage).toMatchObject({
|
|
144
|
+
kind: "template",
|
|
145
|
+
template: fork,
|
|
146
|
+
source: { type: "git", repo: fork, url: "../fork" },
|
|
147
|
+
ref: "v1.1.0",
|
|
148
|
+
parameters: {},
|
|
149
|
+
migrations: [],
|
|
150
|
+
manualSteps: [],
|
|
151
|
+
});
|
|
152
|
+
expect(lineage.address?.commit).toBe(git(fork, ["rev-parse", "v1.1.0^{commit}"]));
|
|
153
|
+
expect(lineage.address?.tree).toBe(git(fork, ["rev-parse", "v1.1.0^{tree}"]));
|
|
154
|
+
// Every template file, at the template's hash: the merge base. The workspace's own file is not the template's.
|
|
155
|
+
expect(Object.keys(lineage.files).sort()).toEqual(["README.md", "docs.md", "settings.json", "src/app.ts", "src/extra.ts"]);
|
|
156
|
+
expect(lineage.files["src/app.ts"].sha256).toBe(fileHash(git(fork, ["show", "v1.1.0:src/app.ts"]) + "\n"));
|
|
157
|
+
|
|
158
|
+
// The exact commit range: everything up to HEAD, as .chant/trust.json admits it.
|
|
159
|
+
const head = git(ws, ["rev-parse", "HEAD"]);
|
|
160
|
+
expect(lineage.adoption).toEqual({
|
|
161
|
+
by: "files",
|
|
162
|
+
commits: { to: head },
|
|
163
|
+
match: { files: 5, identical: 3, edited: 1, missing: 1 },
|
|
164
|
+
index: "computed",
|
|
165
|
+
});
|
|
166
|
+
expect(p.trust).toEqual({ path: ".chant/trust.json", entry: { to: head, note: `adopt-lineage . ${fork}@v1.1.0` } });
|
|
167
|
+
expect(JSON.parse(read(ws, ".chant/trust.json"))).toEqual({ schema: 1, adopted: [{ to: head, note: `adopt-lineage . ${fork}@v1.1.0` }] });
|
|
168
|
+
|
|
169
|
+
// P7: the range counts only once the policy at base holds it.
|
|
170
|
+
const view = lineageView(ws)!.scopes[0];
|
|
171
|
+
expect(view.provenance?.level).toBe("unattested");
|
|
172
|
+
expect(view.edited).toEqual(["src/app.ts"]);
|
|
173
|
+
expect(view.missing).toEqual(["docs.md"]);
|
|
174
|
+
commit(ws, "adopt");
|
|
175
|
+
expect(lineageView(ws)!.scopes[0].provenance).toMatchObject({ level: "adopted" });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a template's parameters are substituted before the files are recorded", () => {
|
|
179
|
+
const tpl = join(root, "tpl");
|
|
180
|
+
mkdirSync(tpl);
|
|
181
|
+
git(tpl, ["init", "-q", "-b", "main"]);
|
|
182
|
+
release(tpl, "v1.0.0", {
|
|
183
|
+
"chant.template.json": JSON.stringify({ parameters: { name: { type: "string", default: "starter" } }, files: ["app.json"] }),
|
|
184
|
+
"app.json": '{ "name": "{{chant:name}}" }\n',
|
|
185
|
+
"README.md": "readme\n",
|
|
186
|
+
});
|
|
187
|
+
const proj = join(root, "proj");
|
|
188
|
+
mkdirSync(proj);
|
|
189
|
+
git(proj, ["init", "-q", "-b", "main"]);
|
|
190
|
+
put(proj, "app.json", '{ "name": "shop" }\n');
|
|
191
|
+
put(proj, "README.md", "readme\n");
|
|
192
|
+
commit(proj, "made by hand");
|
|
193
|
+
const p = adoptLineage({ root: proj, from: tpl, params: { name: "shop" } });
|
|
194
|
+
expect(p.identical.sort()).toEqual(["README.md", "app.json"]);
|
|
195
|
+
expect(p.edited).toEqual([]);
|
|
196
|
+
const lineage = readLock(proj)!.scopes["."];
|
|
197
|
+
expect(lineage.parameters).toEqual({ name: "shop" });
|
|
198
|
+
// The manifest is the template's, never the project's.
|
|
199
|
+
expect(Object.keys(lineage.files).sort()).toEqual(["README.md", "app.json"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a tag in --from adopts at exactly that version; --dry-run writes nothing", () => {
|
|
203
|
+
const p = adoptLineage({ root: ws, from: `${fork}@v1.0.0`, dryRun: true });
|
|
204
|
+
expect(p.written).toBe(false);
|
|
205
|
+
expect(p.compared).toBe(1);
|
|
206
|
+
expect(p.chosen).toMatchObject({ tag: "v1.0.0", identical: 2 });
|
|
207
|
+
expect(existsSync(join(ws, LOCK_FILE))).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("versions that match equally are named, and the oldest is adopted", () => {
|
|
211
|
+
// v1.1.1 re-tags the same files as v1.1.0 (on a branch, so the fork's main moves on untouched).
|
|
212
|
+
git(fork, ["tag", "v1.1.1", "v1.1.0"]);
|
|
213
|
+
// A template that ignores .chant/ carries that into the copy.
|
|
214
|
+
put(ws, ".gitignore", ".chant/\n");
|
|
215
|
+
commit(ws, "ignore .chant/");
|
|
216
|
+
const p = adoptLineage({ root: ws, from: fork });
|
|
217
|
+
expect(p.chosen.tag).toBe("v1.1.0");
|
|
218
|
+
expect(p.ties).toEqual(["v1.1.1"]);
|
|
219
|
+
expect(p.alternatives.map((a) => a.tag)).toEqual(["v1.0.0", "v1.2.0"]);
|
|
220
|
+
expect(p.lockIgnored).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("--tags narrows the candidates", () => {
|
|
224
|
+
const p = adoptLineage({ root: ws, from: fork, tags: "v1.2.*", dryRun: true });
|
|
225
|
+
expect(p.compared).toBe(1);
|
|
226
|
+
expect(p.chosen.tag).toBe("v1.2.0");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("refuses a scope with a lineage, uncommitted changes, or nothing in common", () => {
|
|
230
|
+
put(ws, "src/own.ts", "changed\n");
|
|
231
|
+
expect(() => adoptLineage({ root: ws, from: fork })).toThrow(/uncommitted changes .*src\/own\.ts/);
|
|
232
|
+
commit(ws, "more");
|
|
233
|
+
|
|
234
|
+
const other = join(root, "other");
|
|
235
|
+
mkdirSync(other);
|
|
236
|
+
git(other, ["init", "-q", "-b", "main"]);
|
|
237
|
+
release(other, "v1.0.0", { "unrelated.txt": "x\n" });
|
|
238
|
+
expect(() => adoptLineage({ root: ws, from: other })).toThrow(/no version of .* shares a file/);
|
|
239
|
+
|
|
240
|
+
adoptLineage({ root: ws, from: fork });
|
|
241
|
+
expect(() => adoptLineage({ root: ws, from: fork })).toThrow(/already has a lineage for scope "\."/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a cached index is reused, and the adopted tag is re-checked against the template", () => {
|
|
245
|
+
const tags = new TemplateTags(fork, undefined, fork);
|
|
246
|
+
let index: HashIndex;
|
|
247
|
+
try {
|
|
248
|
+
index = computeHashIndex(tags, fork).index;
|
|
249
|
+
} finally {
|
|
250
|
+
tags.dispose();
|
|
251
|
+
}
|
|
252
|
+
expect(index.tags.map((t) => t.tag)).toEqual(["v1.0.0", "v1.1.0", "v1.2.0"]);
|
|
253
|
+
const file = join(root, "index.json");
|
|
254
|
+
writeFileSync(file, renderHashIndex(index));
|
|
255
|
+
const cache = readHashIndex(file);
|
|
256
|
+
|
|
257
|
+
// A cache for another template is refused.
|
|
258
|
+
expect(() => adoptLineage({ root: ws, from: up, cache, dryRun: true })).toThrow(/is for .* not/);
|
|
259
|
+
|
|
260
|
+
const fromCache = adoptLineage({ root: ws, from: fork, cache, dryRun: true });
|
|
261
|
+
expect(fromCache.index).toBe("cache");
|
|
262
|
+
expect(fromCache.chosen.tag).toBe("v1.1.0");
|
|
263
|
+
|
|
264
|
+
// A forged entry makes v1.0.0 look like a perfect match. The re-check catches it, and every tag is computed instead.
|
|
265
|
+
const forged: HashIndex = structuredClone(cache);
|
|
266
|
+
forged.tags[0].files = {
|
|
267
|
+
"README.md": fileHash(read(ws, "README.md")),
|
|
268
|
+
"settings.json": fileHash(read(ws, "settings.json")),
|
|
269
|
+
"src/app.ts": fileHash(read(ws, "src/app.ts")),
|
|
270
|
+
"src/extra.ts": fileHash(read(ws, "src/extra.ts")),
|
|
271
|
+
};
|
|
272
|
+
const rechecked = adoptLineage({ root: ws, from: fork, cache: forged });
|
|
273
|
+
expect(rechecked.index).toBe("computed");
|
|
274
|
+
expect(rechecked.chosen.tag).toBe("v1.1.0");
|
|
275
|
+
expect(readLock(ws)!.scopes["."].adoption?.index).toBe("computed");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a moved tag is recomputed rather than read from the cache", () => {
|
|
279
|
+
const tags = new TemplateTags(fork, undefined, fork);
|
|
280
|
+
let cache: HashIndex;
|
|
281
|
+
try {
|
|
282
|
+
cache = computeHashIndex(tags, fork).index;
|
|
283
|
+
} finally {
|
|
284
|
+
tags.dispose();
|
|
285
|
+
}
|
|
286
|
+
// Retag v1.2.0 onto a new commit.
|
|
287
|
+
git(fork, ["tag", "-d", "v1.2.0"]);
|
|
288
|
+
release(fork, "v1.2.0", { "README.md": "starter (fork, 1.2 retagged)\n" });
|
|
289
|
+
const again = new TemplateTags(fork, undefined, fork);
|
|
290
|
+
try {
|
|
291
|
+
const { index, cached } = computeHashIndex(again, fork, { cache });
|
|
292
|
+
expect([...cached].sort()).toEqual(["v1.0.0", "v1.1.0"]);
|
|
293
|
+
expect(index.tags[2].commit).toBe(git(fork, ["rev-parse", "v1.2.0^{commit}"]));
|
|
294
|
+
expect(index.tags[2].files["README.md"]).toBe(fileHash("starter (fork, 1.2 retagged)\n"));
|
|
295
|
+
} finally {
|
|
296
|
+
again.dispose();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("bringing a fork-born workspace forward", () => {
|
|
302
|
+
test("an adopted scope upgrades within its own template", async () => {
|
|
303
|
+
adoptLineage({ root: ws, from: fork });
|
|
304
|
+
commit(ws, "adopt");
|
|
305
|
+
const staged = await stageUpgrade({ root: ws, to: "v1.2.0", runChant: passing });
|
|
306
|
+
try {
|
|
307
|
+
expect(staged.written).toEqual(["README.md"]);
|
|
308
|
+
expect(staged.manualSteps).toEqual([]);
|
|
309
|
+
} finally {
|
|
310
|
+
staged.dispose();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("adopted against the fork, then moved onto the upstream template by its bridge migration", async () => {
|
|
315
|
+
// Upstream v2.0.0 renames src/app.ts, and ships a bridge for projects of the fork.
|
|
316
|
+
release(up, "v2.0.0", {
|
|
317
|
+
"README.md": "starter v2\n",
|
|
318
|
+
"src/app.ts": null,
|
|
319
|
+
"src/main.ts": "one\ntwo\nthree\nfour\nfive\nsix\nSEVEN (v2)\n",
|
|
320
|
+
"config.json": '{ "name": "starter", "v": 2 }\n',
|
|
321
|
+
".chant/migrations/rename.json": JSON.stringify({
|
|
322
|
+
id: "rename-app",
|
|
323
|
+
from: { versions: ">=1.0.0 <2.0.0" },
|
|
324
|
+
to: "2.0.0",
|
|
325
|
+
body: { type: "declarative", steps: [{ op: "move", from: "src/app.ts", to: "src/main.ts" }] },
|
|
326
|
+
}),
|
|
327
|
+
".chant/migrations/from-fork.json": JSON.stringify({
|
|
328
|
+
id: "from-fork",
|
|
329
|
+
description: "the fork's layout onto upstream 2.0.0",
|
|
330
|
+
from: { template: fork, versions: ">=1.0.0 <2.0.0" },
|
|
331
|
+
to: "2.0.0",
|
|
332
|
+
body: {
|
|
333
|
+
type: "declarative",
|
|
334
|
+
steps: [
|
|
335
|
+
{ op: "move", from: "src/app.ts", to: "src/main.ts" },
|
|
336
|
+
{ op: "move", from: "settings.json", to: "config.json" },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
post: [
|
|
340
|
+
{ check: "exists", path: "config.json" },
|
|
341
|
+
{ check: "absent", path: "settings.json" },
|
|
342
|
+
],
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
adoptLineage({ root: ws, from: fork });
|
|
347
|
+
commit(ws, "adopt the fork's lineage");
|
|
348
|
+
|
|
349
|
+
const port = ledger();
|
|
350
|
+
const first = await upgradeCommand({ root: ws, source: up, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:00:00.000Z" });
|
|
351
|
+
expect(first.outcome).toBe("gated");
|
|
352
|
+
expect(first.staged).toMatchObject({ template: fork, switchedTo: up, from: "v1.1.0", to: "v2.0.0", migrations: ["from-fork"] });
|
|
353
|
+
expect(read(ws, "settings.json")).toBe('{ "name": "starter" }\n');
|
|
354
|
+
|
|
355
|
+
port.approve(first.staged!.digest, "2026-09-23T10:01:00.000Z");
|
|
356
|
+
const applied = await upgradeCommand({ root: ws, source: up, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:02:00.000Z" });
|
|
357
|
+
expect(applied.outcome).toBe("applied");
|
|
358
|
+
expect(applied.staged!.merged).toEqual(["src/main.ts"]);
|
|
359
|
+
|
|
360
|
+
// The workspace is on upstream v2.0.0, with its own edit kept and upstream's merged in.
|
|
361
|
+
expect(read(ws, "src/main.ts")).toBe("one\ntwo\nthree\nfour (ours)\nfive\nsix\nSEVEN (v2)\n");
|
|
362
|
+
expect(read(ws, "config.json")).toBe('{ "name": "starter", "v": 2 }\n');
|
|
363
|
+
expect(read(ws, "README.md")).toBe("starter v2\n");
|
|
364
|
+
expect(read(ws, "src/own.ts")).toBe("export const own = 1;\n");
|
|
365
|
+
for (const gone of ["settings.json", "src/app.ts", "src/extra.ts", "docs.md"]) expect(existsSync(join(ws, gone)), gone).toBe(false);
|
|
366
|
+
|
|
367
|
+
const lineage = readLock(ws)!.scopes["."];
|
|
368
|
+
expect(lineage).toMatchObject({ template: up, source: { type: "git", repo: up, url: "../up" }, ref: "v2.0.0", migrations: ["from-fork"], manualSteps: [] });
|
|
369
|
+
expect(lineage.address?.commit).toBe(git(up, ["rev-parse", "v2.0.0^{commit}"]));
|
|
370
|
+
expect(Object.keys(lineage.files).sort()).toEqual(["README.md", "config.json", "src/main.ts"]);
|
|
371
|
+
// The adoption stays: it describes the history before the lock.
|
|
372
|
+
expect(lineage.adoption?.by).toBe("files");
|
|
373
|
+
|
|
374
|
+
// The next upstream release is an ordinary upgrade.
|
|
375
|
+
commit(ws, "on upstream v2");
|
|
376
|
+
release(up, "v2.1.0", { "README.md": "starter v2.1\n" });
|
|
377
|
+
const staged = await stageUpgrade({ root: ws, to: "v2.1.0", runChant: passing });
|
|
378
|
+
try {
|
|
379
|
+
expect(staged.written).toEqual(["README.md"]);
|
|
380
|
+
expect(staged.migrations).toEqual([]);
|
|
381
|
+
} finally {
|
|
382
|
+
staged.dispose();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("moving to a template with migrations but no bridge is refused", async () => {
|
|
387
|
+
release(up, "v2.0.0", {
|
|
388
|
+
"README.md": "starter v2\n",
|
|
389
|
+
".chant/migrations/rename.json": JSON.stringify({
|
|
390
|
+
id: "rename-app",
|
|
391
|
+
from: { versions: ">=1.0.0 <2.0.0" },
|
|
392
|
+
to: "2.0.0",
|
|
393
|
+
body: { type: "declarative", steps: [{ op: "move", from: "src/app.ts", to: "src/main.ts" }] },
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
adoptLineage({ root: ws, from: fork });
|
|
397
|
+
commit(ws, "adopt");
|
|
398
|
+
await expect(stageUpgrade({ root: ws, source: up, to: "v2.0.0", runChant: passing })).rejects.toThrow(/needs a bridge migration/);
|
|
399
|
+
await expect(stageUpgrade({ root: ws, source: up, runChant: passing })).rejects.toThrow(/needs --to/);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("chant workspace versions", () => {
|
|
404
|
+
test("reports each workspace's template and plugin versions, by family", () => {
|
|
405
|
+
adoptLineage({ root: ws, from: fork });
|
|
406
|
+
// A second member of the fork's family, adopted at an older tag, with other plugin versions.
|
|
407
|
+
const ws2 = join(root, "ws2");
|
|
408
|
+
copyAt(fork, "v1.0.0", ws2);
|
|
409
|
+
git(ws2, ["init", "-q", "-b", "main"]);
|
|
410
|
+
commit(ws2, "copied");
|
|
411
|
+
adoptLineage({ root: ws2, from: fork });
|
|
412
|
+
for (const [dir, version] of [
|
|
413
|
+
[ws, "0.80.0"],
|
|
414
|
+
[ws2, "0.79.0"],
|
|
415
|
+
] as const) {
|
|
416
|
+
put(dir, "package.json", JSON.stringify({ dependencies: { "@intentius/chant": `^${version}`, "left-pad": "1" } }));
|
|
417
|
+
put(dir, "node_modules/@intentius/chant/package.json", JSON.stringify({ name: "@intentius/chant", version }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const report = workspaceVersions(root);
|
|
421
|
+
expect(report.workspaces.map((w) => w.path)).toEqual(["ws", "ws2"]);
|
|
422
|
+
expect(report.workspaces[0].scopes).toEqual([
|
|
423
|
+
{
|
|
424
|
+
scope: ".",
|
|
425
|
+
kind: "template",
|
|
426
|
+
template: fork,
|
|
427
|
+
ref: "v1.1.0",
|
|
428
|
+
version: "1.1.0",
|
|
429
|
+
commit: git(fork, ["rev-parse", "v1.1.0^{commit}"]),
|
|
430
|
+
migrations: 0,
|
|
431
|
+
adoption: "files",
|
|
432
|
+
provenance: "unattested",
|
|
433
|
+
source: { type: "git", url: fork },
|
|
434
|
+
},
|
|
435
|
+
]);
|
|
436
|
+
expect(report.workspaces[0].plugins).toEqual([{ name: "@intentius/chant", declared: "^0.80.0", installed: "0.80.0" }]);
|
|
437
|
+
expect(report.families).toEqual([
|
|
438
|
+
{
|
|
439
|
+
template: fork,
|
|
440
|
+
newest: "1.1.0",
|
|
441
|
+
members: [
|
|
442
|
+
{ path: "ws", scope: ".", ref: "v1.1.0", version: "1.1.0", behind: false },
|
|
443
|
+
{ path: "ws2", scope: ".", ref: "v1.0.0", version: "1.0.0", behind: true },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
]);
|
|
447
|
+
expect(report.pluginSpread).toEqual([{ template: fork, name: "@intentius/chant", versions: { "0.80.0": ["ws"], "0.79.0": ["ws2"] } }]);
|
|
448
|
+
|
|
449
|
+
// --available lists the template's own version tags, and how far behind them each member is.
|
|
450
|
+
const available = workspaceVersions(root, { available: true });
|
|
451
|
+
expect(available.families[0].available).toEqual({ tags: ["v1.0.0", "v1.1.0", "v1.2.0"], newest: "v1.2.0" });
|
|
452
|
+
expect(available.families[0].members.map((m) => [m.path, m.newer])).toEqual([
|
|
453
|
+
["ws", 1],
|
|
454
|
+
["ws2", 2],
|
|
455
|
+
]);
|
|
456
|
+
expect(workspaceVersions(root, { available: true, tags: "v1.1.*" }).families[0].available?.tags).toEqual(["v1.1.0"]);
|
|
457
|
+
|
|
458
|
+
// Narrowed to a template no workspace uses, the report is empty.
|
|
459
|
+
expect(workspaceVersions(root, "github.com/nobody/nothing").workspaces).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("moving a directory lineage onto git (studio boxes across kit releases)", () => {
|
|
464
|
+
let studio: string;
|
|
465
|
+
let image: string;
|
|
466
|
+
let box: string;
|
|
467
|
+
|
|
468
|
+
/** The kit image's template directory: the studio's template/ at a ref, as plain files with no .git. */
|
|
469
|
+
function bake(ref: string): void {
|
|
470
|
+
const scratch = join(root, `bake-${ref.replace(/[^a-z0-9]/gi, "-")}`);
|
|
471
|
+
copyAt(studio, ref, scratch);
|
|
472
|
+
rmSync(image, { recursive: true, force: true });
|
|
473
|
+
execFileSync("mv", [join(scratch, "template"), image]);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
beforeEach(async () => {
|
|
477
|
+
studio = join(root, "studio");
|
|
478
|
+
image = join(root, "image-template");
|
|
479
|
+
box = join(root, "box");
|
|
480
|
+
mkdirSync(studio);
|
|
481
|
+
git(studio, ["init", "-q", "-b", "main"]);
|
|
482
|
+
release(studio, "kit-v1.0.0", {
|
|
483
|
+
"README.md": "the studio\n",
|
|
484
|
+
"template/chant.template.json": JSON.stringify({ parameters: { box: { type: "string" } }, files: ["box.json"] }),
|
|
485
|
+
"template/box.json": '{ "box": "{{chant:box}}" }\n',
|
|
486
|
+
"template/app.ts": APP_V1,
|
|
487
|
+
});
|
|
488
|
+
bake("kit-v1.0.0");
|
|
489
|
+
const made = await initFromCommand({ from: image, path: box, params: { box: "b1" } });
|
|
490
|
+
expect(made.error).toBeUndefined();
|
|
491
|
+
git(box, ["init", "-q", "-b", "main"]);
|
|
492
|
+
commit(box, "chant init --from ~/box/template");
|
|
493
|
+
put(box, "app.ts", "one\ntwo\nthree\nfour (box)\nfive\nsix\nseven\n");
|
|
494
|
+
commit(box, "the box's own change");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("a kit release replaces the directory; adoption moves the lineage onto the studio repository, and the upgrade merges", async () => {
|
|
498
|
+
release(studio, "kit-v2.0.0", {
|
|
499
|
+
"template/app.ts": "one\ntwo\nthree\nfour\nfive\nsix\nSEVEN (kit 2)\n",
|
|
500
|
+
"template/box.json": '{ "box": "{{chant:box}}", "kit": 2 }\n',
|
|
501
|
+
});
|
|
502
|
+
bake("kit-v2.0.0");
|
|
503
|
+
// The directory now holds the new kit's template, so the old merge base is gone.
|
|
504
|
+
await expect(stageUpgrade({ root: box, to: image, runChant: passing })).rejects.toThrow(/no longer holds the files the scope was made from/);
|
|
505
|
+
|
|
506
|
+
const p = adoptLineage({ root: box, from: `${studio}#template` });
|
|
507
|
+
expect(p).toMatchObject({ by: "lineage", chosen: { tag: "kit-v1.0.0", identical: 2, edited: 0, missing: 0 }, trust: null, ties: [] });
|
|
508
|
+
expect(existsSync(join(box, ".chant/trust.json"))).toBe(false);
|
|
509
|
+
const lineage = readLock(box)!.scopes["."];
|
|
510
|
+
expect(lineage).toMatchObject({
|
|
511
|
+
template: `${studio}#template`,
|
|
512
|
+
source: { type: "git", repo: studio, url: "../studio", path: "template" },
|
|
513
|
+
ref: "kit-v1.0.0",
|
|
514
|
+
parameters: { box: "b1" },
|
|
515
|
+
adoption: { by: "lineage", previous: { source: { type: "dir", path: image } } },
|
|
516
|
+
});
|
|
517
|
+
expect(lineage.address?.commit).toBe(git(studio, ["rev-parse", "kit-v1.0.0^{commit}"]));
|
|
518
|
+
expect(lineageView(box)!.scopes[0].provenance).toBeUndefined();
|
|
519
|
+
|
|
520
|
+
commit(box, "adopt the studio's lineage");
|
|
521
|
+
const staged = await stageUpgrade({ root: box, to: "kit-v2.0.0", runChant: passing });
|
|
522
|
+
try {
|
|
523
|
+
expect(staged.merged).toEqual(["app.ts"]);
|
|
524
|
+
expect(staged.written).toEqual(["box.json"]);
|
|
525
|
+
expect(staged.manualSteps).toEqual([]);
|
|
526
|
+
} finally {
|
|
527
|
+
staged.dispose();
|
|
528
|
+
}
|
|
529
|
+
const port = ledger();
|
|
530
|
+
const first = await upgradeCommand({ root: box, to: "kit-v2.0.0", runChant: passing, ledger: port, now: "2026-09-30T10:00:00.000Z" });
|
|
531
|
+
port.approve(first.staged!.digest, "2026-09-30T10:01:00.000Z");
|
|
532
|
+
const applied = await upgradeCommand({ root: box, to: "kit-v2.0.0", runChant: passing, ledger: port, now: "2026-09-30T10:02:00.000Z" });
|
|
533
|
+
expect(applied.outcome).toBe("applied");
|
|
534
|
+
expect(read(box, "app.ts")).toBe("one\ntwo\nthree\nfour (box)\nfive\nsix\nSEVEN (kit 2)\n");
|
|
535
|
+
expect(read(box, "box.json")).toBe('{ "box": "b1", "kit": 2 }\n');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("a ref names the exact commit the image was baked from; a version that does not reproduce the recorded files is refused", () => {
|
|
539
|
+
const baked = git(studio, ["rev-parse", "kit-v1.0.0^{commit}"]);
|
|
540
|
+
release(studio, "kit-v1.1.0", { "template/app.ts": "changed\n" });
|
|
541
|
+
expect(() => adoptLineage({ root: box, from: `${studio}#template`, tags: "kit-v1.1.*", dryRun: true })).toThrow(
|
|
542
|
+
/no version of .* reproduces the files the scope was made from .*the nearest, kit-v1\.1\.0, differs in app\.ts/,
|
|
543
|
+
);
|
|
544
|
+
expect(() => adoptLineage({ root: box, from: `${studio}@main#template`, dryRun: true })).toThrow(/differs in app\.ts/);
|
|
545
|
+
const p = adoptLineage({ root: box, from: `${studio}@${baked}#template` });
|
|
546
|
+
expect(p).toMatchObject({ by: "lineage", compared: 1, chosen: { tag: baked, commit: baked } });
|
|
547
|
+
expect(readLock(box)!.scopes["."].ref).toBe(baked);
|
|
548
|
+
// A scope whose lineage is not a directory lineage is refused.
|
|
549
|
+
commit(box, "adopted");
|
|
550
|
+
expect(() => adoptLineage({ root: box, from: `${studio}#template` })).toThrow(/already has a lineage .* moves a directory lineage onto git/);
|
|
551
|
+
});
|
|
552
|
+
});
|