@intentius/chant 0.109.0 → 0.111.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.
Files changed (83) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/discovery/fold-import.d.ts.map +1 -1
  3. package/dist/discovery/param-deps.d.ts +30 -2
  4. package/dist/discovery/param-deps.d.ts.map +1 -1
  5. package/dist/fold/subset.d.ts +9 -2
  6. package/dist/fold/subset.d.ts.map +1 -1
  7. package/dist/fold-provenance.d.ts +14 -2
  8. package/dist/fold-provenance.d.ts.map +1 -1
  9. package/dist/import/ir-utils.d.ts.map +1 -1
  10. package/dist/import/parser.d.ts +8 -0
  11. package/dist/import/parser.d.ts.map +1 -1
  12. package/dist/op/activities/factory.d.ts.map +1 -1
  13. package/dist/provenance.d.ts +11 -0
  14. package/dist/provenance.d.ts.map +1 -1
  15. package/dist/serializer-walker.d.ts.map +1 -1
  16. package/dist/stack-output.d.ts +5 -0
  17. package/dist/stack-output.d.ts.map +1 -1
  18. package/dist/workspace/box-cli.d.ts +3 -2
  19. package/dist/workspace/box-cli.d.ts.map +1 -1
  20. package/dist/workspace/box-factory-write.d.ts +71 -0
  21. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  22. package/dist/workspace/box-intent.d.ts +6 -0
  23. package/dist/workspace/box-intent.d.ts.map +1 -1
  24. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  25. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  26. package/dist/workspace/declaration-write.d.ts +78 -0
  27. package/dist/workspace/declaration-write.d.ts.map +1 -0
  28. package/dist/workspace/declaration.d.ts +8 -0
  29. package/dist/workspace/declaration.d.ts.map +1 -1
  30. package/dist/workspace/json-edit.d.ts +11 -0
  31. package/dist/workspace/json-edit.d.ts.map +1 -1
  32. package/dist/workspace/ls.d.ts +8 -0
  33. package/dist/workspace/ls.d.ts.map +1 -1
  34. package/dist/workspace/reason-codes.d.ts +4 -0
  35. package/dist/workspace/reason-codes.d.ts.map +1 -1
  36. package/dist/workspace/status.d.ts.map +1 -1
  37. package/dist/workspace/write-scope.d.ts +10 -0
  38. package/dist/workspace/write-scope.d.ts.map +1 -1
  39. package/dist/yaml.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/commands/import.test.ts +5 -4
  42. package/src/cli/main.test.ts +2 -0
  43. package/src/cli/main.ts +18 -0
  44. package/src/discovery/fold-composite.test.ts +22 -0
  45. package/src/discovery/fold-host-composite.test.ts +23 -0
  46. package/src/discovery/fold-import.ts +73 -21
  47. package/src/discovery/fold-project-provenance.test.ts +52 -0
  48. package/src/discovery/param-deps.test.ts +43 -2
  49. package/src/discovery/param-deps.ts +101 -2
  50. package/src/fold/subset.ts +9 -2
  51. package/src/fold-provenance.test.ts +11 -0
  52. package/src/fold-provenance.ts +22 -2
  53. package/src/import/ir-utils.ts +6 -1
  54. package/src/import/parser.ts +8 -0
  55. package/src/op/activities/factory.ts +3 -1
  56. package/src/op/factory.e2e.test.ts +3 -1
  57. package/src/provenance.ts +13 -1
  58. package/src/serializer-walker.test.ts +10 -0
  59. package/src/serializer-walker.ts +17 -1
  60. package/src/stack-output.ts +6 -1
  61. package/src/workspace/box-cli.ts +35 -4
  62. package/src/workspace/box-factory-write.schema.json +342 -0
  63. package/src/workspace/box-factory-write.test.ts +108 -0
  64. package/src/workspace/box-factory-write.ts +226 -0
  65. package/src/workspace/box-intent.test.ts +14 -4
  66. package/src/workspace/box-intent.ts +9 -1
  67. package/src/workspace/declaration-extensions.test.ts +79 -0
  68. package/src/workspace/declaration-write-cli.ts +82 -0
  69. package/src/workspace/declaration-write.test.ts +155 -0
  70. package/src/workspace/declaration-write.ts +275 -0
  71. package/src/workspace/declaration.ts +19 -1
  72. package/src/workspace/json-edit.ts +71 -0
  73. package/src/workspace/ls-contract.test.ts +53 -0
  74. package/src/workspace/ls.schema.json +11 -0
  75. package/src/workspace/ls.ts +18 -0
  76. package/src/workspace/member-write.schema.json +193 -0
  77. package/src/workspace/reason-codes.test.ts +4 -0
  78. package/src/workspace/reason-codes.ts +6 -0
  79. package/src/workspace/status.schema.json +10 -1
  80. package/src/workspace/status.ts +25 -6
  81. package/src/workspace/write-scope.ts +17 -0
  82. package/src/yaml.test.ts +6 -0
  83. package/src/yaml.ts +4 -0
