@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hash index that `chant workspace adopt-lineage` matches a scope against
|
|
3
|
+
* (#2551, D9, ws-006).
|
|
4
|
+
*
|
|
5
|
+
* For each tagged version of a template, the index lists every file the
|
|
6
|
+
* template has at that version with the SHA-256 of its content. It is computed
|
|
7
|
+
* on demand from the template's tags: no registry holds it, and a template
|
|
8
|
+
* needs no extra publishing to be adoptable. A template's CI may publish a copy
|
|
9
|
+
* (`chant workspace hash-index`) and an adopter may pass it with `--index`.
|
|
10
|
+
* That copy is a cache only. chant reuses an entry only while the tag still
|
|
11
|
+
* names the commit the entry records, and it recomputes the entry it adopts
|
|
12
|
+
* from the template itself before anything reaches the lock.
|
|
13
|
+
*
|
|
14
|
+
* ws-006 also names parameter masking: rendering each version with and without
|
|
15
|
+
* its parameters, so a file that differs only by a parameter value still
|
|
16
|
+
* matches. The index hashes the files as the tag holds them, with the
|
|
17
|
+
* template's `chant.template.json` left out, since a project never receives
|
|
18
|
+
* it (#2627). A file a parameter changed reads as edited when matched against
|
|
19
|
+
* a scope's files; adopt-lineage records it with its substituted hash once
|
|
20
|
+
* the version is chosen. Moving a directory lineage onto git renders every
|
|
21
|
+
* candidate with the recorded parameters instead (./lineage-adopt.ts).
|
|
22
|
+
*
|
|
23
|
+
* The network steps are `git ls-remote --tags` and one `git fetch` of the tags
|
|
24
|
+
* to compute, both catalogued in `test/egress-catalogue.ts`. A local
|
|
25
|
+
* repository reaches nothing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
import { git } from "./lineage-init";
|
|
35
|
+
import { LOCK_FILE, LockError } from "./lineage-lock";
|
|
36
|
+
import { MIGRATIONS_DIR } from "./lineage-migrations";
|
|
37
|
+
import { compareVersions, parseVersion } from "./lineage-version";
|
|
38
|
+
import { TEMPLATE_MANIFEST } from "./template-manifest";
|
|
39
|
+
|
|
40
|
+
/** The index format this chant reads and writes. */
|
|
41
|
+
export const HASH_INDEX_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
const Sha256 = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
44
|
+
const ObjectId = z.string().regex(/^[0-9a-f]{40,64}$/);
|
|
45
|
+
|
|
46
|
+
const EntrySchema = z
|
|
47
|
+
.object({
|
|
48
|
+
tag: z.string().min(1),
|
|
49
|
+
commit: ObjectId,
|
|
50
|
+
/** The tree of the template's directory at the tag. */
|
|
51
|
+
tree: ObjectId,
|
|
52
|
+
/** Per file, relative to the template's directory: `sha256:<hex>` of its content. */
|
|
53
|
+
files: z.record(z.string(), Sha256),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
export type HashIndexEntry = z.infer<typeof EntrySchema>;
|
|
57
|
+
|
|
58
|
+
const IndexSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
indexVersion: z.literal(HASH_INDEX_VERSION),
|
|
61
|
+
/** The template id, as the lock writes it: `github.com/acme/starter#service`. */
|
|
62
|
+
template: z.string().min(1),
|
|
63
|
+
/** Oldest version first. */
|
|
64
|
+
tags: z.array(EntrySchema),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
67
|
+
export type HashIndex = z.infer<typeof IndexSchema>;
|
|
68
|
+
|
|
69
|
+
/** Read and validate an index file, such as a copy a template's CI published. */
|
|
70
|
+
export function readHashIndex(path: string): HashIndex {
|
|
71
|
+
if (!existsSync(path)) throw new LockError(`no hash index at ${path}`);
|
|
72
|
+
let raw: unknown;
|
|
73
|
+
try {
|
|
74
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new LockError(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
77
|
+
}
|
|
78
|
+
const parsed = IndexSchema.safeParse(raw);
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
throw new LockError(`invalid hash index ${path}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
81
|
+
}
|
|
82
|
+
return parsed.data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderHashIndex(index: HashIndex): string {
|
|
86
|
+
return JSON.stringify(IndexSchema.parse(index), null, 2) + "\n";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a template path is left out of the index: files a project never receives from the template. */
|
|
90
|
+
export function indexExcludes(path: string): boolean {
|
|
91
|
+
return path === LOCK_FILE || path === TEMPLATE_MANIFEST || path.startsWith(`${MIGRATIONS_DIR}/`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A tag glob (`*` and `?`) as a regular expression over the whole tag name. */
|
|
95
|
+
function globRegExp(glob: string): RegExp {
|
|
96
|
+
const body = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
97
|
+
return new RegExp(`^${body}$`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RemoteTag {
|
|
101
|
+
tag: string;
|
|
102
|
+
/** The commit the tag names, peeled through an annotated tag. */
|
|
103
|
+
commit: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A scratch repository to read a template's tags in. Every method works on it;
|
|
108
|
+
* `dispose()` deletes it.
|
|
109
|
+
*/
|
|
110
|
+
export class TemplateTags {
|
|
111
|
+
readonly scratch: string;
|
|
112
|
+
private readonly blobHashes = new Map<string, string>();
|
|
113
|
+
private readonly fetched = new Set<string>();
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
readonly url: string,
|
|
117
|
+
readonly member: string | undefined,
|
|
118
|
+
readonly label: string,
|
|
119
|
+
) {
|
|
120
|
+
this.scratch = mkdtempSync(join(tmpdir(), "chant-hash-index-"));
|
|
121
|
+
git(this.scratch, ["init", "-q"]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dispose(): void {
|
|
125
|
+
rmSync(this.scratch, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The template's version tags, oldest first: every tag whose name reads as
|
|
130
|
+
* a version, narrowed by `pattern` when given (`chant-v*`).
|
|
131
|
+
*/
|
|
132
|
+
listTags(pattern?: string): RemoteTag[] {
|
|
133
|
+
let out: string;
|
|
134
|
+
try {
|
|
135
|
+
// A network step of adopt-lineage and hash-index, catalogued in test/egress-catalogue.ts.
|
|
136
|
+
out = git(this.scratch, ["ls-remote", "--tags", this.url]);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const stderr = (err as { stderr?: string }).stderr?.toString().trim();
|
|
139
|
+
throw new LockError(`could not list the tags of ${this.label}${stderr ? `: ${stderr}` : ""}`);
|
|
140
|
+
}
|
|
141
|
+
const byTag = new Map<string, string>();
|
|
142
|
+
const peeled = new Set<string>();
|
|
143
|
+
for (const line of out.split("\n")) {
|
|
144
|
+
const [sha, ref] = line.split("\t");
|
|
145
|
+
if (!sha || !ref?.startsWith("refs/tags/")) continue;
|
|
146
|
+
const name = ref.slice("refs/tags/".length);
|
|
147
|
+
if (name.endsWith("^{}")) {
|
|
148
|
+
const tag = name.slice(0, -3);
|
|
149
|
+
byTag.set(tag, sha);
|
|
150
|
+
peeled.add(tag);
|
|
151
|
+
} else if (!peeled.has(name)) {
|
|
152
|
+
byTag.set(name, sha);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const match = pattern ? globRegExp(pattern) : null;
|
|
156
|
+
return [...byTag.entries()]
|
|
157
|
+
.filter(([tag]) => parseVersion(tag) !== null && (!match || match.test(tag)))
|
|
158
|
+
.map(([tag, commit]) => ({ tag, commit }))
|
|
159
|
+
.sort((a, b) => compareVersions(parseVersion(a.tag)!, parseVersion(b.tag)!) || a.tag.localeCompare(b.tag));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Fetch the given tags, one commit deep each, in as few `git fetch` calls as the argument limit allows. */
|
|
163
|
+
fetchTags(tags: string[]): void {
|
|
164
|
+
const todo = tags.filter((t) => !this.fetched.has(t));
|
|
165
|
+
for (let i = 0; i < todo.length; i += 100) {
|
|
166
|
+
const batch = todo.slice(i, i + 100);
|
|
167
|
+
try {
|
|
168
|
+
// A network step of adopt-lineage and hash-index, catalogued in test/egress-catalogue.ts.
|
|
169
|
+
git(this.scratch, ["fetch", "-q", "--depth", "1", "--no-tags", this.url, ...batch.map((t) => `+refs/tags/${t}:refs/tags/${t}`)]);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const stderr = (err as { stderr?: string }).stderr?.toString().trim();
|
|
172
|
+
throw new LockError(`could not fetch the tags of ${this.label}${stderr ? `: ${stderr}` : ""}`);
|
|
173
|
+
}
|
|
174
|
+
for (const t of batch) this.fetched.add(t);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fetch one ref that is not a version tag, such as a branch or a commit, one
|
|
180
|
+
* commit deep, and return the commit it names.
|
|
181
|
+
*/
|
|
182
|
+
fetchRef(ref: string): RemoteTag {
|
|
183
|
+
try {
|
|
184
|
+
// A network step of adopt-lineage, catalogued in test/egress-catalogue.ts.
|
|
185
|
+
git(this.scratch, ["fetch", "-q", "--depth", "1", "--no-tags", this.url, ref]);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const stderr = (err as { stderr?: string }).stderr?.toString().trim();
|
|
188
|
+
throw new LockError(`could not fetch ${this.label}@${ref}${stderr ? `: ${stderr}` : ""}`);
|
|
189
|
+
}
|
|
190
|
+
return { tag: ref, commit: git(this.scratch, ["rev-parse", "FETCH_HEAD^{commit}"]) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The index entry of one fetched tag: its tree and the SHA-256 of every file
|
|
195
|
+
* under the template's directory. Null when the directory does not exist at
|
|
196
|
+
* that tag: a template kept in one directory of a larger repository is
|
|
197
|
+
* absent from the tags cut before it was added.
|
|
198
|
+
*/
|
|
199
|
+
entry(tag: RemoteTag): HashIndexEntry | null {
|
|
200
|
+
let tree: string;
|
|
201
|
+
try {
|
|
202
|
+
tree = git(this.scratch, ["rev-parse", this.member ? `${tag.commit}:${this.member}` : `${tag.commit}^{tree}`]);
|
|
203
|
+
if (git(this.scratch, ["cat-file", "-t", tree]) !== "tree") throw new Error("not a tree");
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const listing = execFileSync("git", ["ls-tree", "-r", "-z", tree], { cwd: this.scratch, maxBuffer: 256 * 1024 * 1024 }).toString("utf-8");
|
|
208
|
+
const blobs: Array<{ path: string; sha: string }> = [];
|
|
209
|
+
for (const row of listing.split("\0")) {
|
|
210
|
+
if (!row) continue;
|
|
211
|
+
const tab = row.indexOf("\t");
|
|
212
|
+
const [mode, type, sha] = row.slice(0, tab).split(" ");
|
|
213
|
+
const path = row.slice(tab + 1);
|
|
214
|
+
// The same files `chant init --from` copies: no symbolic links, submodules, lock or migrations.
|
|
215
|
+
if (type !== "blob" || mode === "120000" || indexExcludes(path)) continue;
|
|
216
|
+
blobs.push({ path, sha });
|
|
217
|
+
}
|
|
218
|
+
this.hashBlobs(blobs.map((b) => b.sha));
|
|
219
|
+
const files: Record<string, string> = {};
|
|
220
|
+
for (const b of blobs.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0))) files[b.path] = this.blobHashes.get(b.sha)!;
|
|
221
|
+
return { tag: tag.tag, commit: tag.commit, tree, files };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** SHA-256 each blob not seen yet, read in one `git cat-file --batch`. Blobs shared between tags are hashed once. */
|
|
225
|
+
private hashBlobs(ids: string[]): void {
|
|
226
|
+
const todo = [...new Set(ids)].filter((id) => !this.blobHashes.has(id));
|
|
227
|
+
if (todo.length === 0) return;
|
|
228
|
+
const out = execFileSync("git", ["cat-file", "--batch"], { cwd: this.scratch, input: todo.join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
|
|
229
|
+
let at = 0;
|
|
230
|
+
for (const id of todo) {
|
|
231
|
+
const eol = out.indexOf(0x0a, at);
|
|
232
|
+
const header = out.subarray(at, eol).toString("utf-8").split(" ");
|
|
233
|
+
if (header[0] !== id || header[1] !== "blob") throw new LockError(`${this.label}: could not read blob ${id}`);
|
|
234
|
+
const size = Number(header[2]);
|
|
235
|
+
const data = out.subarray(eol + 1, eol + 1 + size);
|
|
236
|
+
this.blobHashes.set(id, `sha256:${createHash("sha256").update(data).digest("hex")}`);
|
|
237
|
+
at = eol + 1 + size + 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface ComputedIndex {
|
|
243
|
+
index: HashIndex;
|
|
244
|
+
/** Tags whose entry came from the cache, unverified until one is adopted. */
|
|
245
|
+
cached: Set<string>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The index of a template, from its tags. With `cache`, an entry is reused when
|
|
250
|
+
* its tag still names the recorded commit; the others are fetched and
|
|
251
|
+
* computed. `only` restricts the index to one ref the user named: a version
|
|
252
|
+
* tag, or any other ref git can fetch, such as a branch or a commit.
|
|
253
|
+
*/
|
|
254
|
+
export function computeHashIndex(
|
|
255
|
+
tags: TemplateTags,
|
|
256
|
+
template: string,
|
|
257
|
+
options: { pattern?: string; cache?: HashIndex; only?: string } = {},
|
|
258
|
+
): ComputedIndex {
|
|
259
|
+
let remote: RemoteTag[];
|
|
260
|
+
if (options.only !== undefined) {
|
|
261
|
+
// A named ref: a version tag when the template has one by that name, otherwise any ref git can fetch.
|
|
262
|
+
const only = options.only;
|
|
263
|
+
const tag = tags.listTags().find((t) => t.tag === only);
|
|
264
|
+
if (!tag) {
|
|
265
|
+
const ref = tags.fetchRef(only);
|
|
266
|
+
const entry = tags.entry(ref);
|
|
267
|
+
if (!entry) throw new LockError(`${tags.label}@${only} has no directory ${tags.member}`);
|
|
268
|
+
return { index: { indexVersion: HASH_INDEX_VERSION, template, tags: [entry] }, cached: new Set() };
|
|
269
|
+
}
|
|
270
|
+
remote = [tag];
|
|
271
|
+
} else {
|
|
272
|
+
remote = tags.listTags(options.pattern);
|
|
273
|
+
}
|
|
274
|
+
if (remote.length === 0) {
|
|
275
|
+
throw new LockError(
|
|
276
|
+
`${tags.label} has no tag that reads as a version${options.pattern ? ` and matches ${options.pattern}` : ""}; adopt-lineage matches against tagged releases`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (options.cache && options.cache.template !== template) {
|
|
280
|
+
throw new LockError(`the hash index is for ${options.cache.template}, not ${template}`);
|
|
281
|
+
}
|
|
282
|
+
const fromCache = new Map((options.cache?.tags ?? []).map((e) => [e.tag, e]));
|
|
283
|
+
const cached = new Set<string>();
|
|
284
|
+
const entries: HashIndexEntry[] = [];
|
|
285
|
+
const toCompute = remote.filter((t) => {
|
|
286
|
+
const hit = fromCache.get(t.tag);
|
|
287
|
+
return !hit || hit.commit !== t.commit;
|
|
288
|
+
});
|
|
289
|
+
tags.fetchTags(toCompute.map((t) => t.tag));
|
|
290
|
+
const computed = new Map(toCompute.map((t) => [t.tag, tags.entry(t)]));
|
|
291
|
+
for (const t of remote) {
|
|
292
|
+
if (computed.has(t.tag)) {
|
|
293
|
+
// A tag without the template's directory has no entry.
|
|
294
|
+
const own = computed.get(t.tag);
|
|
295
|
+
if (own) entries.push(own);
|
|
296
|
+
} else {
|
|
297
|
+
entries.push(fromCache.get(t.tag)!);
|
|
298
|
+
cached.add(t.tag);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (entries.length === 0) {
|
|
302
|
+
throw new LockError(`${tags.label}: no version tag${options.only !== undefined ? ` ${options.only}` : ""} has the directory ${tags.member}`);
|
|
303
|
+
}
|
|
304
|
+
return { index: { indexVersion: HASH_INDEX_VERSION, template, tags: entries }, cached };
|
|
305
|
+
}
|
|
@@ -180,6 +180,15 @@ describe("chant init --from with parameters (#2627)", () => {
|
|
|
180
180
|
expect(scope.files["chant.template.json"]).toBeUndefined();
|
|
181
181
|
// The merge base is the file as written, with the value in it.
|
|
182
182
|
expect(scope.files["src/main.ts"].sha256).toBe(fileHash(readFileSync(join(target, "src/main.ts"))));
|
|
183
|
+
// The host-bound parameter, with the listed files that carry it, for export and import (#2552).
|
|
184
|
+
expect(scope.hostBound).toEqual({ url: ["src/main.ts"] });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a template with no host-bound parameter records no hostBound (#2552)", async () => {
|
|
188
|
+
declare({ parameters: { name: MANIFEST.parameters.name }, files: ["package.json"] }, { "src/main.ts": "plain\n" });
|
|
189
|
+
const target = join(root, "proj");
|
|
190
|
+
expect((await initFromCommand({ from: `${tpl}@main#svc`, path: target })).error).toBeUndefined();
|
|
191
|
+
expect(readLock(target)!.scopes["."]).not.toHaveProperty("hostBound");
|
|
183
192
|
});
|
|
184
193
|
|
|
185
194
|
test("an undeclared --param is refused with the declared names listed, and nothing is written", async () => {
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type Lineage,
|
|
36
36
|
} from "./lineage-lock";
|
|
37
37
|
import { MIGRATIONS_DIR } from "./lineage-migrations";
|
|
38
|
-
import { TEMPLATE_MANIFEST, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
|
|
38
|
+
import { TEMPLATE_MANIFEST, hostBoundFiles, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
|
|
39
39
|
import { repinSubstituted, type RepinnedRecord } from "./template-pins";
|
|
40
40
|
|
|
41
41
|
// ── The template spec ────────────────────────────────────────────────────────
|
|
@@ -71,14 +71,31 @@ function splitMember(spec: string, where: string): { rest: string; member?: stri
|
|
|
71
71
|
* `git@host:path`, a local path, or `owner/name` for a GitHub repository.
|
|
72
72
|
*/
|
|
73
73
|
export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): TemplateSpec {
|
|
74
|
-
const
|
|
74
|
+
const parsed = parseTemplateSource(spec, cwd, "--from");
|
|
75
|
+
if (parsed.ref === undefined) {
|
|
76
|
+
throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0, or an existing directory`);
|
|
77
|
+
}
|
|
78
|
+
return { ...parsed, ref: parsed.ref };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse `<repo>[@<ref>][#<member>]`, where the ref may be left out: the form
|
|
83
|
+
* `chant workspace adopt-lineage --from`, `hash-index --from` and
|
|
84
|
+
* `upgrade --source` take (#2551). `flag` names the option in messages.
|
|
85
|
+
*/
|
|
86
|
+
export function parseTemplateSource(spec: string, cwd: string = process.cwd(), flag = "--from"): Omit<TemplateSpec, "ref"> & { ref?: string } {
|
|
87
|
+
const { rest: whole, member } = splitMember(spec, "the repository");
|
|
88
|
+
let rest = whole;
|
|
89
|
+
let ref: string | undefined;
|
|
75
90
|
const at = rest.lastIndexOf("@");
|
|
76
91
|
const lastSep = Math.max(rest.lastIndexOf("/"), rest.lastIndexOf(":"));
|
|
77
|
-
if (at
|
|
78
|
-
throw new LockError(
|
|
92
|
+
if (at > 0 && at > lastSep) {
|
|
93
|
+
if (at === rest.length - 1) throw new LockError(`${flag} ${spec}: the ref after "@" is empty; expected <repo>@<ref>[#<member>]`);
|
|
94
|
+
ref = rest.slice(at + 1);
|
|
95
|
+
rest = rest.slice(0, at);
|
|
79
96
|
}
|
|
80
|
-
const repo = rest
|
|
81
|
-
|
|
97
|
+
const repo = rest;
|
|
98
|
+
if (!repo) throw new LockError(`${flag} ${spec}: no repository`);
|
|
82
99
|
|
|
83
100
|
let url: string;
|
|
84
101
|
let repoId: string;
|
|
@@ -99,9 +116,9 @@ export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): Te
|
|
|
99
116
|
url = `https://${repo}${repo.endsWith(".git") ? "" : ".git"}`;
|
|
100
117
|
repoId = repo.replace(/\.git$/, "");
|
|
101
118
|
} else {
|
|
102
|
-
throw new LockError(
|
|
119
|
+
throw new LockError(`${flag} ${spec}: "${repo}" is not a URL, a local repository or owner/name`);
|
|
103
120
|
}
|
|
104
|
-
return { repo, url, ref, member, id: member ? `${repoId}#${member}` : repoId };
|
|
121
|
+
return { repo, url, ...(ref !== undefined ? { ref } : {}), member, id: member ? `${repoId}#${member}` : repoId };
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
// ── Fetch ────────────────────────────────────────────────────────────────────
|
|
@@ -415,10 +432,12 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
|
|
|
415
432
|
let parameters: Record<string, string>;
|
|
416
433
|
let contents: Map<string, Buffer>;
|
|
417
434
|
let repinned: RepinnedRecord[];
|
|
435
|
+
let hostBound: Record<string, string[]> | undefined;
|
|
418
436
|
try {
|
|
419
437
|
const raw = new Map([...fetched.files].map(([path, f]) => [path, f.data]));
|
|
420
438
|
const manifest = readManifest(raw);
|
|
421
439
|
parameters = resolveParameters(manifest, options.params ?? {});
|
|
440
|
+
hostBound = hostBoundFiles(raw, manifest);
|
|
422
441
|
// Records that pin a substituted file get its new hash, so a copy's pins hold (#2549).
|
|
423
442
|
({ files: contents, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []));
|
|
424
443
|
} catch (err) {
|
|
@@ -445,6 +464,7 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
|
|
|
445
464
|
|
|
446
465
|
const common = {
|
|
447
466
|
parameters,
|
|
467
|
+
...(hostBound ? { hostBound } : {}),
|
|
448
468
|
...(repinned.length > 0 ? { repinned: repinned.filter((r) => written.has(r.record)) } : {}),
|
|
449
469
|
migrations: [],
|
|
450
470
|
files: fileEntries(written, declaredFilesAt(targetDir)),
|
|
@@ -487,7 +507,7 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
|
|
|
487
507
|
}
|
|
488
508
|
|
|
489
509
|
/** A local repository is recorded relative to the project, so the lock does not name this machine's paths. */
|
|
490
|
-
function portableUrl(url: string, targetDir: string): string {
|
|
510
|
+
export function portableUrl(url: string, targetDir: string): string {
|
|
491
511
|
if (!isAbsolute(url)) return url;
|
|
492
512
|
const rel = relative(targetDir, url).split(sep).join("/");
|
|
493
513
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
@@ -120,6 +120,49 @@ const ManualStepSchema = z
|
|
|
120
120
|
.strict();
|
|
121
121
|
export type ManualStep = z.infer<typeof ManualStepSchema>;
|
|
122
122
|
|
|
123
|
+
const CommitId = z.string().regex(/^[0-9a-f]{40,64}$/);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How a scope got its lineage from `chant workspace adopt-lineage` (#2551, D5,
|
|
127
|
+
* D9, requirement P7), rather than from `chant init`.
|
|
128
|
+
*
|
|
129
|
+
* - `by: "files"`: the scope had no lineage. Its files were matched against
|
|
130
|
+
* the template's versions, and the history before the lock is vouched for
|
|
131
|
+
* by an exact commit range, `commits.to` and every commit before it. The
|
|
132
|
+
* range is the one `.chant/trust.json` lists under `adopted`; once that
|
|
133
|
+
* entry is at the base revision, an admin has admitted it, and the scope
|
|
134
|
+
* reads as provenance `adopted` (D5). Until then it reads as `unattested`.
|
|
135
|
+
* - `by: "lineage"`: the scope had a directory lineage (#2647), and it was
|
|
136
|
+
* moved onto a git source whose files at the chosen ref reproduce every hash
|
|
137
|
+
* the lock recorded. The merge base is unchanged, so no history needs
|
|
138
|
+
* vouching for, and `previous` keeps the source it replaced.
|
|
139
|
+
*/
|
|
140
|
+
const AdoptionSchema = z
|
|
141
|
+
.object({
|
|
142
|
+
by: z.enum(["files", "lineage"]),
|
|
143
|
+
/** HEAD when the scope was adopted: the range `.chant/trust.json` admits ends here. */
|
|
144
|
+
commits: z.object({ to: CommitId }).strict(),
|
|
145
|
+
/** How the chosen version held up against the scope. */
|
|
146
|
+
match: z
|
|
147
|
+
.object({
|
|
148
|
+
/** Files the template has at that version. */
|
|
149
|
+
files: z.number().int().nonnegative(),
|
|
150
|
+
/** Of those, the ones the scope holds (or the lock recorded) byte for byte. */
|
|
151
|
+
identical: z.number().int().nonnegative(),
|
|
152
|
+
/** The ones it holds with other content: edited since. */
|
|
153
|
+
edited: z.number().int().nonnegative(),
|
|
154
|
+
/** The ones it does not hold. */
|
|
155
|
+
missing: z.number().int().nonnegative(),
|
|
156
|
+
})
|
|
157
|
+
.strict(),
|
|
158
|
+
/** Where the hash index came from: computed from the template, or a cached copy chant re-checked at the chosen version. */
|
|
159
|
+
index: z.enum(["computed", "cache"]),
|
|
160
|
+
/** For `by: "lineage"`: the template and source the lineage had before. */
|
|
161
|
+
previous: z.object({ template: z.string().min(1), source: SourceSchema }).strict().optional(),
|
|
162
|
+
})
|
|
163
|
+
.strict();
|
|
164
|
+
export type Adoption = z.infer<typeof AdoptionSchema>;
|
|
165
|
+
|
|
123
166
|
const LineageSchema = z
|
|
124
167
|
.object({
|
|
125
168
|
kind: z.enum(["template", "vendor"]),
|
|
@@ -133,6 +176,13 @@ const LineageSchema = z
|
|
|
133
176
|
address: AddressSchema.nullable(),
|
|
134
177
|
/** The parameter values the template was instantiated with. Always empty for vendor scopes. */
|
|
135
178
|
parameters: z.record(z.string(), z.unknown()),
|
|
179
|
+
/**
|
|
180
|
+
* The parameters the template marks `hostBound` (#2524 D9, #2552), each
|
|
181
|
+
* with the files, relative to the scope, whose template text carries its
|
|
182
|
+
* placeholder. `chant workspace export` switches their values for the
|
|
183
|
+
* export, and `import` switches them back. Absent when the template has none.
|
|
184
|
+
*/
|
|
185
|
+
hostBound: z.record(z.string(), z.array(z.string().min(1))).optional(),
|
|
136
186
|
/**
|
|
137
187
|
* Records whose evidence pins were re-pinned to the substituted content
|
|
138
188
|
* of a parameterised file (#2549): each record's path in the scope, and the
|
|
@@ -144,6 +194,8 @@ const LineageSchema = z
|
|
|
144
194
|
/** Per file, relative to the scope directory, in sorted order. */
|
|
145
195
|
files: z.record(z.string(), LockFileEntrySchema),
|
|
146
196
|
manualSteps: z.array(ManualStepSchema),
|
|
197
|
+
/** Present when the lineage was adopted rather than written at init (#2551). */
|
|
198
|
+
adoption: AdoptionSchema.optional(),
|
|
147
199
|
})
|
|
148
200
|
.strict();
|
|
149
201
|
export type Lineage = z.infer<typeof LineageSchema>;
|
|
@@ -255,10 +307,12 @@ function canonical(lock: LineageLock): LineageLock {
|
|
|
255
307
|
...(s.ref !== undefined ? { ref: s.ref } : {}),
|
|
256
308
|
address: s.address,
|
|
257
309
|
parameters: s.parameters,
|
|
310
|
+
...(s.hostBound !== undefined ? { hostBound: Object.fromEntries(Object.keys(s.hostBound).sort().map((k) => [k, [...s.hostBound![k]].sort()])) } : {}),
|
|
258
311
|
...(s.repinned !== undefined ? { repinned: s.repinned } : {}),
|
|
259
312
|
migrations: s.migrations,
|
|
260
313
|
files,
|
|
261
314
|
manualSteps: [...s.manualSteps].sort((a, b) => a.path.localeCompare(b.path)),
|
|
315
|
+
...(s.adoption !== undefined ? { adoption: s.adoption } : {}),
|
|
262
316
|
};
|
|
263
317
|
}
|
|
264
318
|
return { lockVersion: lock.lockVersion, scopes };
|
|
@@ -85,6 +85,33 @@ describe("planMigrations", () => {
|
|
|
85
85
|
// With nothing to run, no version is needed.
|
|
86
86
|
expect(planMigrations(lineage("main"), "main", []).chain).toEqual([]);
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
// #2551 — moving a scope to another template starts the chain with a bridge.
|
|
90
|
+
test("a switch starts with the bridge from the scope's template, then the new template's own migrations", () => {
|
|
91
|
+
const upstream = "github.com/acme/upstream";
|
|
92
|
+
const plan = planMigrations(lineage("v1.4.0"), "v3.0.0", [
|
|
93
|
+
migration("bridge-old", "<1.0.0", "1.0.0", "github.com/acme/starter"),
|
|
94
|
+
migration("bridge", ">=1.0.0 <2.0.0", "2.0.0", "github.com/acme/starter"),
|
|
95
|
+
migration("own-2", ">=1.0.0 <2.0.0", "2.0.0"),
|
|
96
|
+
migration("own-3", "2.x", "3.0.0"),
|
|
97
|
+
migration("other-bridge", "1.x", "2.0.0", "github.com/someone/else"),
|
|
98
|
+
], upstream);
|
|
99
|
+
// own-2 lands where the bridge already did, so it does not run; the fork's version 1.4.0 means nothing to upstream.
|
|
100
|
+
expect(plan.chain.map((m) => m.migration.id)).toEqual(["bridge", "own-3"]);
|
|
101
|
+
// No downgrade check across templates: the fork's v5 can bridge to upstream's v3.
|
|
102
|
+
expect(planMigrations(lineage("v5.0.0"), "v3.0.0", [migration("b", ">=5.0.0", "3.0.0", "github.com/acme/starter")], upstream).chain.map((m) => m.migration.id)).toEqual(["b"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a switch without a bridge is refused when the new template has migrations, and allowed when it has none", () => {
|
|
106
|
+
const upstream = "github.com/acme/upstream";
|
|
107
|
+
expect(() => planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("own", "1.x", "2.0.0")], upstream)).toThrow(/needs a bridge migration/);
|
|
108
|
+
expect(() => planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("b", ">=3.0.0", "2.0.0", "github.com/acme/starter"), migration("own", "1.x", "2.0.0")], upstream)).toThrow(
|
|
109
|
+
/needs a bridge migration.*found b: >=3\.0\.0/,
|
|
110
|
+
);
|
|
111
|
+
expect(planMigrations(lineage("v1.0.0"), "v2.0.0", [], upstream).chain).toEqual([]);
|
|
112
|
+
// The same id is no switch.
|
|
113
|
+
expect(planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("a", "1.x", "2.0.0")], "github.com/acme/starter").chain.map((m) => m.migration.id)).toEqual(["a"]);
|
|
114
|
+
});
|
|
88
115
|
});
|
|
89
116
|
|
|
90
117
|
describe("migration files", () => {
|
|
@@ -161,39 +161,69 @@ export interface MigrationPlan {
|
|
|
161
161
|
/**
|
|
162
162
|
* The migrations that take `lineage` from its version to `targetRef`, in order.
|
|
163
163
|
*
|
|
164
|
+
* With `switchTo`, the upgrade also moves the scope to another template, the
|
|
165
|
+
* one with that id (#2551). The chain then starts with a bridge migration: one
|
|
166
|
+
* the new template ships whose `from.template` names the scope's current
|
|
167
|
+
* template and whose `from.versions` accepts the scope's version. Its `to` is a
|
|
168
|
+
* version of the new template, and the new template's own migrations carry on
|
|
169
|
+
* from there. This is how a fork-born scope, adopted against the fork, comes
|
|
170
|
+
* forward onto the template the fork came from.
|
|
171
|
+
*
|
|
164
172
|
* Refused, with a {@link MigrationError}:
|
|
165
|
-
* - a target below the scope's version
|
|
173
|
+
* - a target below the scope's version (without a switch: versions of two
|
|
174
|
+
* templates do not compare);
|
|
166
175
|
* - a pending migration when either end of the upgrade is not a version, so
|
|
167
176
|
* no chain can be computed;
|
|
168
177
|
* - a gap: a migration in range whose `from` does not accept the version the
|
|
169
|
-
* scope has reached by then
|
|
178
|
+
* scope has reached by then;
|
|
179
|
+
* - a switch with no bridge from the scope's version, when the new template
|
|
180
|
+
* has migrations of its own, since the scope's place in its history is
|
|
181
|
+
* then unknown.
|
|
170
182
|
*/
|
|
171
|
-
export function planMigrations(lineage: Lineage, targetRef: string | undefined, available: LoadedMigration[]): MigrationPlan {
|
|
183
|
+
export function planMigrations(lineage: Lineage, targetRef: string | undefined, available: LoadedMigration[], switchTo?: string): MigrationPlan {
|
|
172
184
|
const from = parseVersion(lineage.ref);
|
|
173
185
|
const to = parseVersion(targetRef);
|
|
174
|
-
|
|
186
|
+
const applied = new Set(lineage.migrations);
|
|
187
|
+
const switching = switchTo !== undefined && switchTo !== lineage.template;
|
|
188
|
+
if (!switching && from && to && compareVersions(to, from) < 0) {
|
|
175
189
|
throw new MigrationError(`the target ${targetRef} (${formatVersion(to)}) is older than the scope's ${lineage.ref} (${formatVersion(from)}); an upgrade only goes forward`);
|
|
176
190
|
}
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
);
|
|
181
|
-
if (mine.length === 0) return { chain: [], from, to };
|
|
191
|
+
const target = switching ? switchTo : lineage.template;
|
|
192
|
+
const own = available.filter(({ migration: m }) => (m.from.template === undefined || m.from.template === target) && !applied.has(m.id));
|
|
193
|
+
const bridges = switching ? available.filter(({ migration: m }) => m.from.template === lineage.template && !applied.has(m.id)) : [];
|
|
194
|
+
if (own.length === 0 && bridges.length === 0) return { chain: [], from, to };
|
|
182
195
|
|
|
183
196
|
if (!from || !to) {
|
|
197
|
+
const pending = [...bridges, ...own];
|
|
184
198
|
const which = !from ? `the scope's ref ${lineage.ref ?? "(none)"}` : `the target ${targetRef ?? "(none)"}`;
|
|
185
199
|
throw new MigrationError(
|
|
186
|
-
`the template has ${
|
|
200
|
+
`the template has ${pending.length} migration(s) not yet applied (${pending.map((m) => m.migration.id).join(", ")}), but ${which} is not a version, so no chain can be planned. Pin tagged versions, such as v1.2.0.`,
|
|
187
201
|
);
|
|
188
202
|
}
|
|
189
203
|
|
|
190
|
-
|
|
204
|
+
let at = from;
|
|
205
|
+
const chain: LoadedMigration[] = [];
|
|
206
|
+
if (switching) {
|
|
207
|
+
// The bridge: the one that accepts the scope's version and lands furthest without passing the target.
|
|
208
|
+
const fits = bridges
|
|
209
|
+
.map((m) => ({ ...m, target: parseVersion(m.migration.to)! }))
|
|
210
|
+
.filter((m) => satisfies(from, m.migration.from.versions) && compareVersions(m.target, to) <= 0)
|
|
211
|
+
.sort((a, b) => compareVersions(b.target, a.target) || a.migration.id.localeCompare(b.migration.id));
|
|
212
|
+
if (fits.length === 0) {
|
|
213
|
+
if (own.length === 0) return { chain: [], from, to };
|
|
214
|
+
throw new MigrationError(
|
|
215
|
+
`moving scope from ${lineage.template} to ${switchTo} needs a bridge migration: one in ${switchTo} with from.template "${lineage.template}" whose from.versions accepts ${formatVersion(from)}${bridges.length > 0 ? ` (found ${bridges.map((m) => `${m.migration.id}: ${m.migration.from.versions}`).join(", ")})` : ""}. Without it the scope's place in ${switchTo}'s history is unknown, so the upgrade is refused.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
chain.push({ migration: fits[0].migration, file: fits[0].file });
|
|
219
|
+
at = fits[0].target;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const inRange = own
|
|
191
223
|
.map((m) => ({ ...m, target: parseVersion(m.migration.to)! }))
|
|
192
|
-
.filter((m) => compareVersions(m.target,
|
|
224
|
+
.filter((m) => compareVersions(m.target, at) > 0 && compareVersions(m.target, to) <= 0)
|
|
193
225
|
.sort((a, b) => compareVersions(a.target, b.target) || a.migration.id.localeCompare(b.migration.id));
|
|
194
226
|
|
|
195
|
-
const chain: LoadedMigration[] = [];
|
|
196
|
-
let at = from;
|
|
197
227
|
let i = 0;
|
|
198
228
|
while (i < inRange.length) {
|
|
199
229
|
const step = inRange[i].target;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provenance of an adopted lineage (#2551, D5, requirement P7).
|
|
3
|
+
*
|
|
4
|
+
* `chant workspace adopt-lineage` records the commit it adopted at, and adds
|
|
5
|
+
* the range ending there to `.chant/trust.json`. That file is policy, always
|
|
6
|
+
* read at the base revision (./trust/policy.ts), so an adoption counts only
|
|
7
|
+
* once its range is in the policy at base, which takes a protected write by
|
|
8
|
+
* an admin. Until then the scope reads as `unattested`, like any lineage
|
|
9
|
+
* chant wrote itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import type { Lineage } from "./lineage-lock";
|
|
14
|
+
import { isAdopted, policyAtBase, resolveBase } from "./trust/provenance";
|
|
15
|
+
|
|
16
|
+
export interface LineageProvenance {
|
|
17
|
+
level: "adopted" | "unattested";
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Null for a lineage that makes no adoption claim: one chant wrote at init, vendor or upgrade, or a directory lineage moved onto git. */
|
|
22
|
+
export function lineageProvenance(root: string, lineage: Lineage): LineageProvenance | null {
|
|
23
|
+
const adoption = lineage.adoption;
|
|
24
|
+
if (!adoption || adoption.by !== "files") return null;
|
|
25
|
+
const to = adoption.commits.to;
|
|
26
|
+
let repo: string;
|
|
27
|
+
try {
|
|
28
|
+
repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return { level: "unattested", reason: "not in a git repository, so the adopted range cannot be checked" };
|
|
31
|
+
}
|
|
32
|
+
const base = resolveBase(repo);
|
|
33
|
+
if (!base.commit) return { level: "unattested", reason: base.problem ?? "no base revision" };
|
|
34
|
+
const policy = policyAtBase(repo, base);
|
|
35
|
+
if (isAdopted(repo, policy, to)) return { level: "adopted", reason: `the policy at ${base.commit.slice(0, 12)} adopts the range up to ${to.slice(0, 12)}` };
|
|
36
|
+
return {
|
|
37
|
+
level: "unattested",
|
|
38
|
+
reason: `the range up to ${to.slice(0, 12)} is not in .chant/trust.json at ${base.commit.slice(0, 12)} (${base.from}); it counts once that change is merged`,
|
|
39
|
+
};
|
|
40
|
+
}
|