@intentius/chant 0.106.4 → 0.108.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/cli/commands/check-lexicon.d.ts +7 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/activities/apply.d.ts +40 -6
- package/dist/op/activities/apply.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +21 -3
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +10 -2
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-plan-context.d.ts +101 -0
- package/dist/op/gate-plan-context.d.ts.map +1 -0
- package/dist/op/gate-point-run.d.ts +110 -0
- package/dist/op/gate-point-run.d.ts.map +1 -0
- package/dist/op/gate-point.d.ts +70 -0
- package/dist/op/gate-point.d.ts.map +1 -0
- package/dist/op/gate.d.ts +12 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +11 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +11 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +16 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +82 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +185 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +15 -1
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon.test.ts +21 -5
- package/src/cli/commands/check-lexicon.ts +14 -3
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/handlers/operator.ts +9 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +6 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/gate-ledger.ts +30 -0
- package/src/op/activities/apply.test.ts +43 -0
- package/src/op/activities/apply.ts +111 -6
- package/src/op/builders.ts +13 -1
- package/src/op/composites/apply-op.ts +24 -4
- package/src/op/gate-approval.ts +18 -2
- package/src/op/gate-plan-context.test.ts +186 -0
- package/src/op/gate-plan-context.ts +229 -0
- package/src/op/gate-point-run.ts +207 -0
- package/src/op/gate-point.e2e.test.ts +166 -0
- package/src/op/gate-point.test.ts +235 -0
- package/src/op/gate-point.ts +119 -0
- package/src/op/gate.ts +26 -1
- package/src/op/index.ts +8 -0
- package/src/op/local-executor.ts +96 -3
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +101 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +198 -0
- package/src/workspace/ci-green-workflow.ts +215 -0
- package/src/workspace/ci-green.test.ts +375 -0
- package/src/workspace/ci-green.ts +408 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/status-gates.ts +13 -2
- package/src/workspace/status.schema.json +11 -1
package/src/op/local-executor.ts
CHANGED
|
@@ -28,9 +28,12 @@ import { parseDuration } from "./duration";
|
|
|
28
28
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
29
29
|
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
30
30
|
import { gateName } from "./gate-name";
|
|
31
|
+
import { gatePointOf } from "./gate-point";
|
|
32
|
+
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
31
33
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
34
|
+
import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
|
|
32
35
|
import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
|
|
33
|
-
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
36
|
+
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
34
37
|
import { isPointWait, type WaitingPoint } from "./steward-points";
|
|
35
38
|
import { isGateWait } from "./gate-wait";
|
|
36
39
|
import { currentStewardTurn, enterStewardTurn } from "./steward-turn";
|
|
@@ -72,10 +75,12 @@ export interface StepRecord {
|
|
|
72
75
|
resolvedBy: string;
|
|
73
76
|
timestamp: string;
|
|
74
77
|
url?: string;
|
|
75
|
-
/** On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. */
|
|
76
|
-
via?: "quorum" | "policy";
|
|
78
|
+
/** On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. `"point"` on a gate that asks a decision point (#3170). */
|
|
79
|
+
via?: "quorum" | "policy" | "point";
|
|
77
80
|
/** On a gate with a quorum (#2508): every approver who counted toward it. */
|
|
78
81
|
approvers?: string[];
|
|
82
|
+
/** On a gate that asks a decision point (#3170): the answer record that passed it, which the gate ledger's resolution cites too. */
|
|
83
|
+
answer?: GateAnswerRef;
|
|
79
84
|
};
|
|
80
85
|
/**
|
|
81
86
|
* Why a step declined to proceed on something that is not a failure
|
|
@@ -489,6 +494,10 @@ interface GateContext {
|
|
|
489
494
|
passed?: PassedGate[];
|
|
490
495
|
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
491
496
|
work?: RunWorkLease;
|
|
497
|
+
/** How a gate that names a decision point asks it (#3170). */
|
|
498
|
+
points?: GatePointAsker;
|
|
499
|
+
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
500
|
+
env?: string;
|
|
492
501
|
}
|
|
493
502
|
|
|
494
503
|
/**
|
|
@@ -584,6 +593,13 @@ function resolveGateApproval(
|
|
|
584
593
|
if (resolved !== undefined && resolved !== null) context[key] = resolved;
|
|
585
594
|
}
|
|
586
595
|
}
|
|
596
|
+
// #3182: a policy also sees what the plan does. When the gate's `plan`
|
|
597
|
+
// names the step that planned it and that step returned a change set, its
|
|
598
|
+
// summary lands under `context.plan` with no context code in the Op.
|
|
599
|
+
if (authored.policy && isStepOutputRef(step.plan)) {
|
|
600
|
+
const summary = gatePlanSummaryOfResult(resultsById.get(step.plan.step));
|
|
601
|
+
if (summary) context = { ...(context ?? {}), [GATE_PLAN_CONTEXT_KEY]: summary };
|
|
602
|
+
}
|
|
587
603
|
return {
|
|
588
604
|
...(authored.quorum ? { quorum: authored.quorum } : {}),
|
|
589
605
|
...(authored.policy ? { policy: authored.policy } : {}),
|
|
@@ -604,6 +620,7 @@ async function runGateStep(
|
|
|
604
620
|
gates: GateContext,
|
|
605
621
|
resultsById: ReadonlyMap<string, unknown> = new Map(),
|
|
606
622
|
): Promise<{ record: StepRecord; pending?: PendingGateRecord; pushed?: boolean; pushWarning?: string }> {
|
|
623
|
+
if (step.point !== undefined) return runPointGateStep(step, phaseName, gates, resultsById);
|
|
607
624
|
const start = Date.now();
|
|
608
625
|
// #2300: `plan` is authored as a reference into the Plan phase's result
|
|
609
626
|
// (`plan.out.planDigest`), and is resolved here through the same walk an
|
|
@@ -689,6 +706,57 @@ async function runGateStep(
|
|
|
689
706
|
};
|
|
690
707
|
}
|
|
691
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Decide a gate that asks a decision point (#3170, `./gate-point-run.ts`).
|
|
711
|
+
* An answer it passes on lands on the step record as the approval, citing
|
|
712
|
+
* the answer record; an open question puts the question on the record, as an
|
|
713
|
+
* activity's open point does, and the caller ends the run `waiting`; any
|
|
714
|
+
* other answer fails the step.
|
|
715
|
+
*/
|
|
716
|
+
async function runPointGateStep(
|
|
717
|
+
step: GateStep,
|
|
718
|
+
phaseName: string,
|
|
719
|
+
gates: GateContext,
|
|
720
|
+
resultsById: ReadonlyMap<string, unknown>,
|
|
721
|
+
): Promise<{ record: StepRecord; pushed?: boolean; pushWarning?: string }> {
|
|
722
|
+
const start = Date.now();
|
|
723
|
+
const point = gatePointOf(step.point)!;
|
|
724
|
+
const base = { phase: phaseName, fn: gateFn(step), args: {} };
|
|
725
|
+
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
726
|
+
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
727
|
+
const inputs = point.inputs ? (resolveStepOutputRefs(point.inputs, resultsById) as Record<string, unknown>) : undefined;
|
|
728
|
+
try {
|
|
729
|
+
const check = await evaluatePointGate(gates.port, gates.points ?? workspaceGatePointAsker(process.cwd()), {
|
|
730
|
+
op: gates.op,
|
|
731
|
+
gate: gateName(step),
|
|
732
|
+
point,
|
|
733
|
+
...(inputs ? { inputs } : {}),
|
|
734
|
+
...(step.description ? { description: step.description } : {}),
|
|
735
|
+
...(step.timeout ? { timeout: step.timeout } : {}),
|
|
736
|
+
...(gates.runId ? { runId: gates.runId } : {}),
|
|
737
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
738
|
+
...(gates.env !== undefined ? { env: gates.env } : {}),
|
|
739
|
+
...(gates.now ? { now: gates.now } : {}),
|
|
740
|
+
});
|
|
741
|
+
if (check.satisfied) {
|
|
742
|
+
const approval: NonNullable<StepRecord["approval"]> = {
|
|
743
|
+
gate: gateName(step),
|
|
744
|
+
resolvedBy: check.resolution.resolvedBy,
|
|
745
|
+
timestamp: check.resolution.timestamp,
|
|
746
|
+
via: "point",
|
|
747
|
+
...(check.answer.answeredBy ? { approvers: check.answer.answeredBy } : {}),
|
|
748
|
+
answer: check.answer,
|
|
749
|
+
};
|
|
750
|
+
gates.passed?.push({ gate: gateName(step), approval });
|
|
751
|
+
return { record: { ...base, status: "ok", durationMs: Date.now() - start, approval }, pushed: check.pushed, ...(check.pushWarning ? { pushWarning: check.pushWarning } : {}) };
|
|
752
|
+
}
|
|
753
|
+
if ("refused" in check) return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: check.refused } };
|
|
754
|
+
return { record: { ...base, status: "skipped", durationMs: Date.now() - start, point: check.waiting }, pushed: check.pushed, ...(check.pushWarning ? { pushWarning: check.pushWarning } : {}) };
|
|
755
|
+
} catch (err) {
|
|
756
|
+
return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(err) } };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
692
760
|
/**
|
|
693
761
|
* Run one effect step: read-compare-run-write. On a match the nested steps are
|
|
694
762
|
* recorded as skipped ("effect already applied") and nothing is written. On a
|
|
@@ -778,6 +846,11 @@ async function runEffectStep(
|
|
|
778
846
|
skipRest(i + 1);
|
|
779
847
|
return { records, failed: false, pending, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
780
848
|
}
|
|
849
|
+
if (record.point) {
|
|
850
|
+
// A gate's open decision point (#3170): as for an activity's.
|
|
851
|
+
skipRest(i + 1);
|
|
852
|
+
return { records, failed: false, point: record.point };
|
|
853
|
+
}
|
|
781
854
|
continue;
|
|
782
855
|
}
|
|
783
856
|
const ran = await runStep(nested, phaseName, activities, profiles, resultsById, signal);
|
|
@@ -866,6 +939,13 @@ async function runPhase(
|
|
|
866
939
|
}
|
|
867
940
|
throw new GateStop(gateRecords, pending, phase.name, pushed, pushWarning);
|
|
868
941
|
}
|
|
942
|
+
if (record.point) {
|
|
943
|
+
// A gate's open decision point (#3170): nothing fans out.
|
|
944
|
+
for (const skipped of phase.steps.filter(isActivity)) {
|
|
945
|
+
pushRecord(gateRecords, gates, skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
946
|
+
}
|
|
947
|
+
throw new PointStop(gateRecords, record.point, phase.name);
|
|
948
|
+
}
|
|
869
949
|
}
|
|
870
950
|
const steps = phase.steps.filter(isActivity);
|
|
871
951
|
const settled = await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)));
|
|
@@ -921,6 +1001,12 @@ async function runPhase(
|
|
|
921
1001
|
skipRemaining(i + 1);
|
|
922
1002
|
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
923
1003
|
}
|
|
1004
|
+
if (record.point) {
|
|
1005
|
+
// A gate that asks a decision point (#3170) and found it open: the
|
|
1006
|
+
// run waits on the question, as at an activity's open point.
|
|
1007
|
+
skipRemaining(i + 1);
|
|
1008
|
+
throw new PointStop(records, record.point, phase.name);
|
|
1009
|
+
}
|
|
924
1010
|
continue;
|
|
925
1011
|
}
|
|
926
1012
|
if (isEffect(step)) {
|
|
@@ -980,6 +1066,11 @@ export interface RunOpOptions {
|
|
|
980
1066
|
now?: string;
|
|
981
1067
|
/** Identifies this run on any pending fact it records. */
|
|
982
1068
|
runId?: string;
|
|
1069
|
+
/**
|
|
1070
|
+
* How a gate that names a decision point asks it (#3170). Defaults to the
|
|
1071
|
+
* workspace holding `cwd` (`workspaceGatePointAsker`); a test passes a stub.
|
|
1072
|
+
*/
|
|
1073
|
+
points?: GatePointAsker;
|
|
983
1074
|
/**
|
|
984
1075
|
* The environment the run was started for (`--env`, #2522). The executor
|
|
985
1076
|
* only hands it to activities through the run context (`./run-context.ts`).
|
|
@@ -1102,6 +1193,8 @@ async function runOpInTurn(
|
|
|
1102
1193
|
runId,
|
|
1103
1194
|
...(options.onRecord ? { onRecord: options.onRecord } : {}),
|
|
1104
1195
|
passed: run.passedGates,
|
|
1196
|
+
points: options.points ?? workspaceGatePointAsker(options.cwd ?? process.cwd()),
|
|
1197
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
1105
1198
|
};
|
|
1106
1199
|
|
|
1107
1200
|
// Effect steps are ordered (read-compare-run-write): refuse them in a
|
package/src/op/op-ir.ts
CHANGED
|
@@ -149,6 +149,8 @@ export interface OpIRGateStep {
|
|
|
149
149
|
plan?: GateStep["plan"];
|
|
150
150
|
/** Quorum, roles and policy (#2508), carried as authored. Absent on a gate that passes on one approval. */
|
|
151
151
|
approval?: GateStep["approval"];
|
|
152
|
+
/** The decision point the gate asks (#3170), carried as authored. Absent on a gate that `chant approve` passes. */
|
|
153
|
+
point?: GateStep["point"];
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
export interface OpIREffectStep {
|
|
@@ -258,6 +260,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
258
260
|
...(step.description ? { description: step.description } : {}),
|
|
259
261
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
260
262
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
263
|
+
...(step.point !== undefined ? { point: step.point } : {}),
|
|
261
264
|
};
|
|
262
265
|
}
|
|
263
266
|
|
|
@@ -412,6 +415,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
412
415
|
...(step.description ? { description: step.description } : {}),
|
|
413
416
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
414
417
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
418
|
+
...(step.point !== undefined ? { point: step.point } : {}),
|
|
415
419
|
};
|
|
416
420
|
}
|
|
417
421
|
return {
|
package/src/op/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { EffectReceiptRef } from "./receipt-store";
|
|
|
9
9
|
import type { ActivityProfileName } from "./activity-profiles";
|
|
10
10
|
import type { StepOutputRef } from "./step-output-ref";
|
|
11
11
|
import type { GateApproval } from "./gate-approval";
|
|
12
|
+
import type { GatePoint } from "./gate-point";
|
|
12
13
|
|
|
13
14
|
export interface OpConfig {
|
|
14
15
|
/** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
|
|
@@ -276,6 +277,16 @@ export interface GateStepBase {
|
|
|
276
277
|
* `./gate-approval.ts`.
|
|
277
278
|
*/
|
|
278
279
|
approval?: GateApproval;
|
|
280
|
+
/**
|
|
281
|
+
* A declared decision point this gate asks (#3170), by name or as a
|
|
282
|
+
* {@link GatePoint}. Reaching the gate asks the point: its deciders and
|
|
283
|
+
* quorum decide, the answer is an answer record, and `chant workspace
|
|
284
|
+
* points answer` answers an open question. The run passes the gate on an
|
|
285
|
+
* answer in `pass`, and the resolution it writes cites the record. A gate
|
|
286
|
+
* with a point takes no `approval`: the point's quorum is the gate's. See
|
|
287
|
+
* `./gate-point.ts`.
|
|
288
|
+
*/
|
|
289
|
+
point?: string | GatePoint;
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
/**
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
checkProjectCodegen,
|
|
7
|
+
generateProjectCode,
|
|
8
|
+
GENERATED_TS_HEADER,
|
|
9
|
+
prepareProjectCodegen,
|
|
10
|
+
readStamp,
|
|
11
|
+
resolveCodegenRoot,
|
|
12
|
+
STAMP_FILE,
|
|
13
|
+
type ProjectCodegen,
|
|
14
|
+
type ProjectCodegenPlugin,
|
|
15
|
+
} from "./project-codegen";
|
|
16
|
+
import { collectBuildRootContributors } from "./cli/plugins";
|
|
17
|
+
import { mergeBuildRootEntities } from "./build";
|
|
18
|
+
import { GENERATED_MARKER } from "./discovery/files";
|
|
19
|
+
import { runGenerate } from "./cli/handlers/generate";
|
|
20
|
+
import type { LexiconPlugin } from "./lexicon";
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = [];
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function project(): string {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-project-codegen-"));
|
|
29
|
+
dirs.push(dir);
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A lexicon whose declared input is one local file, `widgets.txt`, named by
|
|
35
|
+
* `config.fake.source`. Each line becomes a generated file.
|
|
36
|
+
*/
|
|
37
|
+
function fakePlugin(onLoad?: () => void): ProjectCodegenPlugin {
|
|
38
|
+
const codegen: ProjectCodegen = {
|
|
39
|
+
inputs(ctx) {
|
|
40
|
+
const source = (ctx.config.fake as { source?: string } | undefined)?.source;
|
|
41
|
+
if (!source) return undefined;
|
|
42
|
+
return { source, content: readFileSync(join(ctx.projectRoot, source), "utf8") };
|
|
43
|
+
},
|
|
44
|
+
async generate(ctx) {
|
|
45
|
+
const source = (ctx.config.fake as { source: string }).source;
|
|
46
|
+
const names = readFileSync(join(ctx.projectRoot, source), "utf8").split("\n").filter(Boolean);
|
|
47
|
+
const files: Record<string, string> = { "index.ts": `${GENERATED_TS_HEADER}\nexport const names = ${JSON.stringify(names)};\n` };
|
|
48
|
+
for (const n of names) files[`parts/${n}.ts`] = `export const ${n} = 1;\n`;
|
|
49
|
+
return { files, summary: names };
|
|
50
|
+
},
|
|
51
|
+
load: onLoad,
|
|
52
|
+
};
|
|
53
|
+
return { name: "fake", projectCodegen: () => codegen };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("project codegen", () => {
|
|
57
|
+
test("the generated header carries discovery's generated-file marker", () => {
|
|
58
|
+
expect(GENERATED_TS_HEADER).toContain(GENERATED_MARKER);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("outDir defaults to src/generated and follows codegen.outDir", () => {
|
|
62
|
+
expect(resolveCodegenRoot("/p", {})).toBe("/p/src/generated");
|
|
63
|
+
expect(resolveCodegenRoot("/p", { codegen: { outDir: "gen" } })).toBe("/p/gen");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("generate writes the files and a stamp; the check is then current", async () => {
|
|
67
|
+
const root = project();
|
|
68
|
+
writeFileSync(join(root, "widgets.txt"), "a\nb\n");
|
|
69
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
70
|
+
const plugin = fakePlugin();
|
|
71
|
+
|
|
72
|
+
const result = await generateProjectCode(plugin, root, config);
|
|
73
|
+
expect(result?.status).toBe("written");
|
|
74
|
+
expect(result?.files).toEqual(["index.ts", "parts/a.ts", "parts/b.ts"]);
|
|
75
|
+
const outDir = join(root, "src/generated/fake");
|
|
76
|
+
expect(readFileSync(join(outDir, "index.ts"), "utf8")).toContain('["a","b"]');
|
|
77
|
+
expect(readStamp(outDir)?.inputs).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
78
|
+
|
|
79
|
+
expect(await checkProjectCodegen(plugin, root, config)).toEqual({ status: "current" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("editing a declared source without regenerating is drift", async () => {
|
|
83
|
+
const root = project();
|
|
84
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
85
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
86
|
+
const plugin = fakePlugin();
|
|
87
|
+
await generateProjectCode(plugin, root, config);
|
|
88
|
+
|
|
89
|
+
writeFileSync(join(root, "widgets.txt"), "a\nc\n");
|
|
90
|
+
const check = await checkProjectCodegen(plugin, root, config);
|
|
91
|
+
expect(check.status).toBe("drift");
|
|
92
|
+
expect("message" in check && check.message).toMatch(/src\/generated\/fake is out of date.*Run `chant generate`/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("declared sources with no output, or a recorded file deleted, is missing", async () => {
|
|
96
|
+
const root = project();
|
|
97
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
98
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
99
|
+
const plugin = fakePlugin();
|
|
100
|
+
expect((await checkProjectCodegen(plugin, root, config)).status).toBe("missing");
|
|
101
|
+
|
|
102
|
+
await generateProjectCode(plugin, root, config);
|
|
103
|
+
rmSync(join(root, "src/generated/fake/parts/a.ts"));
|
|
104
|
+
const check = await checkProjectCodegen(plugin, root, config);
|
|
105
|
+
expect(check.status).toBe("missing");
|
|
106
|
+
expect("message" in check && check.message).toContain("parts/a.ts");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("regenerating deletes files the previous run wrote and this one did not", async () => {
|
|
110
|
+
const root = project();
|
|
111
|
+
writeFileSync(join(root, "widgets.txt"), "a\nb\n");
|
|
112
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
113
|
+
const plugin = fakePlugin();
|
|
114
|
+
await generateProjectCode(plugin, root, config);
|
|
115
|
+
writeFileSync(join(root, "src/generated/fake/hand-written.ts"), "// mine\n");
|
|
116
|
+
|
|
117
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
118
|
+
await generateProjectCode(plugin, root, config);
|
|
119
|
+
expect(existsSync(join(root, "src/generated/fake/parts/b.ts"))).toBe(false);
|
|
120
|
+
expect(existsSync(join(root, "src/generated/fake/parts/a.ts"))).toBe(true);
|
|
121
|
+
// Only files a run recorded are ever removed.
|
|
122
|
+
expect(existsSync(join(root, "src/generated/fake/hand-written.ts"))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("with nothing declared, leftover output is an orphan and generate removes it", async () => {
|
|
126
|
+
const root = project();
|
|
127
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
128
|
+
const plugin = fakePlugin();
|
|
129
|
+
await generateProjectCode(plugin, root, { fake: { source: "widgets.txt" } });
|
|
130
|
+
|
|
131
|
+
expect((await checkProjectCodegen(plugin, root, {})).status).toBe("orphan");
|
|
132
|
+
const result = await generateProjectCode(plugin, root, {});
|
|
133
|
+
expect(result?.status).toBe("removed");
|
|
134
|
+
expect(existsSync(join(root, "src/generated/fake"))).toBe(false);
|
|
135
|
+
expect(await checkProjectCodegen(plugin, root, {})).toEqual({ status: "none" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("prepare loads current output and throws on drift", async () => {
|
|
139
|
+
const root = project();
|
|
140
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
141
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
142
|
+
const load = vi.fn();
|
|
143
|
+
const plugin = fakePlugin(load);
|
|
144
|
+
await generateProjectCode(plugin, root, config);
|
|
145
|
+
|
|
146
|
+
await prepareProjectCodegen(plugin, root, config);
|
|
147
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
148
|
+
|
|
149
|
+
writeFileSync(join(root, "widgets.txt"), "b\n");
|
|
150
|
+
await expect(prepareProjectCodegen(plugin, root, config)).rejects.toThrow(/out of date/);
|
|
151
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the build-root contributors carry the check, so a drifted build reports an error", async () => {
|
|
155
|
+
const root = project();
|
|
156
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
157
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
158
|
+
const plugin = fakePlugin() as unknown as LexiconPlugin;
|
|
159
|
+
await generateProjectCode(plugin, root, config);
|
|
160
|
+
|
|
161
|
+
const current = await mergeBuildRootEntities(new Map(), collectBuildRootContributors([plugin], config, root));
|
|
162
|
+
expect(current.errors).toEqual([]);
|
|
163
|
+
|
|
164
|
+
writeFileSync(join(root, "widgets.txt"), "b\n");
|
|
165
|
+
const drifted = await mergeBuildRootEntities(new Map(), collectBuildRootContributors([plugin], config, root));
|
|
166
|
+
expect(drifted.errors).toHaveLength(1);
|
|
167
|
+
expect(drifted.errors[0]).toMatch(/out of date/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("chant generate", () => {
|
|
172
|
+
function args(path: string, extra: Record<string, unknown> = {}) {
|
|
173
|
+
return { command: "generate", path, ...extra } as never;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("writes each lexicon's code, and --check reports drift with exit 1", async () => {
|
|
177
|
+
const root = project();
|
|
178
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ fake: { source: "widgets.txt" } }));
|
|
179
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
180
|
+
const plugins = [fakePlugin() as unknown as LexiconPlugin];
|
|
181
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
182
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
183
|
+
try {
|
|
184
|
+
expect(await runGenerate({ args: args(root), plugins, serializers: [] })).toBe(0);
|
|
185
|
+
expect(existsSync(join(root, "src/generated/fake", STAMP_FILE))).toBe(true);
|
|
186
|
+
expect(await runGenerate({ args: args(root, { check: true }), plugins, serializers: [] })).toBe(0);
|
|
187
|
+
|
|
188
|
+
writeFileSync(join(root, "widgets.txt"), "z\n");
|
|
189
|
+
expect(await runGenerate({ args: args(root, { check: true }), plugins, serializers: [] })).toBe(1);
|
|
190
|
+
expect(err.mock.calls.flat().join("\n")).toMatch(/out of date/);
|
|
191
|
+
} finally {
|
|
192
|
+
log.mockRestore();
|
|
193
|
+
err.mockRestore();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("--lexicon naming a lexicon with no project codegen fails", async () => {
|
|
198
|
+
const root = project();
|
|
199
|
+
writeFileSync(join(root, "chant.config.json"), "{}");
|
|
200
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
201
|
+
try {
|
|
202
|
+
const code = await runGenerate({ args: args(root, { lexicon: "aws" }), plugins: [fakePlugin() as unknown as LexiconPlugin], serializers: [] });
|
|
203
|
+
expect(code).toBe(1);
|
|
204
|
+
} finally {
|
|
205
|
+
err.mockRestore();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|