@@ -530,3 +530,55 @@ describe("a member destructured from a called chant composite is unknown (chant#
530
530
  expectAllUnknown(built.foldProvenance, ["namespace", "resourceQuota", "sharedNamespace", "sharedResourceQuota"]);
531
531
  });
532
532
  });
533
+
534
+ describe("a value host code produced is unknown, never direct (typescript-as-data#248)", () => {
535
+ let testDir: string;
536
+ let srcDir: string;
537
+ let file: string;
538
+
539
+ beforeAll(async () => {
540
+ const dir = join(repoRoot, ".cache", `chant-tsad-248-${process.pid}`);
541
+ await rm(dir, { recursive: true, force: true });
542
+ await mkdir(join(dir, "src"), { recursive: true });
543
+ testDir = await realpath(dir);
544
+ srcDir = join(testDir, "src");
545
+ file = join(srcDir, "main.ts");
546
+ await writeFile(
547
+ file,
548
+ `
549
+ import { Bucket, AWS } from ${JSON.stringify(LEXICON)};
550
+ const upper = "logs".toUpperCase();
551
+ export const bucket = new Bucket({
552
+ BucketName: "x".toUpperCase(),
553
+ Tags: [{ Key: "n", Value: [1, 2].join("-") }],
554
+ ObjectLockEnabled: true,
555
+ });
556
+ export const logs = new Bucket({ BucketName: upper, AccelerateConfiguration: { AccelerationStatus: AWS.Region } });
557
+ `,
558
+ );
559
+ });
560
+
561
+ afterAll(async () => {
562
+ await rm(testDir, { recursive: true, force: true });
563
+ });
564
+
565
+ test("a host call and a host value report unknown with the reference's reasons, as build() does", async () => {
566
+ const verdict = (await foldProject([file], [], { lexicons: [LEXICON_NAME] })).get(file)!;
567
+ expect(verdict.verdict).toBe("fold");
568
+ const fromFold = verdict.foldProvenance!;
569
+ expect(fromFold.bucket.fields).toEqual({
570
+ BucketName: { kind: "unknown", reason: "host-call" },
571
+ ObjectLockEnabled: { kind: "direct" },
572
+ Tags: { kind: "unknown", reason: "host-call" },
573
+ });
574
+ expect(fromFold.logs.fields).toEqual({
575
+ "AccelerateConfiguration.AccelerationStatus": { kind: "unknown", reason: "host-value" },
576
+ BucketName: { kind: "unknown", reason: "host-call" },
577
+ });
578
+
579
+ const built = await build(srcDir, [namesSerializer], undefined, { fold: true, lexicons: [LEXICON_NAME] });
580
+ expect(built.errors).toEqual([]);
581
+ expect(built.foldProvenance.bucket).toEqual(fromFold.bucket);
582
+ expect(built.foldProvenance.logs).toEqual(fromFold.logs);
583
+ });
584
+ });
@@ -7,6 +7,7 @@ import {
7
7
  collectParamDependencies,
8
8
  collectTagOrigins,
9
9
  type CompositeCallSite,
10
+ type HostBindings,
10
11
  } from "./param-deps";
11
12
  import type { PathOrigin } from "../provenance";
12
13
 
@@ -14,7 +15,7 @@ import type { PathOrigin } from "../provenance";
14
15
  * Collect dependencies for the props of the file's single
15
16
  * `export const x = new Type({...})`, which is the shape every case here uses.
16
17
  */
17
- function depsOf(source: string, paramLocals = ["params"]): Record<string, PathOrigin> {
18
+ function depsOf(source: string, paramLocals = ["params"], host?: HostBindings): Record<string, PathOrigin> {
18
19
  const file = ts.createSourceFile("fixture.ts", source, ts.ScriptTarget.Latest, true);
19
20
  const consts = collectConsts(file);
20
21
  let props: ts.ObjectLiteralExpression | undefined;
@@ -32,7 +33,7 @@ function depsOf(source: string, paramLocals = ["params"]): Record<string, PathOr
32
33
  }
33
34
  }
34
35
  if (!props) throw new Error("fixture has no `const x = new Type({...})`");
35
- return collectParamDependencies(props, consts, new Set(paramLocals));
36
+ return collectParamDependencies(props, consts, new Set(paramLocals), host);
36
37
  }
37
38
 
38
39
  const param = (...names: string[]): PathOrigin => ({ kind: "build-param", params: names });
@@ -411,3 +412,43 @@ describe("argumentLocation (#3597)", () => {
411
412
  });
412
413
  });
413
414
  });
