@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
|
@@ -129,7 +129,7 @@ describe("chant workspace ls on built workspaces", () => {
|
|
|
129
129
|
["tools", false, "kind-probe-failed"],
|
|
130
130
|
["vendor", true, null],
|
|
131
131
|
]);
|
|
132
|
-
expect(doc.members[2].reason?.message).toMatch(/known kinds: chant, other, workspace/);
|
|
132
|
+
expect(doc.members[2].reason?.message).toMatch(/known kinds: chant, design, other, workspace/);
|
|
133
133
|
expect(doc.members[0].roles).toEqual([{ name: "frontend", path: "src" }]);
|
|
134
134
|
expect(doc.groups[0].reason?.code).toBe("no-matches");
|
|
135
135
|
expect(doc.summary).toEqual({ members: 5, unreadable: 3, groups: 1, matches: 0 });
|
|
@@ -269,3 +269,86 @@ describe("chant workspace ls on built workspaces", () => {
|
|
|
269
269
|
expect(result(listWorkspace({ cwd: root })).members).toEqual([]);
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
|
+
|
|
273
|
+
describe("chant workspace ls: generated files and job names (#3050)", () => {
|
|
274
|
+
const workflow = [
|
|
275
|
+
"name: ci",
|
|
276
|
+
"jobs:",
|
|
277
|
+
" build:",
|
|
278
|
+
" runs-on: ubuntu-latest",
|
|
279
|
+
" deploy:",
|
|
280
|
+
" name: Deploy to prod",
|
|
281
|
+
" runs-on: ubuntu-latest",
|
|
282
|
+
"",
|
|
283
|
+
].join("\n");
|
|
284
|
+
const gitlab = ["stages: [build]", "variables:", " A: b", ".hidden:", " script: [x]", "api-build:", " stage: build", " script: [x]", ""].join("\n");
|
|
285
|
+
|
|
286
|
+
function build(prefix: string) {
|
|
287
|
+
const p = (path: string) => (prefix === "" ? path : `${prefix}/${path}`);
|
|
288
|
+
const record = {
|
|
289
|
+
schema: 1,
|
|
290
|
+
files: [
|
|
291
|
+
{ path: ".github/workflows/chant-api-prod.yml", command: "chant build --components --generate github --env prod", env: "prod" },
|
|
292
|
+
{ path: ".gitlab/ci/chant-api-prod.gitlab-ci.yml", command: "chant build --components --generate gitlab --env prod", env: "prod" },
|
|
293
|
+
{ path: p("services/api/dist/out.json"), command: "chant build" },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
return repo({
|
|
297
|
+
[p("chant.workspace.json")]: declaration([
|
|
298
|
+
{ name: "api", dir: "services/api", kind: "other", because: "an api", generated: [{ path: "dist/out.json", generator: "chant build --out dist/out.json" }, { path: "NOTES.md", generator: "x", handWritten: { because: "kept by hand" } }] },
|
|
299
|
+
{ name: "docs", dir: "docs", kind: "other", because: "the docs" },
|
|
300
|
+
]),
|
|
301
|
+
[p("services/api/.chant/generated.json")]: JSON.stringify(record),
|
|
302
|
+
[p(".github/workflows/chant-api-prod.yml")]: workflow,
|
|
303
|
+
[p(".gitlab/ci/chant-api-prod.gitlab-ci.yml")]: gitlab,
|
|
304
|
+
[p("docs/README.md")]: "x",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
test("lists each member's generated files with the job names in its CI files", () => {
|
|
309
|
+
const root = build("");
|
|
310
|
+
const doc = result(listWorkspace({ cwd: root }));
|
|
311
|
+
expectValid(doc);
|
|
312
|
+
const api = doc.members.find((m) => m.name === "api")!;
|
|
313
|
+
expect(api.generated).toEqual([
|
|
314
|
+
{ path: ".github/workflows/chant-api-prod.yml", command: "chant build --components --generate github --env prod", env: "prod", jobs: ["build", "Deploy to prod"] },
|
|
315
|
+
{ path: ".gitlab/ci/chant-api-prod.gitlab-ci.yml", command: "chant build --components --generate gitlab --env prod", env: "prod", jobs: ["api-build"] },
|
|
316
|
+
{ path: "services/api/dist/out.json", command: "chant build --out dist/out.json", env: null, jobs: null },
|
|
317
|
+
]);
|
|
318
|
+
expect(doc.members.find((m) => m.name === "docs")!.generated).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("names paths from the repository root for a workspace below it", () => {
|
|
322
|
+
const root = build("infra");
|
|
323
|
+
const doc = result(listWorkspace({ cwd: join(root, "infra") }));
|
|
324
|
+
expectValid(doc);
|
|
325
|
+
const api = doc.members.find((m) => m.name === "api")!;
|
|
326
|
+
expect(api.generated.map((g) => g.path)).toEqual([
|
|
327
|
+
".github/workflows/chant-api-prod.yml",
|
|
328
|
+
".gitlab/ci/chant-api-prod.gitlab-ci.yml",
|
|
329
|
+
"infra/services/api/dist/out.json",
|
|
330
|
+
].sort());
|
|
331
|
+
expect(api.generated.find((g) => g.path.endsWith("chant-api-prod.yml"))!.jobs).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("reads the job names at a revision", () => {
|
|
335
|
+
const root = build("");
|
|
336
|
+
git(root, "add", "-A");
|
|
337
|
+
git(root, "commit", "-q", "-m", "x");
|
|
338
|
+
const rev = git(root, "rev-parse", "HEAD");
|
|
339
|
+
const doc = result(listWorkspace({ cwd: root, at: rev }));
|
|
340
|
+
expectValid(doc);
|
|
341
|
+
expect(doc.members.find((m) => m.name === "api")!.generated[0].jobs).toEqual(["build", "Deploy to prod"]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("lists a CI file with null jobs when it can't be parsed, and nothing when there is no record", () => {
|
|
345
|
+
const root = repo({
|
|
346
|
+
"chant.workspace.json": declaration([{ name: "api", dir: "api", kind: "other", because: "x" }]),
|
|
347
|
+
"api/.chant/generated.json": JSON.stringify({ schema: 1, files: [{ path: ".github/workflows/x.yml", command: "c" }] }),
|
|
348
|
+
".github/workflows/x.yml": "jobs: [unclosed",
|
|
349
|
+
});
|
|
350
|
+
const doc = result(listWorkspace({ cwd: root }));
|
|
351
|
+
expectValid(doc);
|
|
352
|
+
expect(doc.members[0].generated).toEqual([{ path: ".github/workflows/x.yml", command: "c", env: null, jobs: null }]);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated files a member owns, and the CI job names inside them, for
|
|
3
|
+
* `chant workspace ls --json` (#3050; read by INTENTIUS/github-warden#62).
|
|
4
|
+
*
|
|
5
|
+
* Two sources feed the list. The member's declared `generated` entries (#2541)
|
|
6
|
+
* are relative to the member's directory. The interim record
|
|
7
|
+
* `.chant/generated.json` that `chant build --components --generate` and
|
|
8
|
+
* `chant run --generate` write (#2542) holds repository-relative paths, which is
|
|
9
|
+
* how a forge CI file outside the member's directory is named. A path in both
|
|
10
|
+
* lists once, with the declared entry's command.
|
|
11
|
+
*
|
|
12
|
+
* The job names are what a forge shows as the check name, so a reader can
|
|
13
|
+
* require them: for GitHub, Forgejo and Gitea the job's `name`, else its id;
|
|
14
|
+
* for GitLab the job's key. chant parses the file and runs nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import yaml from "js-yaml";
|
|
18
|
+
import type { Member } from "./declaration";
|
|
19
|
+
import { isForgePath } from "./checks/pipelines";
|
|
20
|
+
import { GENERATED_RECORD_FILE } from "./member-pipeline";
|
|
21
|
+
import { joinPath, type WorkspaceTree } from "./tree";
|
|
22
|
+
|
|
23
|
+
/** A generated file of a member, as `ls --json` lists it. */
|
|
24
|
+
export interface LsGenerated {
|
|
25
|
+
/** From the repository root, with / separators, as a forge reads CI files. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** The command that regenerates it, run in the member's directory. */
|
|
28
|
+
command: string;
|
|
29
|
+
/** The environment a component pipeline deploys, or null. */
|
|
30
|
+
env: string | null;
|
|
31
|
+
/** The check names of a forge CI file, or null for any other file and for one that can't be read or parsed. */
|
|
32
|
+
jobs: string[] | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Top-level GitLab keys that are not jobs. */
|
|
36
|
+
const GITLAB_KEYWORDS = new Set(["stages", "variables", "default", "include", "workflow", "image", "services", "cache", "before_script", "after_script", "pages"]);
|
|
37
|
+
|
|
38
|
+
/** The check names in a forge CI file, or null when the file is no workflow or can't be parsed. */
|
|
39
|
+
export function jobNames(path: string, text: string): string[] | null {
|
|
40
|
+
let doc: unknown;
|
|
41
|
+
try {
|
|
42
|
+
doc = yaml.load(text);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
|
|
47
|
+
const top = doc as Record<string, unknown>;
|
|
48
|
+
if (path.startsWith(".gitlab/ci/") || path === ".gitlab-ci.yml") {
|
|
49
|
+
return Object.entries(top)
|
|
50
|
+
.filter(([k, v]) => !k.startsWith(".") && !GITLAB_KEYWORDS.has(k) && v && typeof v === "object" && !Array.isArray(v))
|
|
51
|
+
.map(([k]) => k);
|
|
52
|
+
}
|
|
53
|
+
const jobs = top["jobs"];
|
|
54
|
+
if (!jobs || typeof jobs !== "object" || Array.isArray(jobs)) return null;
|
|
55
|
+
return Object.entries(jobs as Record<string, unknown>).map(([id, job]) => {
|
|
56
|
+
const name = job && typeof job === "object" ? (job as Record<string, unknown>)["name"] : undefined;
|
|
57
|
+
return typeof name === "string" && name !== "" ? name : id;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface RecordFile {
|
|
62
|
+
path?: unknown;
|
|
63
|
+
command?: unknown;
|
|
64
|
+
env?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The member's generated files. `prefix` is the workspace root relative to the
|
|
69
|
+
* repository root, `"."` when they are the same. Never throws for a record that
|
|
70
|
+
* is missing or malformed: that member lists what the declaration gives.
|
|
71
|
+
*/
|
|
72
|
+
export function memberGenerated(member: Member, tree: WorkspaceTree, prefix: string): LsGenerated[] {
|
|
73
|
+
const repoPath = (workspacePath: string) => joinPath(prefix, workspacePath);
|
|
74
|
+
const byPath = new Map<string, LsGenerated>();
|
|
75
|
+
|
|
76
|
+
const recordFile = joinPath(member.dir, GENERATED_RECORD_FILE);
|
|
77
|
+
if (tree.stat(recordFile) === "file") {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(tree.read(recordFile)) as { files?: RecordFile[] };
|
|
80
|
+
for (const f of Array.isArray(parsed.files) ? parsed.files : []) {
|
|
81
|
+
if (typeof f.path !== "string") continue;
|
|
82
|
+
byPath.set(f.path, {
|
|
83
|
+
path: f.path,
|
|
84
|
+
command: typeof f.command === "string" ? f.command : "",
|
|
85
|
+
env: typeof f.env === "string" ? f.env : null,
|
|
86
|
+
jobs: null,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// an unreadable record lists nothing from itself
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const g of member.generated) {
|
|
94
|
+
if (g.handWritten) continue;
|
|
95
|
+
const path = repoPath(joinPath(member.dir, g.path));
|
|
96
|
+
byPath.set(path, { path, command: g.generator, env: byPath.get(path)?.env ?? null, jobs: null });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const own = prefix === "." ? "" : `${prefix}/`;
|
|
100
|
+
for (const entry of byPath.values()) {
|
|
101
|
+
if (!isForgePath(entry.path) || !entry.path.startsWith(own)) continue;
|
|
102
|
+
const inTree = entry.path.slice(own.length);
|
|
103
|
+
if (tree.stat(inTree) !== "file") continue;
|
|
104
|
+
try {
|
|
105
|
+
entry.jobs = jobNames(entry.path, tree.read(inTree));
|
|
106
|
+
} catch {
|
|
107
|
+
entry.jobs = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
111
|
+
}
|
|
@@ -104,6 +104,11 @@
|
|
|
104
104
|
"type": "array",
|
|
105
105
|
"items": { "$ref": "#/$defs/recordKind" }
|
|
106
106
|
},
|
|
107
|
+
"generated": {
|
|
108
|
+
"description": "Added in contract 1 by #3050. The files the member generates, from its declared generated entries and from the record `chant build --components --generate` and `chant run --generate` write, sorted by path. A hand-written entry is not listed.",
|
|
109
|
+
"type": "array",
|
|
110
|
+
"items": { "$ref": "#/$defs/generatedFile" }
|
|
111
|
+
},
|
|
107
112
|
"reason": {
|
|
108
113
|
"oneOf": [
|
|
109
114
|
{ "type": "null" },
|
|
@@ -165,6 +170,20 @@
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
},
|
|
173
|
+
"generatedFile": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"required": ["path", "command", "env", "jobs"],
|
|
176
|
+
"properties": {
|
|
177
|
+
"path": { "type": "string", "description": "From the repository root, with / separators, which is how a forge reads a CI file. For a workspace below the git root, that includes the workspace root." },
|
|
178
|
+
"command": { "type": "string", "description": "The command that regenerates the file, run in the member's directory. Empty when a record names none." },
|
|
179
|
+
"env": { "type": ["string", "null"], "description": "The environment a component pipeline deploys, or null." },
|
|
180
|
+
"jobs": {
|
|
181
|
+
"description": "The check names a forge CI file declares: for GitHub, Forgejo and Gitea the job's name, else its id; for GitLab the job's key. Null for any other file, and for a CI file that is missing from the tree read or can't be parsed.",
|
|
182
|
+
"type": ["array", "null"],
|
|
183
|
+
"items": { "type": "string" }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
168
187
|
"diagram": {
|
|
169
188
|
"description": "A diagram artifact the declaration names (#2764), pinned to the renderer its render was made with. chant runs no renderer; everything here comes straight from the declaration.",
|
|
170
189
|
"type": "object",
|
package/src/workspace/ls.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
-
import { resolve } from "node:path";
|
|
24
|
+
import { isAbsolute, resolve } from "node:path";
|
|
25
25
|
import { formatError } from "../cli/format";
|
|
26
26
|
import type { CommandContext } from "../cli/registry";
|
|
27
27
|
import {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type WorkspaceErrorCode,
|
|
38
38
|
} from "./declaration";
|
|
39
39
|
import type { ReasonCode } from "./reason-codes";
|
|
40
|
+
import { memberGenerated, type LsGenerated } from "./ls-generated";
|
|
40
41
|
import { loadKindRegistry, probeKind, type KindRegistry } from "./kinds";
|
|
41
42
|
import type { WorkspaceTree } from "./tree";
|
|
42
43
|
import { handToRootChant, locateWorkspace } from "./which-chant";
|
|
@@ -89,6 +90,8 @@ export interface LsRecordKind {
|
|
|
89
90
|
acceptance: LsAcceptance[] | null;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
export type { LsGenerated } from "./ls-generated";
|
|
94
|
+
|
|
92
95
|
/** A work item's acceptance criteria, counted (#2772). */
|
|
93
96
|
export interface LsAcceptance {
|
|
94
97
|
item: string;
|
|
@@ -108,6 +111,8 @@ export interface LsMember {
|
|
|
108
111
|
reason: { code: MemberReasonCode; message: string } | null;
|
|
109
112
|
/** The record kinds the member declares, in file order (#2680). */
|
|
110
113
|
records: LsRecordKind[];
|
|
114
|
+
/** The files the member generates, and the job names inside its forge CI files (#3050). */
|
|
115
|
+
generated: LsGenerated[];
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/**
|
|
@@ -231,6 +236,7 @@ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declarati
|
|
|
231
236
|
// for --at: they are read as data, never run (#2535).
|
|
232
237
|
const kinds = query.kinds ?? loadKindRegistry(declaration.pins, rootOnDisk).registry;
|
|
233
238
|
const groups = resolveGroups(declaration, tree);
|
|
239
|
+
const repoPrefix = isAbsolute(located.root) ? "." : located.root;
|
|
234
240
|
const members: LsMember[] = declaration.members.map((m) => {
|
|
235
241
|
const reason = memberReason(m, tree, kinds);
|
|
236
242
|
return {
|
|
@@ -243,6 +249,7 @@ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declarati
|
|
|
243
249
|
readable: reason === null,
|
|
244
250
|
reason,
|
|
245
251
|
records: unloaded(m.records),
|
|
252
|
+
generated: memberGenerated(m, tree, repoPrefix),
|
|
246
253
|
};
|
|
247
254
|
});
|
|
248
255
|
const lsGroups: LsGroup[] = groups.map((g) => ({
|
|
@@ -222,7 +222,9 @@ export function planMembers(verb: WorkspaceVerb, root: string, options: PlanOpti
|
|
|
222
222
|
const why =
|
|
223
223
|
entry.kind === "workspace"
|
|
224
224
|
? "a nested workspace runs its own chant workspace commands"
|
|
225
|
-
:
|
|
225
|
+
: entry.kind === "design"
|
|
226
|
+
? "a design member is data: chant reads its files for record pins and builds nothing"
|
|
227
|
+
: `chant workspace ${verb} runs members of kind chant, and this one is kind ${entry.kind}`;
|
|
226
228
|
skipped.push({ name: entry.name, dir: entry.dir, kind: entry.kind, reason: { code: "kind-not-run", message: why } });
|
|
227
229
|
continue;
|
|
228
230
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2551 — read-only nested workspaces: the nested read's document is
|
|
3
|
+
* checked against the read contract, folded into the outer graph with
|
|
4
|
+
* `outer/inner/id` ids, and the outer upgrade never writes inside a nested
|
|
5
|
+
* workspace. The real read against the reference workspace is in
|
|
6
|
+
* read-contract.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { composeWorkspaceGraph, type ComposedMember, type WorkspaceGraph } from "./compose-graph";
|
|
15
|
+
import { emptyLock, fileEntries, writeLock } from "./lineage-lock";
|
|
16
|
+
import { stageUpgrade } from "./lineage-upgrade";
|
|
17
|
+
import { foldNested, nestedArgv, readNestedDocument } from "./nested-graph";
|
|
18
|
+
import { nestedWorkspaces, pathsInsideNested } from "./nesting";
|
|
19
|
+
|
|
20
|
+
const SCHEMA = "https://intentius.io/chant/schemas/workspace/graph/v1/graph.schema.json";
|
|
21
|
+
|
|
22
|
+
function member(name: string, over: Partial<ComposedMember> = {}): ComposedMember {
|
|
23
|
+
return { name, dir: name, kind: "chant", status: "composed", reason: null, chant: "1.0.0", irVersion: 1, live: false, ...over };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function innerGraph(): WorkspaceGraph & { contract: number } {
|
|
27
|
+
return {
|
|
28
|
+
contract: 1,
|
|
29
|
+
version: 1,
|
|
30
|
+
workspace: { name: "inner", root: "." },
|
|
31
|
+
members: [member("api", { dir: "services/api" }), member("docs", { kind: "other", status: "skipped", reason: { code: "kind-not-run", message: "other" } })],
|
|
32
|
+
nodes: [
|
|
33
|
+
{ id: "api/Queue", kind: "Queue", lexicon: "aws", attrs: { dlq: { $ref: "api/Dlq" } }, member: "api", sourceLoc: { file: "src/q.ts", line: 3 } },
|
|
34
|
+
{ id: "api/Dlq", kind: "Queue", lexicon: "aws", attrs: {}, member: "api", compositeInstance: "api/Pair" },
|
|
35
|
+
],
|
|
36
|
+
edges: [{ from: "api/Queue", to: "api/Dlq", member: "api" } as WorkspaceGraph["edges"][number]],
|
|
37
|
+
groups: { byMember: { api: ["api/Dlq", "api/Queue"], docs: [] }, byLexicon: { aws: ["api/Dlq", "api/Queue"] }, byStack: { "api/main": ["api/Queue"] } },
|
|
38
|
+
exports: [{ name: "queueArn", node: "api/Queue", attr: "Arn", member: "api" } as WorkspaceGraph["exports"][number]],
|
|
39
|
+
imports: [],
|
|
40
|
+
links: [],
|
|
41
|
+
collectors: [],
|
|
42
|
+
records: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("readNestedDocument", () => {
|
|
47
|
+
test("accepts a graph document of the contract this chant reads", () => {
|
|
48
|
+
const r = readNestedDocument(JSON.stringify({ $schema: SCHEMA, chant: "1.2.3", ...innerGraph() }), 0, "", SCHEMA, 1);
|
|
49
|
+
expect(r).toMatchObject({ ok: true, chant: "1.2.3" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("refuses no JSON, another document, a newer contract and an error document, each with a reason code", () => {
|
|
53
|
+
expect(readNestedDocument("boom", 1, "line\nlast line", SCHEMA, 1)).toMatchObject({ ok: false, code: "command-failed", message: expect.stringContaining("last line") });
|
|
54
|
+
expect(readNestedDocument("{}", 0, "", SCHEMA, 1)).toMatchObject({ ok: false, code: "output-unreadable" });
|
|
55
|
+
expect(readNestedDocument(JSON.stringify({ $schema: SCHEMA, contract: 2, chant: "9.0.0" }), 0, "", SCHEMA, 1)).toMatchObject({
|
|
56
|
+
ok: false,
|
|
57
|
+
code: "ir-version-unsupported",
|
|
58
|
+
chant: "9.0.0",
|
|
59
|
+
});
|
|
60
|
+
expect(readNestedDocument(JSON.stringify({ $schema: SCHEMA, contract: 1, error: { code: "declaration-missing", message: "none" } }), 1, "", SCHEMA, 1)).toMatchObject({
|
|
61
|
+
ok: false,
|
|
62
|
+
code: "command-failed",
|
|
63
|
+
message: expect.stringContaining("declaration-missing"),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("passes the read flags on, and never a write", () => {
|
|
68
|
+
expect(nestedArgv("/w/inner", { env: "dev", live: true, overlay: true, traffic: "10 rps", noCache: true, output: "x.json" }, "abc")).toEqual([
|
|
69
|
+
"workspace", "graph", "/w/inner", "--at", "abc", "--env", "dev", "--live", "--overlay", "--traffic", "10 rps", "--no-cache",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("foldNested", () => {
|
|
75
|
+
test("prefixes every id with the outer member, keeps the nested member on each node, and fills the member entry", () => {
|
|
76
|
+
const outer = composeWorkspaceGraph({ name: "outer", root: "." }, [
|
|
77
|
+
{ member: member("platform", { kind: "workspace", status: "skipped", reason: { code: "kind-not-run", message: "nested" }, chant: null, irVersion: null }) },
|
|
78
|
+
]);
|
|
79
|
+
const entry = outer.members[0];
|
|
80
|
+
foldNested(outer, entry, innerGraph());
|
|
81
|
+
expect(outer.nodes.map((n) => [n.id, n.member, n.nested])).toEqual([
|
|
82
|
+
["platform/api/Dlq", "platform", "api"],
|
|
83
|
+
["platform/api/Queue", "platform", "api"],
|
|
84
|
+
]);
|
|
85
|
+
const queue = outer.nodes.find((n) => n.id === "platform/api/Queue")!;
|
|
86
|
+
expect(queue.attrs).toEqual({ dlq: { $ref: "platform/api/Dlq" } });
|
|
87
|
+
// sourceLoc is relative to the outer member's directory, the nested root.
|
|
88
|
+
expect(queue.sourceLoc).toEqual({ file: "services/api/src/q.ts", line: 3 });
|
|
89
|
+
expect(outer.nodes.find((n) => n.id === "platform/api/Dlq")!.compositeInstance).toBe("platform/api/Pair");
|
|
90
|
+
expect(outer.edges).toEqual([{ from: "platform/api/Queue", to: "platform/api/Dlq", member: "platform" }]);
|
|
91
|
+
expect(outer.exports).toEqual([{ name: "queueArn", node: "platform/api/Queue", attr: "Arn", member: "platform" }]);
|
|
92
|
+
expect(outer.groups.byMember.platform).toEqual(["platform/api/Dlq", "platform/api/Queue"]);
|
|
93
|
+
expect(outer.groups.byLexicon).toEqual({ aws: ["platform/api/Dlq", "platform/api/Queue"] });
|
|
94
|
+
expect(outer.groups.byStack).toEqual({ "platform/api/main": ["platform/api/Queue"] });
|
|
95
|
+
expect(entry).toMatchObject({ status: "composed", reason: null, nested: { name: "inner", contract: 1, links: [] } });
|
|
96
|
+
expect(entry.nested!.members.map((m) => m.name)).toEqual(["api", "docs"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("the outer workspace never writes inside a nested one", () => {
|
|
101
|
+
let root: string;
|
|
102
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
103
|
+
|
|
104
|
+
const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
105
|
+
const git = (cwd: string, args: string[]) => execFileSync("git", args, { cwd, env: ENV, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
|
|
106
|
+
const put = (base: string, rel: string, text: string) => {
|
|
107
|
+
mkdirSync(dirname(join(base, rel)), { recursive: true });
|
|
108
|
+
writeFileSync(join(base, rel), text);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
test("finds nested members, and the paths inside them", () => {
|
|
112
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-nesting-")));
|
|
113
|
+
put(root, "chant.workspace.json", JSON.stringify({ name: "outer", schema: 1, members: [{ name: "kit", dir: "vendor/kit", kind: "workspace" }, { name: "docs", dir: "docs", kind: "other", because: "prose" }] }));
|
|
114
|
+
put(root, "vendor/kit/chant.workspace.json", JSON.stringify({ name: "kit", schema: 1, members: [] }));
|
|
115
|
+
mkdirSync(join(root, "docs"));
|
|
116
|
+
expect(nestedWorkspaces(root)).toEqual([{ name: "kit", dir: "vendor/kit" }]);
|
|
117
|
+
expect(pathsInsideNested(root, "", ["docs/a.md", "vendor/kit/x.ts", "vendor/kitten.ts"])).toEqual([{ member: "kit", path: "vendor/kit/x.ts" }]);
|
|
118
|
+
expect(pathsInsideNested(root, "infra", ["infra/vendor/kit/x.ts", "vendor/kit/x.ts"])).toEqual([{ member: "kit", path: "infra/vendor/kit/x.ts" }]);
|
|
119
|
+
expect(nestedWorkspaces(join(root, "docs"))).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an upgrade whose patch reaches into a nested workspace is refused", async () => {
|
|
123
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-nesting-")));
|
|
124
|
+
const tpl = join(root, "tpl");
|
|
125
|
+
const ws = join(root, "ws");
|
|
126
|
+
mkdirSync(tpl);
|
|
127
|
+
git(tpl, ["init", "-q", "-b", "main"]);
|
|
128
|
+
put(tpl, "README.md", "one\n");
|
|
129
|
+
put(tpl, "kit/settings.json", "{}\n");
|
|
130
|
+
git(tpl, ["add", "-A"]);
|
|
131
|
+
git(tpl, ["commit", "-q", "-m", "v1"]);
|
|
132
|
+
git(tpl, ["tag", "v1.0.0"]);
|
|
133
|
+
put(tpl, "kit/settings.json", '{ "v": 2 }\n');
|
|
134
|
+
git(tpl, ["commit", "-q", "-am", "v2"]);
|
|
135
|
+
git(tpl, ["tag", "v2.0.0"]);
|
|
136
|
+
|
|
137
|
+
mkdirSync(ws);
|
|
138
|
+
git(ws, ["init", "-q", "-b", "main"]);
|
|
139
|
+
put(ws, "README.md", "one\n");
|
|
140
|
+
put(ws, "kit/settings.json", "{}\n");
|
|
141
|
+
put(ws, "kit/chant.workspace.json", JSON.stringify({ name: "kit", schema: 1, members: [] }));
|
|
142
|
+
put(ws, "chant.workspace.json", JSON.stringify({ name: "outer", schema: 1, members: [{ name: "kit", dir: "kit", kind: "workspace" }] }));
|
|
143
|
+
const lock = emptyLock();
|
|
144
|
+
const files = new Map([
|
|
145
|
+
["README.md", Buffer.from("one\n")],
|
|
146
|
+
["kit/settings.json", Buffer.from("{}\n")],
|
|
147
|
+
]);
|
|
148
|
+
lock.scopes["."] = {
|
|
149
|
+
kind: "template",
|
|
150
|
+
template: tpl,
|
|
151
|
+
source: { type: "git", repo: tpl, url: "../tpl" },
|
|
152
|
+
ref: "v1.0.0",
|
|
153
|
+
address: { digest: `sha256:${"0".repeat(64)}`, commit: git(tpl, ["rev-parse", "v1.0.0"]) },
|
|
154
|
+
parameters: {},
|
|
155
|
+
migrations: [],
|
|
156
|
+
files: fileEntries(files),
|
|
157
|
+
manualSteps: [],
|
|
158
|
+
};
|
|
159
|
+
writeLock(ws, lock);
|
|
160
|
+
git(ws, ["add", "-A"]);
|
|
161
|
+
git(ws, ["commit", "-q", "-m", "made"]);
|
|
162
|
+
await expect(stageUpgrade({ root: ws, to: "v2.0.0", runChant: async () => ({ exitCode: 0, output: "" }) })).rejects.toThrow(
|
|
163
|
+
/inside the nested workspace kit \(kit\/settings\.json\).*never writes inside it/,
|
|
164
|
+
);
|
|
165
|
+
// The refusal leaves no staging worktree behind.
|
|
166
|
+
expect(git(ws, ["worktree", "list"]).split("\n")).toHaveLength(1);
|
|
167
|
+
|
|
168
|
+
// Naming the nested member from the outer root is refused too: it upgrades itself.
|
|
169
|
+
writeLock(join(ws, "kit"), lock);
|
|
170
|
+
git(ws, ["add", "-A"]);
|
|
171
|
+
git(ws, ["commit", "-q", "-m", "the nested workspace's own lock"]);
|
|
172
|
+
await expect(stageUpgrade({ root: ws, scope: "kit", to: "v2.0.0", runChant: async () => ({ exitCode: 0, output: "" }) })).rejects.toThrow(
|
|
173
|
+
/member "kit" is a nested workspace, which upgrades itself: run chant workspace upgrade in kit/,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only expansion of nested workspaces in `chant workspace graph`
|
|
3
|
+
* (#2551, #2524 D2, ws-007, ws-071).
|
|
4
|
+
*
|
|
5
|
+
* A member of kind `workspace` holds its own `chant.workspace.json`. The outer
|
|
6
|
+
* workspace never runs commands inside it and never writes there. It reads
|
|
7
|
+
* it the way it reads any member: through the nested workspace's own
|
|
8
|
+
* command, `chant workspace graph`, started in the nested root. That command
|
|
9
|
+
* hands itself to the nested workspace's own chant when it has one (ws-021),
|
|
10
|
+
* so the nested side is read under its own toolchain, and the answer is a
|
|
11
|
+
* document of the read contract the outer chant validates before using it.
|
|
12
|
+
*
|
|
13
|
+
* The nested document is folded into the outer one with every id prefixed by
|
|
14
|
+
* the outer member's name, so a node `delivery/web::Bucket` of the nested
|
|
15
|
+
* workspace becomes `reference-workspace/delivery/web::Bucket`: `outer/inner/id`.
|
|
16
|
+
* Each node keeps the nested member it came from in `nested`, and the outer
|
|
17
|
+
* member entry carries the nested workspace's own member list and links.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here writes: the nested read is a read (its per-member cache lives
|
|
20
|
+
* in the user's cache directory, ws-059), and the outer side keeps nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import type { ParsedArgs } from "../cli/registry";
|
|
25
|
+
import type { ComposedMember, ComposedNode, WorkspaceGraph } from "./compose-graph";
|
|
26
|
+
|
|
27
|
+
export type { NestedWorkspace } from "./compose-graph";
|
|
28
|
+
import { readerToolchain, type Toolchain } from "./member-commands";
|
|
29
|
+
|
|
30
|
+
/** What reading one nested workspace gave. */
|
|
31
|
+
export type NestedRead =
|
|
32
|
+
| { ok: true; chant: string | null; doc: WorkspaceGraph & { contract: number; chant?: string } }
|
|
33
|
+
| { ok: false; chant: string | null; code: "command-failed" | "output-unreadable" | "ir-version-unsupported"; message: string };
|
|
34
|
+
|
|
35
|
+
/** The command line of the nested read: the outer command's own read flags, passed on. */
|
|
36
|
+
export function nestedArgv(dir: string, args: Partial<ParsedArgs>, at: string | null | undefined): string[] {
|
|
37
|
+
return [
|
|
38
|
+
"workspace",
|
|
39
|
+
"graph",
|
|
40
|
+
dir,
|
|
41
|
+
...(at ? ["--at", at] : []),
|
|
42
|
+
...(args.env ? ["--env", args.env] : []),
|
|
43
|
+
...(args.live ? ["--live"] : []),
|
|
44
|
+
...(args.overlay ? ["--overlay"] : []),
|
|
45
|
+
...(args.traffic !== undefined ? ["--traffic", String(args.traffic)] : []),
|
|
46
|
+
...(args.noCache ? ["--no-cache"] : []),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function spawnRead(command: string[], argv: string[], cwd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
51
|
+
const [bin, ...pre] = command;
|
|
52
|
+
return new Promise((done) => {
|
|
53
|
+
const child = spawn(bin, [...pre, ...argv], { cwd, stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
54
|
+
const out: Buffer[] = [];
|
|
55
|
+
const err: Buffer[] = [];
|
|
56
|
+
child.stdout.on("data", (b: Buffer) => out.push(b));
|
|
57
|
+
child.stderr.on("data", (b: Buffer) => err.push(b));
|
|
58
|
+
child.on("error", (e) => done({ exitCode: 127, stdout: "", stderr: `could not start ${bin}: ${e.message}\n` }));
|
|
59
|
+
child.on("close", (code) => done({ exitCode: code ?? 1, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") }));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check a nested read's output against the read contract this chant knows. */
|
|
64
|
+
export function readNestedDocument(stdout: string, exitCode: number, stderr: string, schemaId: string, contract: number): NestedRead {
|
|
65
|
+
let raw: unknown;
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(stdout);
|
|
68
|
+
} catch {
|
|
69
|
+
const tail = stderr.trim().split("\n").slice(-5).join("\n");
|
|
70
|
+
return { ok: false, chant: null, code: exitCode === 0 ? "output-unreadable" : "command-failed", message: `the nested chant workspace graph exited ${exitCode} and printed no document${tail ? `: ${tail}` : ""}` };
|
|
71
|
+
}
|
|
72
|
+
const doc = raw as Record<string, unknown>;
|
|
73
|
+
const chant = typeof doc.chant === "string" ? doc.chant : null;
|
|
74
|
+
if (doc.$schema !== schemaId || typeof doc.contract !== "number") {
|
|
75
|
+
return { ok: false, chant, code: "output-unreadable", message: "the nested chant workspace graph printed something that is not a workspace graph document" };
|
|
76
|
+
}
|
|
77
|
+
if (doc.contract > contract) {
|
|
78
|
+
return { ok: false, chant, code: "ir-version-unsupported", message: `the nested workspace's chant printed graph contract ${doc.contract}, and this chant reads up to ${contract}` };
|
|
79
|
+
}
|
|
80
|
+
if (doc.error && typeof doc.error === "object") {
|
|
81
|
+
const e = doc.error as { code?: unknown; message?: unknown };
|
|
82
|
+
return { ok: false, chant, code: "command-failed", message: `the nested workspace could not be read: ${String(e.code)}: ${String(e.message)}` };
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(doc.nodes) || !Array.isArray(doc.edges) || !Array.isArray(doc.members)) {
|
|
85
|
+
return { ok: false, chant, code: "output-unreadable", message: "the nested workspace graph has no nodes, edges and members" };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, chant, doc: doc as unknown as WorkspaceGraph & { contract: number } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run the nested workspace's own `chant workspace graph` in `abs`. */
|
|
91
|
+
export async function readNested(
|
|
92
|
+
abs: string,
|
|
93
|
+
args: Partial<ParsedArgs>,
|
|
94
|
+
at: string | null | undefined,
|
|
95
|
+
schemaId: string,
|
|
96
|
+
contract: number,
|
|
97
|
+
toolchain: Toolchain | undefined = undefined,
|
|
98
|
+
): Promise<NestedRead & { stderr: string }> {
|
|
99
|
+
const r = await spawnRead((toolchain ?? readerToolchain()).command, nestedArgv(abs, args, at), abs);
|
|
100
|
+
return { ...readNestedDocument(r.stdout, r.exitCode, r.stderr, schemaId, contract), stderr: r.stderr };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const prefixed = (outer: string, id: string): string => `${outer}/${id}`;
|
|
104
|
+
|
|
105
|
+
function prefixRefs(value: unknown, outer: string): unknown {
|
|
106
|
+
if (Array.isArray(value)) return value.map((v) => prefixRefs(v, outer));
|
|
107
|
+
if (value === null || typeof value !== "object") return value;
|
|
108
|
+
const out: Record<string, unknown> = {};
|
|
109
|
+
for (const [k, v] of Object.entries(value)) out[k] = k === "$ref" && typeof v === "string" ? prefixed(outer, v) : prefixRefs(v, outer);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fold a nested workspace's graph into the outer one, under the outer member
|
|
115
|
+
* `outer`, and fill that member's entry. The outer graph's other sections are
|
|
116
|
+
* left as they are, so links still resolve against the outer declaration.
|
|
117
|
+
*/
|
|
118
|
+
export function foldNested(graph: WorkspaceGraph, member: ComposedMember, inner: WorkspaceGraph & { contract: number }): void {
|
|
119
|
+
const outer = member.name;
|
|
120
|
+
const dirs = new Map(inner.members.map((m) => [m.name, m.dir]));
|
|
121
|
+
const ids: string[] = [];
|
|
122
|
+
for (const node of inner.nodes) {
|
|
123
|
+
const n: ComposedNode = {
|
|
124
|
+
...node,
|
|
125
|
+
id: prefixed(outer, node.id),
|
|
126
|
+
member: outer,
|
|
127
|
+
nested: node.member,
|
|
128
|
+
attrs: prefixRefs(node.attrs ?? {}, outer) as Record<string, unknown>,
|
|
129
|
+
};
|
|
130
|
+
if (node.compositeInstance) n.compositeInstance = prefixed(outer, node.compositeInstance);
|
|
131
|
+
if (node.runtimeOwner) n.runtimeOwner = prefixed(outer, node.runtimeOwner);
|
|
132
|
+
if (node.compositeParent) n.compositeParent = node.compositeParent;
|
|
133
|
+
// sourceLoc is relative to the member's directory: the outer member's is the nested root.
|
|
134
|
+
const dir = dirs.get(node.member);
|
|
135
|
+
if (node.sourceLoc && dir && dir !== ".") n.sourceLoc = { ...node.sourceLoc, file: `${dir}/${node.sourceLoc.file}` };
|
|
136
|
+
graph.nodes.push(n);
|
|
137
|
+
ids.push(n.id);
|
|
138
|
+
}
|
|
139
|
+
for (const e of inner.edges) graph.edges.push({ ...e, from: prefixed(outer, e.from), to: prefixed(outer, e.to), member: outer });
|
|
140
|
+
for (const e of inner.exports ?? []) graph.exports.push({ ...e, ...(e.node ? { node: prefixed(outer, e.node) } : {}), member: outer });
|
|
141
|
+
for (const i of inner.imports ?? []) graph.imports.push({ ...i, node: prefixed(outer, i.node), member: outer });
|
|
142
|
+
graph.groups.byMember[outer] = ids.sort();
|
|
143
|
+
const groups = graph.groups as unknown as Record<string, Record<string, string[]> | undefined>;
|
|
144
|
+
const innerGroups = inner.groups as unknown as Record<string, Record<string, string[]> | undefined>;
|
|
145
|
+
for (const key of ["byLexicon", "byComposite"] as const) {
|
|
146
|
+
for (const [k, v] of Object.entries(innerGroups[key] ?? {})) {
|
|
147
|
+
const into = (groups[key] ??= {});
|
|
148
|
+
into[k] = [...(into[k] ?? []), ...v.map((id) => prefixed(outer, id))].sort();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const key of ["byStack", "byContainer", "byWave"] as const) {
|
|
152
|
+
for (const [k, v] of Object.entries(innerGroups[key] ?? {})) {
|
|
153
|
+
const into = (groups[key] ??= {});
|
|
154
|
+
into[prefixed(outer, k)] = v.map((id) => prefixed(outer, id)).sort();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const c of inner.collectors ?? []) graph.collectors.push({ ...c, member: prefixed(outer, c.member) });
|
|
158
|
+
for (const [kind, attrs] of Object.entries(inner.derivedAttrs ?? {})) {
|
|
159
|
+
const all = new Set([...(graph.derivedAttrs?.[kind] ?? []), ...attrs]);
|
|
160
|
+
graph.derivedAttrs = { ...(graph.derivedAttrs ?? {}), [kind]: [...all].sort() };
|
|
161
|
+
}
|
|
162
|
+
graph.nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
163
|
+
const edgeKey = (e: (typeof graph.edges)[number]) => `${e.from}\u0000${e.to}\u0000${e.viaAttr ?? ""}`;
|
|
164
|
+
graph.edges.sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)));
|
|
165
|
+
|
|
166
|
+
member.status = "composed";
|
|
167
|
+
member.reason = null;
|
|
168
|
+
member.nested = { name: inner.workspace.name, contract: inner.contract, members: inner.members, links: inner.links ?? [] };
|
|
169
|
+
}
|