@intentius/chant 0.38.0 → 0.41.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/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
package/src/discovery/index.ts
CHANGED
|
@@ -11,6 +11,64 @@ import { getProvenance } from "../provenance";
|
|
|
11
11
|
import type { BuildParamProvenance } from "../provenance";
|
|
12
12
|
import { buildParamValues } from "../build-params";
|
|
13
13
|
import { setBuildParams } from "../params";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, parse } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Warn when resolved build parameters cannot reach project source (#1421).
|
|
19
|
+
*
|
|
20
|
+
* chant's core is ESM. When the project is CommonJS — `"type": "commonjs"`, or
|
|
21
|
+
* no `type` field — tsx loads project source through the CommonJS transform, so
|
|
22
|
+
* the project's `require` of `params.ts` and core's `import` of it produce two
|
|
23
|
+
* separate module records. {@link setBuildParams} mutates one object in place;
|
|
24
|
+
* project source reads the other, and sees `{}`.
|
|
25
|
+
*
|
|
26
|
+
* The failure is silence. chant prints `[param] tier = "prod" (cli)` and then
|
|
27
|
+
* emits the graph for the default branch. `chant graph` is always affected
|
|
28
|
+
* because it always takes the run path; `chant build --no-fold` likewise. Plain
|
|
29
|
+
* `chant build` usually escapes because folding substitutes parameters
|
|
30
|
+
* statically — but a file that falls back to run inside a folded build is wrong
|
|
31
|
+
* the same way, which is why this warns regardless of `fold`.
|
|
32
|
+
*
|
|
33
|
+
* Only fires when parameters were actually resolved, so a CJS project that uses
|
|
34
|
+
* none is never nagged. `chant doctor`'s `package-type-module` check is the
|
|
35
|
+
* ambient version of the same advice.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort and never throws: a project whose `package.json` cannot be found
|
|
38
|
+
* or parsed gets no warning rather than a failed build.
|
|
39
|
+
*/
|
|
40
|
+
function warnIfParamsCannotReachProject(path: string, values: Record<string, unknown>): void {
|
|
41
|
+
if (Object.keys(values).length === 0) return;
|
|
42
|
+
try {
|
|
43
|
+
const pkgPath = findPackageJsonUpward(path);
|
|
44
|
+
if (!pkgPath) return;
|
|
45
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { type?: string };
|
|
46
|
+
if (pkg.type === "module") return;
|
|
47
|
+
const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field";
|
|
48
|
+
const names = Object.keys(values).sort().join(", ");
|
|
49
|
+
console.error(
|
|
50
|
+
`warning: ${pkgPath} has ${found}, but chant is ESM — build parameters (${names}) ` +
|
|
51
|
+
`will read as empty in project source on the run path, so declarations conditioned ` +
|
|
52
|
+
`on them take their default branch. Set "type": "module". (chant #1421)`,
|
|
53
|
+
);
|
|
54
|
+
} catch {
|
|
55
|
+
// Unreadable or unparseable package.json — say nothing rather than fail.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Nearest `package.json` at or above `startDir`, or undefined. */
|
|
60
|
+
function findPackageJsonUpward(startDir: string): string | undefined {
|
|
61
|
+
let dir = startDir;
|
|
62
|
+
const { root } = parse(dir);
|
|
63
|
+
for (;;) {
|
|
64
|
+
const candidate = join(dir, "package.json");
|
|
65
|
+
if (existsSync(candidate)) return candidate;
|
|
66
|
+
if (dir === root) return undefined;
|
|
67
|
+
const parent = dirname(dir);
|
|
68
|
+
if (parent === dir) return undefined;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
14
72
|
|
|
15
73
|
/**
|
|
16
74
|
* Per-file fold-vs-run outcome (chant #1022, epic #1019), populated only
|
|
@@ -150,6 +208,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
|
|
|
150
208
|
// `--watch`) never leaks into a build that supplied none.
|
|
151
209
|
const buildParamValuesMap = buildParamValues(options?.buildParams ?? []);
|
|
152
210
|
setBuildParams(buildParamValuesMap);
|
|
211
|
+
warnIfParamsCannotReachProject(path, buildParamValuesMap);
|
|
153
212
|
|
|
154
213
|
// Step 1: Scan for TypeScript files
|
|
155
214
|
const files = await findInfraFiles(path);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discover } from "./index";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #1421 — chant's core is ESM. A CommonJS project's `require` of `params.ts`
|
|
9
|
+
* and core's `import` of it are two module records, so `setBuildParams`'s
|
|
10
|
+
* in-place mutation never reaches project source: it reads `{}` and every
|
|
11
|
+
* declaration conditioned on a parameter takes its default branch, silently.
|
|
12
|
+
*
|
|
13
|
+
* The fix is to stop it being silent. These assert the warning fires exactly
|
|
14
|
+
* when the hazard exists and stays quiet otherwise.
|
|
15
|
+
*/
|
|
16
|
+
describe("build params that cannot reach a CommonJS project (#1421)", () => {
|
|
17
|
+
const dirs: string[] = [];
|
|
18
|
+
const project = (type: string | undefined): string => {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-1421-"));
|
|
20
|
+
dirs.push(dir);
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(dir, "package.json"),
|
|
23
|
+
JSON.stringify(type === undefined ? { name: "p" } : { name: "p", type }),
|
|
24
|
+
);
|
|
25
|
+
mkdirSync(join(dir, "src"));
|
|
26
|
+
writeFileSync(join(dir, "src", "main.ts"), "export const x = 1;\n");
|
|
27
|
+
return dir;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const warnings = async (dir: string, params: Array<{ name: string; value: unknown }>): Promise<string[]> => {
|
|
36
|
+
const seen: string[] = [];
|
|
37
|
+
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void seen.push(a.join(" ")));
|
|
38
|
+
await discover(join(dir, "src"), {
|
|
39
|
+
buildParams: params as never,
|
|
40
|
+
});
|
|
41
|
+
return seen.filter((s) => s.includes("#1421"));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test('warns for "type": "commonjs" when parameters were resolved', async () => {
|
|
45
|
+
const out = await warnings(project("commonjs"), [{ name: "tier", value: "prod" }]);
|
|
46
|
+
expect(out).toHaveLength(1);
|
|
47
|
+
expect(out[0]).toMatch(/"type": "commonjs"/);
|
|
48
|
+
expect(out[0]).toMatch(/tier/);
|
|
49
|
+
expect(out[0]).toMatch(/Set "type": "module"/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The sneakier half: no `type` field at all is also CommonJS.
|
|
53
|
+
test("warns when package.json declares no type at all", async () => {
|
|
54
|
+
const out = await warnings(project(undefined), [{ name: "tier", value: "prod" }]);
|
|
55
|
+
expect(out).toHaveLength(1);
|
|
56
|
+
expect(out[0]).toMatch(/no `type` field/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('stays quiet for "type": "module"', async () => {
|
|
60
|
+
expect(await warnings(project("module"), [{ name: "tier", value: "prod" }])).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A CJS project using no parameters is not at risk, and must not be nagged.
|
|
64
|
+
test("stays quiet when no parameters were resolved", async () => {
|
|
65
|
+
expect(await warnings(project("commonjs"), [])).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("names every resolved parameter, sorted", async () => {
|
|
69
|
+
const out = await warnings(project("commonjs"), [
|
|
70
|
+
{ name: "zone", value: "b" },
|
|
71
|
+
{ name: "tier", value: "prod" },
|
|
72
|
+
]);
|
|
73
|
+
expect(out[0]).toMatch(/\(tier, zone\)/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
2
|
+
import { generateDriverSource } from "./driver";
|
|
3
|
+
import { setBuildParams } from "../../params";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* chant #1108 — the generated child driver must re-bind the parent's resolved
|
|
7
|
+
* build-time parameters BEFORE importing any project file: the child process
|
|
8
|
+
* starts with its own empty copy of ../../params.ts's `params` object, so
|
|
9
|
+
* without this a run-fallback file reading `params.<name>` saw `undefined`
|
|
10
|
+
* under `--sandbox` while the fold path (substituting in the parent) saw the
|
|
11
|
+
* resolved value.
|
|
12
|
+
*/
|
|
13
|
+
describe("generateDriverSource — build-time parameters (#1108)", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
setBuildParams({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("embeds a snapshot of the parent's current params, bound before any project import", () => {
|
|
19
|
+
setBuildParams({ tier: "production-ha", replicas: 3, ha: true });
|
|
20
|
+
const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
|
|
21
|
+
|
|
22
|
+
expect(source).toContain("import { setBuildParams } from ");
|
|
23
|
+
const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ tier: "production-ha", replicas: 3, ha: true })});`);
|
|
24
|
+
const firstImportAt = source.indexOf(`await import("/tmp/project/a.ts")`);
|
|
25
|
+
expect(bindAt).toBeGreaterThan(-1);
|
|
26
|
+
expect(firstImportAt).toBeGreaterThan(-1);
|
|
27
|
+
expect(bindAt).toBeLessThan(firstImportAt);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("with no params resolved, binds an empty snapshot (explicit, not absent)", () => {
|
|
31
|
+
const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
|
|
32
|
+
expect(source).toContain("setBuildParams({});");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
|
+
import { params as currentBuildParams } from "../../params";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* chant #1045 Phase 2 — generates the source of the "driver" module that runs
|
|
@@ -46,6 +47,16 @@ const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
|
|
|
46
47
|
// chant #1131 — the policy driver's build-result decoding + diagnostics contract.
|
|
47
48
|
const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
|
|
48
49
|
const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
|
|
50
|
+
// chant #1108 — the shared build-time params module (../params.ts). The child
|
|
51
|
+
// process starts with its own, EMPTY copy of that module's `params` object;
|
|
52
|
+
// without re-binding, every run-fallback file imported in the child would see
|
|
53
|
+
// `params.* === undefined` while the fold path (which substitutes values in
|
|
54
|
+
// the PARENT) sees the resolved values — the exact split-brain #1064 exists
|
|
55
|
+
// to prevent. esbuild resolves a project file's own
|
|
56
|
+
// `import { params } from "@intentius/chant/params"` to this same absolute
|
|
57
|
+
// file, so the driver's `setBuildParams` call below mutates the one object
|
|
58
|
+
// every bundled import observes.
|
|
59
|
+
const PARAMS_MODULE = join(dirname(DISCOVERY_DIR), "params.ts");
|
|
49
60
|
|
|
50
61
|
export interface GenerateDriverOptions {
|
|
51
62
|
/** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
|
|
@@ -73,9 +84,17 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
73
84
|
`import { encodeEntitySet } from ${lit(ENTITY_WIRE_CODEC_MODULE)};`,
|
|
74
85
|
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
75
86
|
`import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
|
|
87
|
+
`import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
|
|
76
88
|
``,
|
|
77
89
|
`const BUILD_ROOT = ${lit(buildRoot)};`,
|
|
78
90
|
``,
|
|
91
|
+
// chant #1108 — a snapshot of the PARENT's resolved build-time parameter
|
|
92
|
+
// values, embedded as a literal at generation time (the parent bound them
|
|
93
|
+
// via applyBuildParams/discover() before this source was generated).
|
|
94
|
+
// Values are declared-scalar only (BuildParamValue), so JSON round-trips
|
|
95
|
+
// them exactly. Bound before any project import below.
|
|
96
|
+
`setBuildParams(${lit({ ...currentBuildParams })});`,
|
|
97
|
+
``,
|
|
79
98
|
`function send(payload) {`,
|
|
80
99
|
` if (typeof process.send === "function") process.send(payload);`,
|
|
81
100
|
` else console.log(JSON.stringify(payload));`,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildDeclaredPerStack } from "./graph-declared";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Side-by-side stacks (#1433) — several stacks declared in config, each with its
|
|
9
|
+
* own `src`, composed into one graph.
|
|
10
|
+
*
|
|
11
|
+
* This is chant's ordinary multi-stack shape, as distinct from directory
|
|
12
|
+
* partitioning (subdirectories of one `src/`), and it is the one where stack
|
|
13
|
+
* membership is *declared* rather than inferred: the caller passes the names, and
|
|
14
|
+
* `buildDeclaredPerStack` already renames every node `${stack}::${id}`. The
|
|
15
|
+
* grouping was being discarded, so `groups.byStack` — the axis a boundary-box
|
|
16
|
+
* renderer reads — came back empty for exactly the projects that have real
|
|
17
|
+
* stacks to box.
|
|
18
|
+
*/
|
|
19
|
+
let dir: string;
|
|
20
|
+
|
|
21
|
+
// The shape discovery actually recognises — a plain object carrying the
|
|
22
|
+
// declarable marker, as `env.test.ts`'s fixtures do.
|
|
23
|
+
const stackSrc = (resource: string) =>
|
|
24
|
+
`export const ${resource} = { entityType: "Thing", lexicon: "aws", kind: "resource", [Symbol.for("chant.declarable")]: true };\n`;
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), "chant-side-by-side-"));
|
|
28
|
+
for (const [stack, resource] of [
|
|
29
|
+
["network", "vpc"],
|
|
30
|
+
["app", "service"],
|
|
31
|
+
] as const) {
|
|
32
|
+
mkdirSync(join(dir, stack, "src"), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, stack, "src", "main.ts"), stackSrc(resource));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
+
|
|
38
|
+
describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
|
|
39
|
+
test("every node lands in the stack that declared it", async () => {
|
|
40
|
+
const ir = await buildDeclaredPerStack(
|
|
41
|
+
[
|
|
42
|
+
{ name: "network", src: "network/src" },
|
|
43
|
+
{ name: "app", src: "app/src" },
|
|
44
|
+
],
|
|
45
|
+
dir,
|
|
46
|
+
);
|
|
47
|
+
const byStack = ir.groups.byStack ?? {};
|
|
48
|
+
expect(Object.keys(byStack).sort()).toEqual(["app", "network"]);
|
|
49
|
+
// Membership uses the SAME qualified ids the nodes carry — a consumer joins
|
|
50
|
+
// the two without re-deriving anything.
|
|
51
|
+
const ids = new Set(ir.nodes.map((n) => n.id));
|
|
52
|
+
for (const members of Object.values(byStack)) {
|
|
53
|
+
for (const id of members) expect(ids.has(id)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
// Every node is in exactly one stack: a boundary-box renderer that drew from
|
|
56
|
+
// this would otherwise leave resources outside every box.
|
|
57
|
+
expect(Object.values(byStack).flat().sort()).toEqual([...ids].sort());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a stack with no src contributes no group, as it contributes no nodes", async () => {
|
|
61
|
+
const ir = await buildDeclaredPerStack(
|
|
62
|
+
[{ name: "network", src: "network/src" }, { name: "unbuilt" }],
|
|
63
|
+
dir,
|
|
64
|
+
);
|
|
65
|
+
expect(Object.keys(ir.groups.byStack ?? {})).toEqual(["network"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no stacks with src means no grouping at all, not an empty box", async () => {
|
|
69
|
+
const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], dir);
|
|
70
|
+
expect(ir.groups.byStack).toBeUndefined();
|
|
71
|
+
expect(ir.nodes).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("members are sorted, so the IR stays byte-stable across runs", async () => {
|
|
75
|
+
const stacks = [
|
|
76
|
+
{ name: "network", src: "network/src" },
|
|
77
|
+
{ name: "app", src: "app/src" },
|
|
78
|
+
];
|
|
79
|
+
const a = await buildDeclaredPerStack(stacks, dir);
|
|
80
|
+
const b = await buildDeclaredPerStack(stacks, dir);
|
|
81
|
+
expect(a.groups.byStack).toEqual(b.groups.byStack);
|
|
82
|
+
for (const members of Object.values(a.groups.byStack ?? {})) {
|
|
83
|
+
expect(members).toEqual([...members].sort());
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/graph-declared.ts
CHANGED
|
@@ -22,12 +22,24 @@ export async function buildDeclaredPerStack(
|
|
|
22
22
|
): Promise<GraphIR> {
|
|
23
23
|
const nodes: IRNode[] = [];
|
|
24
24
|
const edges: GraphIR["edges"] = [];
|
|
25
|
+
// Which nodes belong to which stack (#1433). This is not derived or guessed:
|
|
26
|
+
// the stack is declared in config and every node here is being renamed with
|
|
27
|
+
// its name on the line below. The membership was always known and thrown
|
|
28
|
+
// away, which left `byStack` — the axis consumers draw boundary boxes from —
|
|
29
|
+
// empty for the one project shape that genuinely has side-by-side stacks.
|
|
30
|
+
const byStack: Record<string, string[]> = {};
|
|
25
31
|
for (const st of stacks) {
|
|
26
32
|
if (!st.src) continue;
|
|
27
33
|
const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
|
|
28
34
|
const q = (id: string) => `${st.name}::${id}`;
|
|
29
|
-
for (const n of g.nodes)
|
|
35
|
+
for (const n of g.nodes) {
|
|
36
|
+
const id = q(n.id);
|
|
37
|
+
nodes.push({ ...n, id });
|
|
38
|
+
(byStack[st.name] ??= []).push(id);
|
|
39
|
+
}
|
|
30
40
|
for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
|
|
31
41
|
}
|
|
32
|
-
|
|
42
|
+
for (const ids of Object.values(byStack)) ids.sort();
|
|
43
|
+
const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
|
|
44
|
+
return { nodes, edges, groups };
|
|
33
45
|
}
|
package/src/graph-ir.ts
CHANGED
|
@@ -94,9 +94,23 @@ export interface IREdge {
|
|
|
94
94
|
export interface IRGroups {
|
|
95
95
|
byLexicon?: Record<string, string[]>;
|
|
96
96
|
byComposite?: Record<string, string[]>;
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
/**
|
|
98
|
+
* Deployable-stack grouping (`stackName → nodeIds`). Consumers (e.g. pinhole's
|
|
99
|
+
* boundary boxes) read this rather than inferring stacks — which is the point,
|
|
100
|
+
* so it should never require one.
|
|
101
|
+
*
|
|
102
|
+
* Two sources, depending on how the project is shaped:
|
|
103
|
+
*
|
|
104
|
+
* - **side-by-side stacks**, declared in config and composed by
|
|
105
|
+
* `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
|
|
106
|
+
* inferred; the project stated both the names and the membership.
|
|
107
|
+
* - **one source tree** — keys are lexicon partitions, since each lexicon
|
|
108
|
+
* serialises to one deployable stack.
|
|
109
|
+
*
|
|
110
|
+
* Formerly documented as awaiting "#513 phase 2" to regroup by nested
|
|
111
|
+
* child-project. #513 is closed and that phase was never filed; directory
|
|
112
|
+
* partitioning is the exception rather than the rule (#1433).
|
|
113
|
+
*/
|
|
100
114
|
byStack?: Record<string, string[]>;
|
|
101
115
|
/** Live containment (#779): a container node id → the node ids directly inside
|
|
102
116
|
* it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
|
|
@@ -415,11 +429,21 @@ export function buildGraphIr(
|
|
|
415
429
|
nodes.push(node);
|
|
416
430
|
|
|
417
431
|
(byLexicon[entity.lexicon] ??= []).push(name);
|
|
418
|
-
//
|
|
419
|
-
// stack
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
432
|
+
// Within ONE source tree a stack is a lexicon partition — each lexicon
|
|
433
|
+
// serialises to one deployable stack (a CloudFormation template, a CI
|
|
434
|
+
// config) — so that is what `byStack` reports here. It stays a distinct axis
|
|
435
|
+
// from `byLexicon` (which is for provenance/colouring), emitted separately
|
|
436
|
+
// even where the two coincide.
|
|
437
|
+
//
|
|
438
|
+
// A project with genuinely separate, side-by-side stacks declares them in
|
|
439
|
+
// config, and `buildDeclaredPerStack` (./graph-declared.ts) groups those by
|
|
440
|
+
// their declared names — no inference, since the project already said. That
|
|
441
|
+
// is the multi-stack shape chant steers toward (#1433).
|
|
442
|
+
//
|
|
443
|
+
// This previously promised that "#513 phase 2 will regroup it by nested
|
|
444
|
+
// child-project". #513 is closed, that phase was never filed, and directory
|
|
445
|
+
// partitioning is the exception rather than the rule — so the promise is
|
|
446
|
+
// withdrawn rather than left pointing at a closed issue.
|
|
423
447
|
(byStack[entity.lexicon] ??= []).push(name);
|
|
424
448
|
if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
|
|
425
449
|
}
|
package/src/graph-refs.test.ts
CHANGED
|
@@ -188,3 +188,59 @@ describe("traversal name vs rendering label (#1275)", () => {
|
|
|
188
188
|
expect(edges).toEqual([]);
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("containment is traversable without being drawn", () => {
|
|
193
|
+
// "What is inside this" is a query, not a picture. The two had been decided
|
|
194
|
+
// by one field: a containment rule became a real edge only if it set
|
|
195
|
+
// `viaAttr`, which a renderer also reads. So making a relationship queryable
|
|
196
|
+
// meant drawing it, and the list of which ones had been remembered was the
|
|
197
|
+
// list of which questions could be asked.
|
|
198
|
+
const catalog: ReferenceCatalog = {
|
|
199
|
+
identities: [
|
|
200
|
+
{ kind: "Subnet", ids: ["SubnetId"] },
|
|
201
|
+
{ kind: "Vpc", ids: ["VpcId"] },
|
|
202
|
+
],
|
|
203
|
+
refs: [
|
|
204
|
+
{ from: "Eni", path: "SubnetId", targetKind: "Subnet", relation: "containment", label: "in subnet" },
|
|
205
|
+
{ from: "Subnet", path: "VpcId", targetKind: "Vpc", relation: "containment", label: "in VPC" },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
const nodes = [
|
|
209
|
+
{ id: "vpc-1", kind: "Vpc", lexicon: "x", attrs: { VpcId: "vpc-1" } },
|
|
210
|
+
{ id: "sub-1", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-1", VpcId: "vpc-1" } },
|
|
211
|
+
{ id: "sub-2", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-2", VpcId: "vpc-1" } },
|
|
212
|
+
{ id: "eni-1", kind: "Eni", lexicon: "x", attrs: { SubnetId: "sub-1" } },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
it("still draws no containment lines — the boundary stays a boundary", () => {
|
|
216
|
+
const { edges } = reconstructEdges(nodes, catalog);
|
|
217
|
+
expect(edges).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("still reports the boundary pairs a renderer groups by", () => {
|
|
221
|
+
const { containment } = reconstructEdges(nodes, catalog);
|
|
222
|
+
expect(containment).toContainEqual({ child: "eni-1", parent: "sub-1", label: "in subnet" });
|
|
223
|
+
expect(containment).toContainEqual({ child: "sub-1", parent: "vpc-1", label: "in VPC" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("offers the same pairs as edges a query can walk", () => {
|
|
227
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
228
|
+
expect(containmentEdges).toContainEqual({ from: "eni-1", to: "sub-1", kind: "ref", viaAttr: "SubnetId" });
|
|
229
|
+
expect(containmentEdges).toContainEqual({ from: "sub-1", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("no rule had to opt in — neither declares viaAttr", () => {
|
|
233
|
+
// The point of the change. Both rules above are plain containment; the
|
|
234
|
+
// traversal name is derived from the attribute the containment was read
|
|
235
|
+
// through, so a new kind is queryable the day its rule is written.
|
|
236
|
+
expect(catalog.refs.every((r) => r.viaAttr === undefined)).toBe(true);
|
|
237
|
+
// eni-1 -> sub-1, and both subnets -> vpc-1.
|
|
238
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
239
|
+
expect(containmentEdges).toHaveLength(3);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("an empty container is reached by nothing, which is the whole question", () => {
|
|
243
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
244
|
+
expect(containmentEdges.some((e) => e.to === "sub-2")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/graph-refs.ts
CHANGED
|
@@ -86,6 +86,30 @@ export interface DanglingRef {
|
|
|
86
86
|
export interface ReconstructedEdges {
|
|
87
87
|
edges: IREdge[];
|
|
88
88
|
containment: ContainmentPair[];
|
|
89
|
+
/**
|
|
90
|
+
* The same containment pairs, as edges a query can walk.
|
|
91
|
+
*
|
|
92
|
+
* Containment is a boundary when you are drawing it and a relationship when
|
|
93
|
+
* you are asking about it, and those two consumers had been served by one
|
|
94
|
+
* decision. `edges` is what a renderer draws as lines, so putting "is in this
|
|
95
|
+
* VPC" there would draw a line from every resource to its VPC and undo the
|
|
96
|
+
* boxes; that is why containment is kept out of it, and why it stays out.
|
|
97
|
+
*
|
|
98
|
+
* But `->`/`<-` is asking which nodes reach which, and being inside something
|
|
99
|
+
* is a way of reaching it. "Which subnets have no network interfaces in them"
|
|
100
|
+
* and "which VPCs have no instances in them" are the same question, and both
|
|
101
|
+
* are containment. With only `edges` to walk, the negation matched everything
|
|
102
|
+
* and reported an estate where nothing is anywhere.
|
|
103
|
+
*
|
|
104
|
+
* The escape hatch this replaces was per-rule: a containment rule could set
|
|
105
|
+
* `viaAttr` and become a real edge. That put the query layer's needs in a
|
|
106
|
+
* field the renderer also reads, and it had to be remembered per rule —
|
|
107
|
+
* `AWS::EC2::Instance -> Subnet` had it and `AWS::EC2::NetworkInterface ->
|
|
108
|
+
* Subnet` did not, which is the kind of gap hand-maintained lists always
|
|
109
|
+
* develop. Deriving them here means a containment rule is traversable because
|
|
110
|
+
* it is a containment rule, not because someone remembered.
|
|
111
|
+
*/
|
|
112
|
+
containmentEdges: IREdge[];
|
|
89
113
|
dangling: DanglingRef[];
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -158,6 +182,8 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
158
182
|
|
|
159
183
|
const edges: IREdge[] = [];
|
|
160
184
|
const containment: ContainmentPair[] = [];
|
|
185
|
+
const containmentEdges: IREdge[] = [];
|
|
186
|
+
const seenContEdge = new Set<string>();
|
|
161
187
|
const dangling: DanglingRef[] = [];
|
|
162
188
|
const seenEdge = new Set<string>();
|
|
163
189
|
const seenCont = new Set<string>();
|
|
@@ -185,6 +211,15 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
185
211
|
seenCont.add(k);
|
|
186
212
|
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
187
213
|
}
|
|
214
|
+
// Traversable by construction. The attribute the containment was read
|
|
215
|
+
// through is its traversal name, so `<-attr:` can still discriminate
|
|
216
|
+
// between two ways of being inside something.
|
|
217
|
+
const via = rule.viaAttr ?? rule.path;
|
|
218
|
+
const ke = `${node.id}|${match.id}|${via}`;
|
|
219
|
+
if (!seenContEdge.has(ke)) {
|
|
220
|
+
seenContEdge.add(ke);
|
|
221
|
+
containmentEdges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
|
|
222
|
+
}
|
|
188
223
|
// A containment relation is a boundary hint, not an edge — unless the
|
|
189
224
|
// rule declares a traversal name (#1275). A fold's first hop is
|
|
190
225
|
// sometimes exactly a containment ("an instance is in a subnet"), and
|
|
@@ -202,10 +237,11 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
202
237
|
}
|
|
203
238
|
|
|
204
239
|
edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
|
|
240
|
+
containmentEdges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
|
|
205
241
|
containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`));
|
|
206
242
|
dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`));
|
|
207
243
|
|
|
208
|
-
return { edges, containment, dangling };
|
|
244
|
+
return { edges, containment, containmentEdges, dangling };
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
/**
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ export * from "./import/parser";
|
|
|
49
49
|
export * from "./import/generator";
|
|
50
50
|
export * from "./lexicon";
|
|
51
51
|
export * from "./observation";
|
|
52
|
+
export * from "./apply";
|
|
52
53
|
export * from "./deep-observation";
|
|
53
54
|
export * from "./owner-chain";
|
|
54
55
|
export * from "./lexicon-integrity";
|
package/src/kubectl-context.ts
CHANGED
|
@@ -126,7 +126,10 @@ export async function resolveClusterTarget(
|
|
|
126
126
|
lexiconName: string,
|
|
127
127
|
options: ResolveClusterTargetOptions = {},
|
|
128
128
|
): Promise<ResolvedClusterTarget> {
|
|
129
|
-
|
|
129
|
+
// #1344 — the k8s lexicon declares this namespace and core validates it at
|
|
130
|
+
// load, so the shape is checked rather than asserted. `K8sConfigShape` stays
|
|
131
|
+
// as core's local description for the case where the lexicon is absent.
|
|
132
|
+
const k8sConfig = (config as { k8s?: K8sConfigShape }).k8s;
|
|
130
133
|
const bound = k8sConfig?.profiles?.[environment]?.context;
|
|
131
134
|
|
|
132
135
|
if (!bound) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateLexiconConfig, formatLexiconConfigProblems } from "./lexicon-config";
|
|
4
|
+
import type { ChantConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
const forgejo = {
|
|
7
|
+
name: "forgejo",
|
|
8
|
+
configSchema: z.strictObject({
|
|
9
|
+
runnerLabels: z.record(z.string(), z.string()).optional(),
|
|
10
|
+
actionsRoot: z.string().optional(),
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const undeclared = { name: "docker" };
|
|
15
|
+
|
|
16
|
+
const cfg = (extra: Record<string, unknown>): ChantConfig =>
|
|
17
|
+
({ lexicons: ["forgejo"], ...extra }) as unknown as ChantConfig;
|
|
18
|
+
|
|
19
|
+
describe("validateLexiconConfig (#1344)", () => {
|
|
20
|
+
test("accepts a namespace matching the declared shape", () => {
|
|
21
|
+
const problems = validateLexiconConfig(
|
|
22
|
+
[forgejo],
|
|
23
|
+
cfg({ forgejo: { runnerLabels: { "ubuntu-latest": "docker" }, actionsRoot: "https://x" } }),
|
|
24
|
+
);
|
|
25
|
+
expect(problems).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects an unknown key — the typo that used to be silently ignored", () => {
|
|
29
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { runnerLabel: {} } }));
|
|
30
|
+
expect(problems).toHaveLength(1);
|
|
31
|
+
expect(problems[0].path).toBe("forgejo");
|
|
32
|
+
expect(problems[0].message).toContain("runnerLabel");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("names the dotted path of a bad value, not just the namespace", () => {
|
|
36
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
|
|
37
|
+
expect(problems[0].path).toBe("forgejo.actionsRoot");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an absent namespace is fine — every one of them is optional", () => {
|
|
41
|
+
expect(validateLexiconConfig([forgejo], cfg({}))).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an empty namespace is fine", () => {
|
|
45
|
+
expect(validateLexiconConfig([forgejo], cfg({ forgejo: {} }))).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a lexicon that declares nothing keeps passthrough", () => {
|
|
49
|
+
// Tightening a namespace nobody described would fail configs that work.
|
|
50
|
+
expect(validateLexiconConfig([undeclared], cfg({ docker: { anything: true } }))).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("another lexicon's namespace is not this lexicon's to reject", () => {
|
|
54
|
+
expect(validateLexiconConfig([forgejo], cfg({ somethingElse: { a: 1 } }))).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("core's own keys are untouched", () => {
|
|
58
|
+
expect(validateLexiconConfig([forgejo], cfg({ sourceDir: "src", build: { fold: true } }))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reports every problem, not just the first", () => {
|
|
62
|
+
const problems = validateLexiconConfig(
|
|
63
|
+
[forgejo],
|
|
64
|
+
cfg({ forgejo: { runnerLabel: {}, actionsRoot: 42 } }),
|
|
65
|
+
);
|
|
66
|
+
expect(problems.length).toBeGreaterThanOrEqual(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("validates each declaring lexicon independently", () => {
|
|
70
|
+
const temporal = {
|
|
71
|
+
name: "temporal",
|
|
72
|
+
configSchema: z.strictObject({ defaultProfile: z.string().optional() }),
|
|
73
|
+
};
|
|
74
|
+
const problems = validateLexiconConfig(
|
|
75
|
+
[forgejo, temporal],
|
|
76
|
+
cfg({ forgejo: { actionsRoot: "https://x" }, temporal: { defaultProfil: "local" } }),
|
|
77
|
+
);
|
|
78
|
+
expect(problems.map((p) => p.lexicon)).toEqual(["temporal"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no config at all is not a problem", () => {
|
|
82
|
+
expect(validateLexiconConfig([forgejo], undefined)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a nested strictObject catches a typo one level down", () => {
|
|
86
|
+
const k8s = {
|
|
87
|
+
name: "k8s",
|
|
88
|
+
configSchema: z.strictObject({
|
|
89
|
+
profiles: z.record(z.string(), z.strictObject({ context: z.string() })).optional(),
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
const problems = validateLexiconConfig(
|
|
93
|
+
[k8s],
|
|
94
|
+
cfg({ k8s: { profiles: { prod: { contxt: "prod-eks" } } } }),
|
|
95
|
+
);
|
|
96
|
+
// Two, and both are right: the key it does not recognize, and the required
|
|
97
|
+
// one that is now missing because of the typo.
|
|
98
|
+
expect(problems.map((p) => p.path).sort()).toEqual(["k8s.profiles.prod", "k8s.profiles.prod.context"]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("formatLexiconConfigProblems", () => {
|
|
103
|
+
test("one indented line per problem", () => {
|
|
104
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
|
|
105
|
+
expect(formatLexiconConfigProblems(problems)).toMatch(/^ {2}forgejo\.actionsRoot: /);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("empty for no problems", () => {
|
|
109
|
+
expect(formatLexiconConfigProblems([])).toBe("");
|
|
110
|
+
});
|
|
111
|
+
});
|