415
+
416
+ describe("collectParamDependencies with host bindings (typescript-as-data#248)", () => {
417
+ const host: HostBindings = { values: new Set(["AWS"]), eagerCalls: new Set(["matrix"]) };
418
+ const hostCall: PathOrigin = { kind: "host", reason: "host-call" };
419
+ const hostValue: PathOrigin = { kind: "host", reason: "host-value" };
420
+
421
+ test("a method call on a receiver is a host call, at its own path", () => {
422
+ const source = `export const x = new Thing({ name: "x".toUpperCase(), spec: { tag: [1, 2].join("-") }, fixed: "k" });`;
423
+ expect(depsOf(source, [], host)).toEqual({ name: hostCall, "spec.tag": hostCall });
424
+ });
425
+
426
+ test("an eager lexicon helper is a host call, and a project function is not", () => {
427
+ const source = `export const x = new Thing({ a: matrix("os"), b: helper("os") });`;
428
+ expect(depsOf(source, [], host)).toEqual({ a: hostCall });
429
+ });
430
+
431
+ test("a lexicon export is a host value, through a const and an operator", () => {
432
+ const source = [`const region = AWS.Region;`, "export const x = new Thing({ a: `${region}-x`, b: AWS });"].join("\n");
433
+ expect(depsOf(source, [], host)).toEqual({ a: hostValue, b: hostValue });
434
+ });
435
+
436
+ test("a host read outranks a build parameter the same expression reads", () => {
437
+ const source = `export const x = new Thing({ name: params.name.toUpperCase(), size: params.size });`;
438
+ expect(depsOf(source, ["params"], host)).toEqual({ name: hostCall, size: param("size") });
439
+ });
440
+
441
+ test("a sibling resource, a member name and a property key are not host reads", () => {
442
+ const source = [
443
+ `const logs = new Bucket({ name: "x".toUpperCase() });`,
444
+ `const AWS2 = 1;`,
445
+ `export const x = new Thing({ a: logs.Arn, b: { AWS: 1 }, c: thing.AWS });`,
446
+ ].join("\n");
447
+ expect(depsOf(source, [], host)).toEqual({});
448
+ });
449
+
450
+ test("a tag is not read, its interpolations are", () => {
451
+ const source = "export const x = new Thing({ a: Sub`${AWS.Region}`, b: Sub`plain` });";
452
+ expect(depsOf(source, [], { values: new Set(["AWS", "Sub"]), eagerCalls: new Set() })).toEqual({ a: hostValue });
453
+ });
454
+ });
@@ -1,5 +1,5 @@
1
1
  import * as ts from "typescript";
2
- import type { ArgumentLocation, PathOrigin, SourceLocation } from "../provenance";
2
+ import type { ArgumentLocation, HostOriginReason, PathOrigin, SourceLocation } from "../provenance";
3
3
 
4
4
  /**
5
5
  * Which build parameters an authored props expression READS, per path (chant
@@ -117,6 +117,94 @@ function readParams(
117
117
  visit(expr);
118
118
  }
119
119
 
120
+ /**
121
+ * What a file binds to host code, for {@link hostReadOf} (INTENTIUS/typescript-as-data#248).
122
+ */
123
+ export interface HostBindings {
124
+ /** Local names bound to a value a lexicon package exports. */
125
+ values: ReadonlySet<string>;
126
+ /** Local names of eager lexicon helpers: a call to one is evaluated by host code at fold time. */
127
+ eagerCalls: ReadonlySet<string>;
128
+ }
129
+
130
+ /**
131
+ * The first host read in `expr`, following this file's own top-level consts,
132
+ * or `undefined` when it reads none (INTENTIUS/typescript-as-data#248).
133
+ *
134
+ * A method called on a receiver is `host-call` whatever the receiver is, since
135
+ * fold calls the real method on it; so is a call to an eager lexicon helper. An
136
+ * identifier bound to a lexicon export is `host-value`. A call fold reduces to
137
+ * an envelope (an authoring helper, a call-form intrinsic) or evaluates itself
138
+ * (a project function) is not host code, and only its arguments are read.
139
+ *
140
+ * Syntactic, like {@link readParams}, so it over-reports in one place: both
141
+ * arms of a conditional are read although only one is taken. That errs toward
142
+ * `unknown`, which never licenses an edit `direct` would not.
143
+ */
144
+ export function hostReadOf(
145
+ expr: ts.Expression,
146
+ consts: ReadonlyMap<string, ts.Expression>,
147
+ paramLocals: ReadonlySet<string>,
148
+ host: HostBindings,
149
+ ): HostOriginReason | undefined {
150
+ const followed = new Set<string>();
151
+ let found: HostOriginReason | undefined;
152
+
153
+ const visit = (node: ts.Node): void => {
154
+ if (found) return;
155
+ if (ts.isCallExpression(node)) {
156
+ const callee = unwrap(node.expression);
157
+ if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) {
158
+ found = "host-call";
159
+ return;
160
+ }
161
+ if (ts.isIdentifier(callee) && !consts.has(callee.text) && host.eagerCalls.has(callee.text)) {
162
+ found = "host-call";
163
+ return;
164
+ }
165
+ for (const argument of node.arguments) visit(argument);
166
+ return;
167
+ }
168
+
169
+ if (ts.isPropertyAccessExpression(node)) {
170
+ // The member name is not a reference.
171
+ visit(node.expression);
172
+ return;
173
+ }
174
+
175
+ if (ts.isIdentifier(node)) {
176
+ if (paramLocals.has(node.text)) return;
177
+ const initializer = consts.get(node.text);
178
+ if (initializer) {
179
+ // A sibling resource is a reference to it, not a read of its props.
180
+ if (ts.isNewExpression(unwrap(initializer)) || followed.has(node.text)) return;
181
+ followed.add(node.text);
182
+ visit(initializer);
183
+ return;
184
+ }
185
+ if (host.values.has(node.text)) found = "host-value";
186
+ return;
187
+ }
188
+
189
+ if (ts.isPropertyAssignment(node)) {
190
+ visit(node.initializer);
191
+ return;
192
+ }
193
+
194
+ // A tag is an intrinsic the lexicon registered, and only its
195
+ // interpolations are read.
196
+ if (ts.isTaggedTemplateExpression(node)) {
197
+ visit(node.template);
198
+ return;
199
+ }
200
+
201
+ ts.forEachChild(node, visit);
202
+ };
203
+
204
+ visit(expr);
205
+ return found;
206
+ }
207
+
120
208
  /**
121
209
  * Path → build-parameter origin for one resource's props object literal.
122
210
  *
@@ -129,16 +217,27 @@ function readParams(
129
217
  * A spread is attributed to the object it spreads INTO, at that object's own
130
218
  * path (the entity root for a top-level spread), since which keys it
131
219
  * contributes is not knowable here.
220
+ *
221
+ * With `host`, a path whose expression reads host code ({@link hostReadOf})
222
+ * is recorded `host` instead, and that outranks a build parameter it also
223
+ * reads: the value is what host code returned, whatever the parameter was.
132
224
  */
