@intentius/chant 0.1.17 → 0.1.19
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/package.json +1 -1
- package/src/build.test.ts +48 -1
- package/src/build.ts +113 -2
- package/src/cli/commands/build.ts +33 -3
- package/src/cli/commands/vendor.test.ts +148 -0
- package/src/cli/commands/vendor.ts +263 -0
- package/src/cli/handlers/build.ts +1 -0
- package/src/cli/handlers/graph.ts +55 -2
- package/src/cli/handlers/lifecycle.ts +57 -0
- package/src/cli/handlers/vendor.ts +61 -0
- package/src/cli/main.ts +19 -2
- package/src/cli/registry.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lifecycle/affected.test.ts +161 -0
- package/src/lifecycle/affected.ts +202 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lint/config.ts +9 -0
- package/src/lint/policy.ts +81 -0
- package/src/lint/post-synth.test.ts +30 -0
- package/src/lint/post-synth.ts +24 -2
- package/src/op/builders.ts +14 -0
- package/src/op/index.ts +1 -1
- package/src/op/op.test.ts +15 -1
- package/src/op/types.ts +1 -1
package/package.json
CHANGED
package/src/build.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs } from "./build";
|
|
2
|
+
import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs, computeStackGraph } from "./build";
|
|
3
3
|
import { output } from "./lexicon-output";
|
|
4
4
|
import { AttrRef } from "./attrref";
|
|
5
5
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
@@ -462,3 +462,50 @@ describe("detectCrossLexiconRefs", () => {
|
|
|
462
462
|
expect(detected[0].outputName).toBe("dataBucket_Endpoint");
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
+
|
|
466
|
+
describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
|
|
467
|
+
const ent = (lexicon: string, props: Record<string, unknown> = {}): Declarable =>
|
|
468
|
+
({ lexicon, entityType: `${lexicon}::X`, [DECLARABLE_MARKER]: true, props }) as unknown as Declarable;
|
|
469
|
+
|
|
470
|
+
test("infers a consumer→producer edge from a cross-lexicon AttrRef", () => {
|
|
471
|
+
const vpc = ent("aws");
|
|
472
|
+
const svc = ent("k8s", { vpcId: new AttrRef(vpc, "id") });
|
|
473
|
+
const g = computeStackGraph(new Map([["vpc", vpc], ["svc", svc]]), ["aws", "k8s"]);
|
|
474
|
+
|
|
475
|
+
expect(g.edges).toEqual([{ from: "k8s", to: "aws" }]);
|
|
476
|
+
expect(g.order).toEqual(["aws", "k8s"]); // producer before consumer
|
|
477
|
+
expect(g.waves).toEqual([["aws"], ["k8s"]]); // separate waves — ordered
|
|
478
|
+
expect(g.cycles).toEqual([]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("independent stacks share a wave (parallel-safe)", () => {
|
|
482
|
+
const g = computeStackGraph(new Map([["a", ent("aws")], ["b", ent("gcp")]]), ["aws", "gcp"]);
|
|
483
|
+
expect(g.edges).toEqual([]);
|
|
484
|
+
expect(g.waves).toEqual([["aws", "gcp"]]); // one wave — no inter-dependency
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("reports a dependency cycle", () => {
|
|
488
|
+
const a = ent("x");
|
|
489
|
+
const b = ent("y", { ref: new AttrRef(a, "out") });
|
|
490
|
+
// close the loop: a references b
|
|
491
|
+
(a as unknown as { props: Record<string, unknown> }).props = { ref: new AttrRef(b, "out") };
|
|
492
|
+
const g = computeStackGraph(new Map([["a", a], ["b", b]]), ["x", "y"]);
|
|
493
|
+
|
|
494
|
+
expect(g.edges).toEqual(expect.arrayContaining([{ from: "x", to: "y" }, { from: "y", to: "x" }]));
|
|
495
|
+
expect(g.cycles).toEqual([["x", "y"]]);
|
|
496
|
+
expect(g.order).toEqual([]); // nothing is orderable inside a cycle
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("a diamond resolves into three waves", () => {
|
|
500
|
+
// base ← (left, right) ← top
|
|
501
|
+
const base = ent("base");
|
|
502
|
+
const left = ent("left", { b: new AttrRef(base, "id") });
|
|
503
|
+
const right = ent("right", { b: new AttrRef(base, "id") });
|
|
504
|
+
const top = ent("top", { l: new AttrRef(left, "id"), r: new AttrRef(right, "id") });
|
|
505
|
+
const g = computeStackGraph(
|
|
506
|
+
new Map([["base", base], ["left", left], ["right", right], ["top", top]]),
|
|
507
|
+
["base", "left", "right", "top"],
|
|
508
|
+
);
|
|
509
|
+
expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
|
|
510
|
+
});
|
|
511
|
+
});
|
package/src/build.ts
CHANGED
|
@@ -20,6 +20,115 @@ export interface BuildManifest {
|
|
|
20
20
|
{ source: string; entity: string; attribute: string }
|
|
21
21
|
>;
|
|
22
22
|
deployOrder: string[];
|
|
23
|
+
/** Cross-stack apply-ordering graph (see {@link computeStackGraph}). */
|
|
24
|
+
stackGraph: StackGraph;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The cross-stack (cross-lexicon) apply-ordering graph chant already computes
|
|
29
|
+
* while resolving cross-lexicon references — surfaced as tool-agnostic data for
|
|
30
|
+
* an orchestrator to consume. chant exposes the order; it does not drive the
|
|
31
|
+
* apply.
|
|
32
|
+
*/
|
|
33
|
+
export interface StackGraph {
|
|
34
|
+
/** Stacks (lexicon partitions) in the build. */
|
|
35
|
+
nodes: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Consumer→producer edges: `from` imports a value `to` exports, so `to` must
|
|
38
|
+
* apply before `from`. Inferred from cross-lexicon references.
|
|
39
|
+
*/
|
|
40
|
+
edges: Array<{ from: string; to: string }>;
|
|
41
|
+
/** A flat applicable sequence — every producer before its consumers. */
|
|
42
|
+
order: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Levels: stacks in the same wave have no inter-dependency and may apply
|
|
45
|
+
* concurrently. `order` flattened with parallelism made explicit.
|
|
46
|
+
*/
|
|
47
|
+
waves: string[][];
|
|
48
|
+
/** Dependency cycles, if any (each a list of stacks). Normally empty. */
|
|
49
|
+
cycles: string[][];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the cross-stack apply-ordering graph from resolved entities. Edges are
|
|
54
|
+
* inferred from cross-lexicon attribute references (a resource in lexicon A
|
|
55
|
+
* referencing an attribute of a resource in lexicon B ⇒ A depends on B). Returns
|
|
56
|
+
* the edge set plus a topological order, parallel-safe waves, and any cycles.
|
|
57
|
+
*/
|
|
58
|
+
export function computeStackGraph(
|
|
59
|
+
entities: Map<string, Declarable>,
|
|
60
|
+
lexiconNames: string[],
|
|
61
|
+
): StackGraph {
|
|
62
|
+
const edges: Array<{ from: string; to: string }> = [];
|
|
63
|
+
const edgeSet = new Set<string>();
|
|
64
|
+
const addEdge = (from: string, to: string): void => {
|
|
65
|
+
if (from === to) return;
|
|
66
|
+
const key = `${from}\0${to}`;
|
|
67
|
+
if (edgeSet.has(key)) return;
|
|
68
|
+
edgeSet.add(key);
|
|
69
|
+
edges.push({ from, to });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const walk = (value: unknown, consumer: string, visited: Set<unknown>): void => {
|
|
73
|
+
if (value === null || value === undefined || typeof value !== "object") return;
|
|
74
|
+
if (visited.has(value)) return;
|
|
75
|
+
visited.add(value);
|
|
76
|
+
if (value instanceof AttrRef) {
|
|
77
|
+
const parent = value.parent.deref();
|
|
78
|
+
const producer = parent ? (parent as Record<string, unknown>).lexicon : undefined;
|
|
79
|
+
if (typeof producer === "string" && producer !== consumer) addEdge(consumer, producer);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (isLexiconOutput(value)) return;
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
for (const item of value) walk(item, consumer, visited);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const val of Object.values(value as Record<string, unknown>)) walk(val, consumer, visited);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const [, entity] of entities) {
|
|
91
|
+
const consumer = entity.lexicon;
|
|
92
|
+
const visited = new Set<unknown>();
|
|
93
|
+
for (const val of Object.values(entity as unknown as Record<string, unknown>)) {
|
|
94
|
+
walk(val, consumer, visited);
|
|
95
|
+
}
|
|
96
|
+
if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
|
|
97
|
+
walk(entity.props, consumer, visited);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Dependency map: node → producers it depends on.
|
|
102
|
+
const nodes = lexiconNames.length
|
|
103
|
+
? [...lexiconNames]
|
|
104
|
+
: [...new Set([...entities.values()].map((e) => e.lexicon))];
|
|
105
|
+
const deps = new Map<string, Set<string>>();
|
|
106
|
+
for (const n of nodes) deps.set(n, new Set());
|
|
107
|
+
for (const { from, to } of edges) {
|
|
108
|
+
if (!deps.has(from)) deps.set(from, new Set());
|
|
109
|
+
if (!deps.has(to)) deps.set(to, new Set());
|
|
110
|
+
deps.get(from)!.add(to);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Kahn layering: a node is ready once every producer it depends on is placed.
|
|
114
|
+
const remaining = new Set(deps.keys());
|
|
115
|
+
const waves: string[][] = [];
|
|
116
|
+
const order: string[] = [];
|
|
117
|
+
while (remaining.size > 0) {
|
|
118
|
+
const wave = [...remaining]
|
|
119
|
+
.filter((n) => [...deps.get(n)!].every((d) => !remaining.has(d)))
|
|
120
|
+
.sort();
|
|
121
|
+
if (wave.length === 0) break; // remaining nodes form a cycle
|
|
122
|
+
for (const n of wave) {
|
|
123
|
+
remaining.delete(n);
|
|
124
|
+
order.push(n);
|
|
125
|
+
}
|
|
126
|
+
waves.push(wave);
|
|
127
|
+
}
|
|
128
|
+
const cycles: string[][] = remaining.size > 0 ? [[...remaining].sort()] : [];
|
|
129
|
+
|
|
130
|
+
edges.sort((a, b) => `${a.from}\0${a.to}`.localeCompare(`${b.from}\0${b.to}`));
|
|
131
|
+
return { nodes: [...nodes].sort(), edges, order, waves, cycles };
|
|
23
132
|
}
|
|
24
133
|
|
|
25
134
|
/**
|
|
@@ -288,7 +397,8 @@ function computeDeployOrder(
|
|
|
288
397
|
*/
|
|
289
398
|
function generateManifest(
|
|
290
399
|
lexiconNames: string[],
|
|
291
|
-
lexiconOutputs: LexiconOutput[]
|
|
400
|
+
lexiconOutputs: LexiconOutput[],
|
|
401
|
+
entities: Map<string, Declarable>
|
|
292
402
|
): BuildManifest {
|
|
293
403
|
const outputsRecord: Record<
|
|
294
404
|
string,
|
|
@@ -307,6 +417,7 @@ function generateManifest(
|
|
|
307
417
|
lexicons: lexiconNames,
|
|
308
418
|
outputs: outputsRecord,
|
|
309
419
|
deployOrder: computeDeployOrder(lexiconNames, lexiconOutputs),
|
|
420
|
+
stackGraph: computeStackGraph(entities, lexiconNames),
|
|
310
421
|
};
|
|
311
422
|
}
|
|
312
423
|
|
|
@@ -453,7 +564,7 @@ export async function build(
|
|
|
453
564
|
|
|
454
565
|
// Step 8: Generate manifest
|
|
455
566
|
const lexiconNames = Array.from(partitions.keys());
|
|
456
|
-
const manifest = generateManifest(lexiconNames, lexiconOutputs);
|
|
567
|
+
const manifest = generateManifest(lexiconNames, lexiconOutputs, discoveryResult.entities);
|
|
457
568
|
|
|
458
569
|
return {
|
|
459
570
|
outputs,
|
|
@@ -3,7 +3,7 @@ import { loadChantConfig, resolveOwnershipMarker } from "../../config";
|
|
|
3
3
|
import type { Serializer, SerializerResult } from "../../serializer";
|
|
4
4
|
import type { LexiconPlugin } from "../../lexicon";
|
|
5
5
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
6
|
-
import
|
|
6
|
+
import { loadPolicyChecks } from "../../lint/policy";
|
|
7
7
|
import { sortedJsonReplacer } from "../../utils";
|
|
8
8
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
9
9
|
import { writeFileSync, mkdirSync } from "fs";
|
|
@@ -26,8 +26,15 @@ export interface BuildOptions {
|
|
|
26
26
|
plugins?: LexiconPlugin[];
|
|
27
27
|
/** Print summary to stderr */
|
|
28
28
|
verbose?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Environment/stack to evaluate policy against (`--env`). Falls back to the
|
|
31
|
+
* project's `ownership.env`. Passed into post-synth checks so organizational
|
|
32
|
+
* policy can branch on environment.
|
|
33
|
+
*/
|
|
34
|
+
env?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* Build command result
|
|
33
40
|
*/
|
|
@@ -56,11 +63,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
56
63
|
|
|
57
64
|
// Resolve opt-in ownership marking from project config (search the infra dir
|
|
58
65
|
// and its parent, where chant.config.* usually lives).
|
|
59
|
-
const
|
|
66
|
+
const loaded = await loadChantConfig(infraPath).then((r) =>
|
|
60
67
|
r.configPath ? r : loadChantConfig(dirname(infraPath)),
|
|
61
68
|
);
|
|
69
|
+
const config = loaded.config;
|
|
62
70
|
const ownership = resolveOwnershipMarker(config);
|
|
63
71
|
|
|
72
|
+
// Environment for policy evaluation: explicit --env wins, else ownership.env.
|
|
73
|
+
const env = options.env ?? config.ownership?.env;
|
|
74
|
+
// Project-authored organizational policy checks (lint.policies), run over the
|
|
75
|
+
// resolved resources during build. Resolve paths relative to the config dir.
|
|
76
|
+
const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
77
|
+
const policyChecks = config.lint?.policies?.length
|
|
78
|
+
? await loadPolicyChecks(config.lint.policies, configDir)
|
|
79
|
+
: [];
|
|
80
|
+
|
|
64
81
|
// Run the build
|
|
65
82
|
const result = await build(infraPath, options.serializers, undefined, { ownership });
|
|
66
83
|
|
|
@@ -96,7 +113,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
const scopedResult = { ...result, outputs: scopedOutputs };
|
|
99
|
-
const postDiags = runPostSynthChecks(checks, scopedResult);
|
|
116
|
+
const postDiags = runPostSynthChecks(checks, scopedResult, env);
|
|
100
117
|
for (const diag of postDiags) {
|
|
101
118
|
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
102
119
|
const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
|
|
@@ -107,6 +124,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
126
|
}
|
|
127
|
+
|
|
128
|
+
// Project-authored organizational policy — cross-cutting, so it sees every
|
|
129
|
+
// lexicon's output at once (not scoped per-plugin), with the current env.
|
|
130
|
+
if (policyChecks.length > 0) {
|
|
131
|
+
const policyDiags = runPostSynthChecks(policyChecks, result, env);
|
|
132
|
+
for (const diag of policyDiags) {
|
|
133
|
+
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
134
|
+
const where = diag.lexicon ? ` (${diag.lexicon})` : "";
|
|
135
|
+
const msg = `[policy:${diag.checkId}] ${prefix}${diag.message}${where}`;
|
|
136
|
+
if (diag.severity === "error") errors.push(formatError({ message: msg }));
|
|
137
|
+
else warnings.push(formatWarning({ message: msg }));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
// Empty-output guard: source files were discovered but no lexicon produced
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
vendorPull,
|
|
7
|
+
vendorCheck,
|
|
8
|
+
contentHash,
|
|
9
|
+
scopeArchiveFiles,
|
|
10
|
+
loadManifest,
|
|
11
|
+
MANIFEST_FILE,
|
|
12
|
+
} from "./vendor";
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
|
|
16
|
+
function write(rel: string, content: string): void {
|
|
17
|
+
const full = join(root, rel);
|
|
18
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
19
|
+
writeFileSync(full, content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
root = mkdtempSync(join(tmpdir(), "chant-vendor-"));
|
|
24
|
+
// A local "shared pattern" source.
|
|
25
|
+
write("shared/web-app/index.ts", "export const WebApp = () => ({});\n");
|
|
26
|
+
write("shared/web-app/README.md", "# pattern\n");
|
|
27
|
+
// A project that vendors it.
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(root, MANIFEST_FILE),
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
vendored: [
|
|
32
|
+
{ name: "web-app", source: { type: "local", path: "shared/web-app" }, target: "vendor/web-app", ref: "v1" },
|
|
33
|
+
],
|
|
34
|
+
}, null, 2),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
rmSync(root, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("vendor pull", () => {
|
|
43
|
+
test("copies the source into target and records a checksum", async () => {
|
|
44
|
+
const result = await vendorPull(root);
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
expect(result.pulled[0].fileCount).toBe(2);
|
|
47
|
+
|
|
48
|
+
expect(existsSync(join(root, "vendor/web-app/index.ts"))).toBe(true);
|
|
49
|
+
expect(readFileSync(join(root, "vendor/web-app/README.md"), "utf-8")).toContain("# pattern");
|
|
50
|
+
|
|
51
|
+
// Checksum written back into the manifest.
|
|
52
|
+
const { manifest } = loadManifest(root);
|
|
53
|
+
expect(manifest.vendored[0].checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("pull <name> filters to one entry; unknown name fails", async () => {
|
|
57
|
+
expect((await vendorPull(root, "web-app")).pulled).toHaveLength(1);
|
|
58
|
+
const miss = await vendorPull(root, "nope");
|
|
59
|
+
expect(miss.success).toBe(false);
|
|
60
|
+
expect(miss.output).toContain("nope");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("fails clearly when the local source is missing", async () => {
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(root, MANIFEST_FILE),
|
|
66
|
+
JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "missing" }, target: "vendor/x" }] }),
|
|
67
|
+
);
|
|
68
|
+
await expect(vendorPull(root)).rejects.toThrow(/local source not found/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("vendor check", () => {
|
|
73
|
+
test("reports ok right after a pull", async () => {
|
|
74
|
+
await vendorPull(root);
|
|
75
|
+
const result = vendorCheck(root);
|
|
76
|
+
expect(result.drift).toBe(false);
|
|
77
|
+
expect(result.entries[0].status).toBe("ok");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("detects drift when a vendored file is edited", async () => {
|
|
81
|
+
await vendorPull(root);
|
|
82
|
+
writeFileSync(join(root, "vendor/web-app/index.ts"), "// locally edited\n");
|
|
83
|
+
const result = vendorCheck(root);
|
|
84
|
+
expect(result.drift).toBe(true);
|
|
85
|
+
expect(result.entries[0].status).toBe("drifted");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("flags a target deleted after pinning as missing", async () => {
|
|
89
|
+
await vendorPull(root);
|
|
90
|
+
rmSync(join(root, "vendor/web-app"), { recursive: true });
|
|
91
|
+
const result = vendorCheck(root);
|
|
92
|
+
expect(result.entries[0].status).toBe("missing");
|
|
93
|
+
expect(result.drift).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an entry with no checksum is unpinned, not drift", async () => {
|
|
97
|
+
// Manifest never pulled → no checksum recorded.
|
|
98
|
+
const result = vendorCheck(root);
|
|
99
|
+
expect(result.entries[0].status).toBe("unpinned");
|
|
100
|
+
expect(result.drift).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("manifest validation", () => {
|
|
105
|
+
test("rejects an invalid manifest", () => {
|
|
106
|
+
writeFileSync(join(root, MANIFEST_FILE), JSON.stringify({ vendored: [{ name: "x" }] }));
|
|
107
|
+
expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("rejects a floating updatePolicy (pins only)", () => {
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(root, MANIFEST_FILE),
|
|
113
|
+
JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "shared/web-app" }, target: "v/x", updatePolicy: "latest" }] }),
|
|
114
|
+
);
|
|
115
|
+
expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("contentHash", () => {
|
|
120
|
+
test("is order-independent and content-sensitive", () => {
|
|
121
|
+
const a = new Map([["a", Buffer.from("1")], ["b", Buffer.from("2")]]);
|
|
122
|
+
const b = new Map([["b", Buffer.from("2")], ["a", Buffer.from("1")]]);
|
|
123
|
+
expect(contentHash(a)).toBe(contentHash(b)); // order doesn't matter
|
|
124
|
+
const c = new Map([["a", Buffer.from("1")], ["b", Buffer.from("CHANGED")]]);
|
|
125
|
+
expect(contentHash(a)).not.toBe(contentHash(c));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("scopeArchiveFiles", () => {
|
|
130
|
+
test("strips the top-level wrapper dir and scopes to a subpath", () => {
|
|
131
|
+
const raw = new Map([
|
|
132
|
+
["repo-main/composites/web-app/index.ts", Buffer.from("a")],
|
|
133
|
+
["repo-main/composites/web-app/util.ts", Buffer.from("b")],
|
|
134
|
+
["repo-main/ops/deploy.op.ts", Buffer.from("c")],
|
|
135
|
+
["repo-main/README.md", Buffer.from("d")],
|
|
136
|
+
]);
|
|
137
|
+
const scoped = scopeArchiveFiles(raw, "composites/web-app");
|
|
138
|
+
expect([...scoped.keys()].sort()).toEqual(["index.ts", "util.ts"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("no subpath keeps everything below the wrapper dir", () => {
|
|
142
|
+
const raw = new Map([
|
|
143
|
+
["repo-main/a.ts", Buffer.from("a")],
|
|
144
|
+
["repo-main/sub/b.ts", Buffer.from("b")],
|
|
145
|
+
]);
|
|
146
|
+
expect([...scopeArchiveFiles(raw).keys()].sort()).toEqual(["a.ts", "sub/b.ts"]);
|
|
147
|
+
});
|
|
148
|
+
});
|