133
225
  export function collectParamDependencies(
134
226
  props: ts.ObjectLiteralExpression,
135
227
  consts: ReadonlyMap<string, ts.Expression>,
136
228
  paramLocals: ReadonlySet<string>,
229
+ host?: HostBindings,
137
230
  ): Record<string, PathOrigin> {
138
231
  const out: Record<string, PathOrigin> = {};
139
- if (paramLocals.size === 0) return out;
232
+ if (paramLocals.size === 0 && !host) return out;
140
233
 
141
234
  const record = (path: string, expr: ts.Expression): void => {
235
+ if (out[path]?.kind === "host") return;
236
+ const reason = host ? hostReadOf(expr, consts, paramLocals, host) : undefined;
237
+ if (reason) {
238
+ out[path] = { kind: "host", reason };
239
+ return;
240
+ }
142
241
  const found = new Set<string>();
143
242
  readParams(expr, consts, paramLocals, found);
144
243
  if (found.size === 0) return;
@@ -179,7 +179,7 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
179
179
  * `spec/VERSION` in the specification repository carries the same string, and
180
180
  * the conformance adapter reads this one to fill its `specVersion` field.
181
181
  *
182
- * ## Why 2.2, and what each version since 1.0 asked for (chant#2445)
182
+ * ## Why 2.3, and what each version since 1.0 asked for (chant#2445)
183
183
  *
184
184
  * Raising this is the last step of adopting a rule set, so each version was
185
185
  * checked against what chant does rather than against whether the suite is
@@ -228,6 +228,13 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
228
228
  * chant already did: a call-bound local exported by name (`export { x }`)
229
229
  * resolves by F-Call as the single declarator does, and a same-file call's
230
230
  * result read inside an F-Call argument resolves once per file (#3329).
231
+ * - **2.3** says a value host code produced, written at an export or into a
232
+ * field, has an unknown origin and never a direct one. chant reports
233
+ * `host-call` and `host-value` for resource fields since
234
+ * typescript-as-data#248. 2.3 also lets `F-Host-Composite` interpret a
235
+ * composite an active package publishes as TypeScript source, which chant
236
+ * has done since #3247, and adds fixtures for a call result read inside an
237
+ * argument, which chant passes since #3610.
231
238
  *
232
239
  * `scripts/check-docs-citations.ts` refuses to run when this constant and the
233
240
  * rules it loads disagree, so this and the `@intentius/tsad-conformance`
@@ -239,7 +246,7 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
239
246
  * Until chant#2470 the rules came from a commit `docs-check.yml` checked out;
240
247
  * now they come from the published package, so bumping it is what moves this.
241
248
  */
242
- export const SPEC_VERSION = "2.2";
249
+ export const SPEC_VERSION = "2.3";
243
250
 
244
251
  /** The two EVL rule ids a shape violation can be attributed to. */
245
252
  export type SubsetRuleId = "EVL001" | "EVL003";
@@ -103,6 +103,17 @@ describe("classifyFieldOrigin", () => {
103
103
  });
104
104
  });
105
105
 
106
+ test("a host origin is unknown with that reason, never a direct declaration (typescript-as-data#248)", () => {
107
+ expect(classifyFieldOrigin({ kind: "host", reason: "host-call" }, authored)).toEqual({
108
+ kind: "unknown",
109
+ reason: "host-call",
110
+ });
111
+ expect(classifyFieldOrigin({ kind: "host", reason: "host-value" }, authored)).toEqual({
112
+ kind: "unknown",
113
+ reason: "host-value",
114
+ });
115
+ });
116
+
106
117
  test("no origin on an entity no composite expanded is a direct declaration", () => {
107
118
  expect(classifyFieldOrigin(undefined, authored)).toEqual({ kind: "direct" });
108
119
  });
@@ -80,7 +80,11 @@ import {
80
80
  } from "./provenance";
81
81
  import type { DeepEntityDrift, PropertyDrift } from "./lifecycle/deep-diff";
82
82
 
83
- /** Why a field's origin could not be determined. Both are facts about the BUILD, not about the field. */
83
+ /**
84
+ * Why a field's origin could not be determined. The first two are facts about
85
+ * the BUILD; the host reasons are facts about the expression, and match the
86
+ * strings the specification's reference implementation reports.
87
+ */
84
88
  export type UnknownOriginReason =
85
89
  /**
86
90
  * A composite expanded the entity and this build did not interpret its body,
@@ -94,7 +98,15 @@ export type UnknownOriginReason =
94
98
  * composite make it" is answerable. A sandboxed child's entities arrive this
95
99
  * way: `discovery/entity-wire-codec.ts` drops build metadata over the wire.
96
100
  */
97
- | "no-provenance";
101
+ | "no-provenance"
102
+ /**
103
+ * INTENTIUS/typescript-as-data#248: host code computed the value, by a method
104
+ * called on a real receiver (`"x".toUpperCase()`, `[1, 2].join("-")`) or an
105
+ * eager lexicon helper. The file wrote the call, not the value.
106
+ */
107
+ | "host-call"
108
+ /** A value a lexicon package exports, bound by an import (`AWS.Region`, `S3Actions.GetObject`). */
109
+ | "host-value";
98
110
 
99
111
  /**
100
112
  * What produced one emitted property path.
@@ -231,6 +243,10 @@ export function classifyFieldOrigin(
231
243
  return provenance.composite
232
244
  ? { kind: "unknown", reason: "composite-not-interpreted" }
233
245
  : { kind: "direct" };
246
+ case "host":
247
+ // INTENTIUS/typescript-as-data#248 — host code produced the value, so
248
+ // editing the declaration edits a call, not the value it returned.
249
+ return { kind: "unknown", reason: origin.reason };
234
250
  case "build-param":
235
251
  // A build parameter governs the expression the AUTHOR wrote in their own
236
252
  // source, so the declaration is still theirs to edit.
@@ -307,6 +323,10 @@ export function unknownOriginText(reason: UnknownOriginReason): string {
307
323
  return "expanded by a composite whose factory this build did not interpret";
308
324
  case "no-provenance":
309
325
  return "this build recorded no provenance for the entity";
326
+ case "host-call":
327
+ return "the value is what a host call returned";
328
+ case "host-value":
329
+ return "the value is one a host package exports";
310
330
  }
311
331
  }
312
332
 
@@ -36,10 +36,15 @@ export function hasIntrinsicInValue(value: unknown, name: string): boolean {
36
36
  */
37
37
  export function irUsesIntrinsic(ir: TemplateIR, name: string): boolean {
38
38
  for (const resource of ir.resources) {
39
- if (hasIntrinsicInValue(resource.properties, name)) {
39
+ if (
40
+ hasIntrinsicInValue(resource.properties, name) ||
41
+ hasIntrinsicInValue(resource.metadata, name) ||
42
+ hasIntrinsicInValue(resource.attributes, name)
43
+ ) {
40
44
  return true;
41
45
  }
42
46
  }
47
+ if (hasIntrinsicInValue(ir.metadata, name)) return true;
43
48
  for (const condition of ir.conditions ?? []) {
44
49
  if (hasIntrinsicInValue(condition.expression, name)) {
45
50
  return true;
@@ -9,6 +9,9 @@ export interface ParameterIR {
9
9
  readonly description?: string;
10
10
  readonly defaultValue?: unknown;
11
11
  readonly required?: boolean;
12
+ /** What the value is checked against, under the source format's own keys
13
+ * and as written (CloudFormation `AllowedValues`, `MinLength`, `NoEcho`). */
14
+ readonly constraints?: Readonly<Record<string, unknown>>;
12
15
  }
13
16
 
14
17
  /**
@@ -22,6 +25,11 @@ export interface ResourceIR {
22
25
  /** Name of the condition gating this resource (CloudFormation `Condition`
23
26
  * key, or equivalent), when the source template declares one (#2069). */
24
27
  readonly condition?: string;
28
+ /** Resource-level attributes other than the condition, under the source
29
+ * format's own keys (CloudFormation `DependsOn`, `DeletionPolicy`,
30
+ * `UpdateReplacePolicy`, `UpdatePolicy`, `CreationPolicy`). Values are
31
+ * parsed value trees, like {@link properties}. */
32
+ readonly attributes?: Readonly<Record<string, unknown>>;
25
33
  }
26
34
 
27
35
  /**
@@ -22,6 +22,7 @@ import { spawn } from "node:child_process";
22
22
  import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
23
23
  import { dirname, join, relative, resolve, sep } from "node:path";
24
24
  import type { FactoryClaim, FactoryContext, FactoryItem } from "../../workspace/factory-rules";
25
+ import { liveRunEnv } from "../run-live";
25
26
 
26
27
  /** The run's work lease, as `workLeaseOutput()` hands it to a step. */
27
28
  export interface FactoryLease {
@@ -263,7 +264,8 @@ interface Ran {
263
264
  /** Run argv in cwd, without a shell, asynchronously. */
264
265
  function run(argv: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Promise<Ran> {
265
266
  return new Promise((settle) => {
266
- const child = spawn(argv[0], argv.slice(1), { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
267
+ // The run's activity file, as a shell step gets it, so a builder's lines reach its in-flight record (#3637).
268
+ const child = spawn(argv[0], argv.slice(1), { cwd, env: { ...process.env, ...liveRunEnv(), ...env }, stdio: ["ignore", "pipe", "pipe"] });
267
269
  let stdout = "";
268
270
  let stderr = "";
269
271
  child.stdout.on("data", (b: Buffer) => (stdout += b.toString("utf8")));
@@ -184,7 +184,7 @@ describe("the factory Op's seams for an orchestrator (studio#382)", () => {
184
184
  // A builder that reports a run and a check that reports per criterion.
185
185
  writeFileSync(join(dir, "hooks", "builder2.cjs"), `${BUILDER}\nconsole.log(JSON.stringify({ ok: true, run: { id: "run-7" }, agent: "builder-small", chantAgent: "factory", records: ["contract:C-1"] }));\n`);
186
186
  writeFileSync(join(dir, "hooks", "check2.cjs"), `console.log(JSON.stringify({ criteria: { "AC-1": "pass", "AC-2": "fail" }, built: JSON.parse(process.env.FACTORY_BUILD).agent }));`);
187
- writeFileSync(join(dir, "hooks", "after.cjs"), `require("node:fs").writeFileSync(require("node:path").join(process.env.FACTORY_FAIL_DIR, "after.json"), JSON.stringify({ outcome: process.env.FACTORY_OUTCOME, commit: process.env.FACTORY_COMMIT, reason: process.env.FACTORY_REASON, check: JSON.parse(process.env.FACTORY_CHECK) }));`);
187
+ writeFileSync(join(dir, "hooks", "after.cjs"), `require("node:fs").writeFileSync(require("node:path").join(process.env.FACTORY_FAIL_DIR, "after.json"), JSON.stringify({ outcome: process.env.FACTORY_OUTCOME, commit: process.env.FACTORY_COMMIT, reason: process.env.FACTORY_REASON, check: JSON.parse(process.env.FACTORY_CHECK), activity: process.env.CHANT_RUN_ACTIVITY ?? null }));`);
188
188
  // The prepare hook writes W-110 in the checkout and leaves it uncommitted; the others are taken out of the way.
189
189
  writeFileSync(
190
190
  join(dir, "hooks", "prepare.cjs"),
@@ -204,6 +204,8 @@ describe("the factory Op's seams for an orchestrator (studio#382)", () => {
204
204
  expect(result.workLease).toMatchObject({ item: "W-110", outcome: "not_done" });
205
205
  const after = JSON.parse(readFileSync(join(dir, "after.json"), "utf-8"));
206
206
  expect(after).toMatchObject({ outcome: "not_done", reason: "criteria AC-2 have no passing evidence", check: { built: "builder-small" } });
207
+ // A hook gets the run's activity file, as a shell step does (#3637).
208
+ expect(after.activity).toMatch(/\.activity\.jsonl$/);
207
209
  // The attempt was committed with why, and the run kept that commit.
208
210
  expect(after.commit).toMatch(/^[0-9a-f]{40}$/);
209
211
  // The kept attempt holds the carried item with AC-1 ticked and AC-2 failed.
package/src/provenance.ts CHANGED
@@ -87,6 +87,12 @@ export interface EntityProvenance {
87
87
  * expression for this path reads no parameter at all. The composite fixes
88
88
  * this field, and no argument at the call site moves it.
89
89
  *
90
+ * - `host` — the authored expression for this path reads a value host code
91
+ * produced (INTENTIUS/typescript-as-data#248): a method called on a real
92
+ * receiver or an eager lexicon helper (`host-call`), or a value a lexicon
93
+ * package exports (`host-value`). The author wrote the call, not the value,
94
+ * so ./fold-provenance.ts answers `unknown` rather than `direct`.
95
+ *
90
96
  * The coarse `composite` and the two fine kinds are not alternatives. A build
91
97
  * records `composite` when it expanded a composite without interpreting its
92
98
  * body (the run path, an inadmissible factory), and that is why
@@ -108,7 +114,11 @@ export type PathOrigin =
108
114
  */
109
115
  arguments?: ArgumentLocation[];
110
116
  }
111
- | { kind: "composite-literal"; composite: string };
117
+ | { kind: "composite-literal"; composite: string }
118
+ | { kind: "host"; reason: HostOriginReason };
119
+
120
+ /** Which host code produced a value: a call it ran, or a value its package exports. */
121
+ export type HostOriginReason = "host-call" | "host-value";
112
122
 
113
123
  /**
114
124
  * A place in a source file. `file` is absolute, as
@@ -270,6 +280,8 @@ export function describePathOrigin(origin: PathOrigin): string {
270
280
  return `composite ${origin.composite} parameter ${origin.parameters.join(", ")}`;
271
281
  case "composite-literal":
272
282
  return `composite ${origin.composite} literal`;
283
+ case "host":
284
+ return origin.reason;
273
285
  }
274
286
  }
275
287
 
@@ -118,6 +118,16 @@ describe("walkValue", () => {
118
118
  });
119
119
  });
120
120
 
121
+ test("an __attrRef envelope naming a property reads the attribute that property's AttrRef carries", () => {
122
+ // A fold writes `db.Endpoint_Address` as the property name; the attribute
123
+ // is `Endpoint.Address`.
124
+ const TestDb = createResource("Test::Db", "test", { Endpoint_Address: "Endpoint.Address" });
125
+ const db = new TestDb({}) as unknown as Declarable;
126
+ const names = new Map<Declarable, string>([[db, "MyDB"]]);
127
+ const value = { __attrRef: { entity: "MyDB", attribute: "Endpoint_Address" } };
128
+ expect(walkValue(value, names, mockVisitor)).toEqual({ __getAtt: ["MyDB", "Endpoint.Address"] });
129
+ });
130
+
121
131
  test("resolves standalone __attrRef envelope in plain object", () => {
122
132
  const names = new Map<Declarable, string>();
123
133
  const value = { __attrRef: { entity: "MyBucket", attribute: "DomainName" } };
@@ -21,6 +21,22 @@ export interface SerializerVisitor {
21
21
  propertyDeclarable(entity: Declarable, walk: (v: unknown) => unknown): unknown;
22
22
  }
23
23
 
24
+ /**
25
+ * The attribute an `{__attrRef}` envelope names. A fold writes the envelope
26
+ * with the property it read (`db.Endpoint_Address`), and the property's own
27
+ * AttrRef carries the attribute (`Endpoint.Address`); they differ when the
28
+ * attribute has a dot. The envelope's name is kept when the entity or
29
+ * property is not found.
30
+ */
31
+ function envelopeAttribute(entityName: string, property: string, entityNames: Map<Declarable, string>): string {
32
+ for (const [entity, name] of entityNames) {
33
+ if (name !== entityName) continue;
34
+ const held = (entity as unknown as Record<string, unknown>)[property];
35
+ return isAttrRefLike(held) ? held.attribute : property;
36
+ }
37
+ return property;
38
+ }
39
+
24
40
  /**
25
41
  * Recursively walk a value, converting AttrRefs, Intrinsics, Declarables,
26
42
  * arrays, and objects using the provided visitor.
@@ -100,7 +116,7 @@ export function walkValue(
100
116
  // Handle serialized AttrRef envelopes (produced by AttrRef.toJSON() inside intrinsics)
101
117
  if (typeof value === "object" && "__attrRef" in value) {
102
118
  const ref = (value as { __attrRef: { entity: string; attribute: string } }).__attrRef;
103
- return visitor.attrRef(ref.entity, ref.attribute);
119
+ return visitor.attrRef(ref.entity, envelopeAttribute(ref.entity, ref.attribute, entityNames));
104
120
  }
105
121
 
106
122
  // Handle objects
@@ -37,6 +37,10 @@ export interface StackOutput extends Declarable {
37
37
  * `Output.Condition`) — a condition name, or the condition Declarable
38
38
  * itself, resolved to its logical name at serialization (#2068/#2069). */
39
39
  readonly condition?: string | Declarable;
40
+ /** The output's key in the template, when it is not the export name. A
41
+ * template may use one key for an output and a parameter or resource; a
42
+ * module cannot export both under that name (#3604). */
43
+ readonly name?: string;
40
44
  }
41
45
 
42
46
  /** What a wrapping intrinsic's lexicon gets derived from: either a nested
@@ -112,7 +116,7 @@ export function isStackOutput(value: unknown): value is StackOutput {
112
116
  */
113
117
  export function stackOutput(
114
118
  ref: AttrRef | Intrinsic | string,
115
- options?: { description?: string; exportName?: string; lexicon?: string; condition?: string | Declarable },
119
+ options?: { description?: string; exportName?: string; lexicon?: string; condition?: string | Declarable; name?: string },
116
120
  ): StackOutput {
117
121
  // Duck-type, not `instanceof` (chant #1137): AttrRef also implements
118
122
  // Intrinsic (a global-symbol marker), so `isIntrinsic(ref)` alone already
@@ -152,6 +156,7 @@ export function stackOutput(
152
156
  description: options?.description,
153
157
  exportName: options?.exportName,
154
158
  condition: options?.condition,
159
+ ...(options?.name !== undefined ? { name: options.name } : {}),
155
160
  };
156
161
 
157
162
  return output;
@@ -2,8 +2,9 @@
2
2
  * `chant workspace box`: a member's box block. Two verbs:
3
3
  *
4
4
  * - `box listing set <member>` (#3308) changes the box's listing
5
- * (`box-listing.ts`). A box's other configuration is changed by editing the
6
- * declaration in a reviewed change.
5
+ * (`box-listing.ts`), and `box factory set <member>` (#3600) its factory,
6
+ * such as where it publishes (`box-factory-write.ts`). A box's other
7
+ * configuration is changed by editing the declaration in a reviewed change.
7
8
  * - `box publish <member> <item> | --records` (#3165, ws-088) runs the
8
9
  * publisher the box block names and checks what it did (`box-publish.ts`).
9
10
  */
@@ -12,12 +13,14 @@ import { readFileSync } from "node:fs";
12
13
  import { resolve } from "node:path";
13
14
  import { formatError } from "../cli/format";
14
15
  import type { CommandContext } from "../cli/registry";
16
+ import { BOX_FACTORY_CONTRACT_VERSION, BOX_FACTORY_WRITE_SCHEMA_ID, boxFactorySet, type BoxFactoryWriteDocument } from "./box-factory-write";
15
17
  import { BOX_LISTING_CONTRACT_VERSION, BOX_LISTING_WRITE_SCHEMA_ID, boxListingSet, type BoxListingWriteDocument } from "./box-listing";
16
18
  import { BOX_PUBLISH_CONTRACT_VERSION, BOX_PUBLISH_SCHEMA_ID, boxPublish, type BoxPublishDocument } from "./box-publish";
17
19
  import { readerVersion } from "./declaration";
18
20
  import { AGENT_ENV } from "./write-scope";
19
21
 
20
22
  const USAGE = "chant workspace box listing set <member> [--from <file|->] [--cover <image> [--cover-path <path>]] [--by <principal>] [--dry-run] [--json]";
23
+ const FACTORY_USAGE = "chant workspace box factory set <member> --from <file|-> [--by <principal>] [--dry-run] [--json]";
21
24
  const PUBLISH_USAGE = "chant workspace box publish <member> (<item> | --records) [--by <principal>] [--head <owner/name>] [--dry-run] [--json]";
22
25
 
23
26
  export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
@@ -27,11 +30,13 @@ export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
27
30
  const [, noun, verb, member, ...extra] = args.positionals ?? [];
28
31
  // For publish, the third positional is the member and the fourth the item.
29
32
  if (noun === "publish") return runPublish(ctx, verb, member === undefined ? [] : [member, ...extra]);
33
+ if (noun === "factory" && verb === "set") return runFactorySet(ctx, member, extra);
30
34
  if (noun !== "listing" || verb !== "set") {
35
+ const verbs = "listing set <member>, factory set <member> or publish <member>";
31
36
  console.error(
32
37
  formatError({
33
- message: noun === undefined ? "chant workspace box takes listing set <member> or publish <member>" : `chant workspace box takes listing set <member> or publish <member>, not ${[noun, verb].filter(Boolean).join(" ")}`,
34
- hint: `${USAGE}\n${PUBLISH_USAGE}`,
38
+ message: noun === undefined ? `chant workspace box takes ${verbs}` : `chant workspace box takes ${verbs}, not ${[noun, verb].filter(Boolean).join(" ")}`,
39
+ hint: `${USAGE}\n${FACTORY_USAGE}\n${PUBLISH_USAGE}`,
35
40
  }),
36
41
  );
37
42
  return 1;
@@ -75,6 +80,32 @@ export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
75
80
  );
76
81
  }
77
82
 
83
+ /** `box factory set <member> --from <file|->` (#3600). */
84
+ function runFactorySet(ctx: CommandContext, member: string | undefined, extra: string[]): number {
85
+ const { args } = ctx;
86
+ const cwd = process.cwd();
87
+ const print = (doc: BoxFactoryWriteDocument): number => {
88
+ console.log(JSON.stringify(doc, null, 2));
89
+ return "error" in doc ? 1 : 0;
90
+ };
91
+ const fail = (code: "write-usage-invalid" | "write-input-invalid", message: string) =>
92
+ print({ $schema: BOX_FACTORY_WRITE_SCHEMA_ID, contract: BOX_FACTORY_CONTRACT_VERSION, chant: readerVersion(), member: member ?? null, error: { code, message } });
93
+ if (member === undefined) return fail("write-usage-invalid", `box factory set needs the member whose box builds; ${FACTORY_USAGE}`);
94
+ if (extra.length > 0) return fail("write-usage-invalid", `box factory set takes one member, and was also given ${extra.join(" ")}`);
95
+ for (const [flag, v] of [["--set", args.set], ["--kind", args.kind], ["--at", args.at], ["--cover", args.cover]] as const) {
96
+ if (v !== undefined) return fail("write-usage-invalid", `box factory set takes its fields with --from, not ${flag}`);
97
+ }
98
+ let fields: string | undefined;
99
+ if (args.migrateFrom !== undefined) {
100
+ try {
101
+ fields = readFileSync(args.migrateFrom === "-" ? 0 : resolve(cwd, args.migrateFrom), "utf-8");
102
+ } catch (err) {
103
+ return fail("write-input-invalid", `--from ${args.migrateFrom} could not be read: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+ return print(boxFactorySet({ cwd, member, fields, by: args.by, agent: process.env[AGENT_ENV] || undefined, dryRun: args.dryRun === true }));
107
+ }
108
+
78
109
  /** `box publish <member> <item>` or `box publish <member> --records`. */
79
110
  function runPublish(ctx: CommandContext, member: string | undefined, rest: string[]): number {
80
111
  const { args } = ctx;