agent-contracts 0.11.1 → 0.11.3

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/README.md CHANGED
@@ -297,7 +297,7 @@ Design regressions become testable.
297
297
  * **Structured handoff definitions** with formal JSON Schema and `allOf` composition
298
298
  * **Reusable schema components** via `components.schemas` and JSON Pointer `$ref`
299
299
  * **Artifact ownership and lifecycle modeling**
300
- * **Config-driven prompt rendering**
300
+ * **Config-driven prompt rendering** with `skip_empty` support for conditional file generation
301
301
  * **Variable substitution** via `${vars.xxx}` in DSL values
302
302
  * **Inheritance with merge operators via `extends`**
303
303
  * **Guardrail definitions** for cross-cutting process constraints
@@ -865,6 +865,44 @@ This lets you generate static outputs for:
865
865
 
866
866
  all from the same resolved DSL.
867
867
 
868
+ ### Render target options
869
+
870
+ Each entry in `renders` supports these fields:
871
+
872
+ | Field | Type | Required | Description |
873
+ |-------|------|----------|-------------|
874
+ | `template` | string | yes | Path to Handlebars template |
875
+ | `context` | string | yes | Context type (see below) |
876
+ | `output` | string | yes | Output file path (supports `{<context>.id}` placeholder) |
877
+ | `include` | string[] | no | Only render these entity IDs (not with `system`) |
878
+ | `exclude` | string[] | no | Skip these entity IDs (not with `system`) |
879
+ | `skip_empty` | boolean | no | When `true`, if the rendered output is empty or whitespace-only, the file is **not written**. If the file already exists, it is **deleted**. |
880
+
881
+ #### `skip_empty` usage
882
+
883
+ `skip_empty` is useful when a single template applies to all entities of a context type, but only some entities produce meaningful output.
884
+
885
+ For example, when using `context: tool` to generate per-tool scripts, tools without an `x-script` property would produce empty files. With `skip_empty: true`, those files are simply not created:
886
+
887
+ ````yaml
888
+ renders:
889
+ - template: ./templates/tool-script.sh.hbs
890
+ context: tool
891
+ output: ./output/scripts/{tool.id}.sh
892
+ skip_empty: true
893
+ ````
894
+
895
+ ````handlebars
896
+ {{!-- tool-script.sh.hbs --}}
897
+ {{#if tool.x-script}}
898
+ {{{tool.x-script}}}
899
+ {{/if}}
900
+ ````
901
+
902
+ Tools with `x-script` get a generated script file; tools without it produce no file at all.
903
+
904
+ `skip_empty` also works with `render --check` (drift detection): when the expected output is empty, the check expects the file to **not exist** and reports drift if it does.
905
+
868
906
  ### Available context types
869
907
 
870
908
  Each `context` type provides a different rendering scope:
package/dist/cli.js CHANGED
@@ -50498,17 +50498,18 @@ import { z as z3 } from "zod";
50498
50498
  var CommandRegexMatcherSchema = z3.object({
50499
50499
  type: z3.literal("command_regex"),
50500
50500
  pattern: z3.string()
50501
- });
50501
+ }).passthrough();
50502
50502
  var ContentRegexMatcherSchema = z3.object({
50503
50503
  type: z3.literal("content_regex"),
50504
50504
  pattern: z3.string(),
50505
50505
  file_glob: z3.string().optional(),
50506
50506
  exclude_glob: z3.string().optional()
50507
- });
50507
+ }).passthrough();
50508
50508
  var FileGlobMatcherSchema = z3.object({
50509
50509
  type: z3.literal("file_glob"),
50510
- pattern: z3.string()
50511
- });
50510
+ pattern: z3.string(),
50511
+ exclude_glob: z3.string().optional()
50512
+ }).passthrough();
50512
50513
  var MatcherSchema = z3.discriminatedUnion("type", [
50513
50514
  CommandRegexMatcherSchema,
50514
50515
  ContentRegexMatcherSchema,
@@ -50525,7 +50526,8 @@ var BindingOutputSchema = z3.object({
50525
50526
  inline_template: z3.string().optional(),
50526
50527
  mode: z3.enum(["write", "patch"]).default("write"),
50527
50528
  group_by: z3.string().optional(),
50528
- executable: z3.boolean().optional()
50529
+ executable: z3.boolean().optional(),
50530
+ skip_empty: z3.boolean().optional()
50529
50531
  }).passthrough().refine(
50530
50532
  (data) => !(data.template && data.inline_template),
50531
50533
  { message: "template and inline_template are mutually exclusive" }
@@ -50859,7 +50861,8 @@ var RenderTargetSchema = z14.object({
50859
50861
  context: ContextTypeSchema,
50860
50862
  output: z14.string(),
50861
50863
  include: z14.array(z14.string()).optional(),
50862
- exclude: z14.array(z14.string()).optional()
50864
+ exclude: z14.array(z14.string()).optional(),
50865
+ skip_empty: z14.boolean().optional()
50863
50866
  }).refine(
50864
50867
  (data) => !(data.include && data.exclude),
50865
50868
  { message: "include and exclude are mutually exclusive" }
@@ -53280,7 +53283,7 @@ var lintCommand = new Command3("lint").description("Run semantic lint rules on r
53280
53283
  import { Command as Command4 } from "commander";
53281
53284
 
53282
53285
  // src/renderer/renderer.ts
53283
- import { readFile as readFile4, writeFile, mkdir } from "fs/promises";
53286
+ import { readFile as readFile4, writeFile, mkdir, unlink } from "fs/promises";
53284
53287
  import { dirname as dirname5 } from "path";
53285
53288
  import Handlebars from "handlebars";
53286
53289
 
@@ -54396,6 +54399,15 @@ function buildEntityContext(dsl, context, entityId) {
54396
54399
  async function loadTemplate(templatePath) {
54397
54400
  return readFile4(templatePath, "utf8");
54398
54401
  }
54402
+ function isEffectivelyEmpty(output) {
54403
+ return output.trim().length === 0;
54404
+ }
54405
+ async function removeIfExists(filePath) {
54406
+ try {
54407
+ await unlink(filePath);
54408
+ } catch {
54409
+ }
54410
+ }
54399
54411
  async function renderFromConfig(dsl, renderTargets) {
54400
54412
  const outputFiles = [];
54401
54413
  for (const target of renderTargets) {
@@ -54404,6 +54416,10 @@ async function renderFromConfig(dsl, renderTargets) {
54404
54416
  if (target.context === "system") {
54405
54417
  const ctx = buildSystemContext(dsl);
54406
54418
  const output = compiled(ctx);
54419
+ if (target.skip_empty && isEffectivelyEmpty(output)) {
54420
+ await removeIfExists(target.output);
54421
+ continue;
54422
+ }
54407
54423
  await mkdir(dirname5(target.output), { recursive: true });
54408
54424
  await writeFile(target.output, output, "utf8");
54409
54425
  outputFiles.push(target.output);
@@ -54415,6 +54431,10 @@ async function renderFromConfig(dsl, renderTargets) {
54415
54431
  const ctx = buildEntityContext(dsl, target.context, entityId);
54416
54432
  const output = compiled(ctx);
54417
54433
  const outputPath = expandOutputPath(target.output, target.context, entityId);
54434
+ if (target.skip_empty && isEffectivelyEmpty(output)) {
54435
+ await removeIfExists(outputPath);
54436
+ continue;
54437
+ }
54418
54438
  await mkdir(dirname5(outputPath), { recursive: true });
54419
54439
  await writeFile(outputPath, output, "utf8");
54420
54440
  outputFiles.push(outputPath);
@@ -54423,6 +54443,26 @@ async function renderFromConfig(dsl, renderTargets) {
54423
54443
  }
54424
54444
  return outputFiles;
54425
54445
  }
54446
+ async function fileExists2(filePath) {
54447
+ try {
54448
+ await readFile4(filePath);
54449
+ return true;
54450
+ } catch {
54451
+ return false;
54452
+ }
54453
+ }
54454
+ function checkExpectedVsExisting(expected, outputPath, skipEmpty, diffs) {
54455
+ if (skipEmpty && isEffectivelyEmpty(expected)) {
54456
+ return fileExists2(outputPath).then((exists) => {
54457
+ if (exists) diffs.push(outputPath);
54458
+ });
54459
+ }
54460
+ return readFile4(outputPath, "utf8").then((existing) => {
54461
+ if (existing !== expected) diffs.push(outputPath);
54462
+ }).catch(() => {
54463
+ diffs.push(outputPath);
54464
+ });
54465
+ }
54426
54466
  async function checkDriftFromConfig(dsl, renderTargets) {
54427
54467
  const diffs = [];
54428
54468
  for (const target of renderTargets) {
@@ -54431,12 +54471,7 @@ async function checkDriftFromConfig(dsl, renderTargets) {
54431
54471
  if (target.context === "system") {
54432
54472
  const ctx = buildSystemContext(dsl);
54433
54473
  const expected = compiled(ctx);
54434
- try {
54435
- const existing = await readFile4(target.output, "utf8");
54436
- if (existing !== expected) diffs.push(target.output);
54437
- } catch {
54438
- diffs.push(target.output);
54439
- }
54474
+ await checkExpectedVsExisting(expected, target.output, target.skip_empty, diffs);
54440
54475
  } else {
54441
54476
  const section = getDslSection(dsl, target.context);
54442
54477
  const allIds = Object.keys(section);
@@ -54445,12 +54480,7 @@ async function checkDriftFromConfig(dsl, renderTargets) {
54445
54480
  const ctx = buildEntityContext(dsl, target.context, entityId);
54446
54481
  const expected = compiled(ctx);
54447
54482
  const outputPath = expandOutputPath(target.output, target.context, entityId);
54448
- try {
54449
- const existing = await readFile4(outputPath, "utf8");
54450
- if (existing !== expected) diffs.push(outputPath);
54451
- } catch {
54452
- diffs.push(outputPath);
54453
- }
54483
+ await checkExpectedVsExisting(expected, outputPath, target.skip_empty, diffs);
54454
54484
  }
54455
54485
  }
54456
54486
  }
@@ -54608,7 +54638,7 @@ var checkCommand = new Command5("check").description("Run full pipeline: resolve
54608
54638
  import { Command as Command6 } from "commander";
54609
54639
 
54610
54640
  // src/guardrail-generator/generator.ts
54611
- import { readFile as readFile5, writeFile as writeFile2, mkdir as mkdir2, chmod } from "fs/promises";
54641
+ import { readFile as readFile5, writeFile as writeFile2, mkdir as mkdir2, chmod, unlink as unlink2 } from "fs/promises";
54612
54642
  import { resolve as resolve6, dirname as dirname6 } from "path";
54613
54643
  import Handlebars2 from "handlebars";
54614
54644
 
@@ -54782,6 +54812,7 @@ async function generateGuardrails(options) {
54782
54812
  });
54783
54813
  continue;
54784
54814
  }
54815
+ const shouldSkipEmpty = outputDef.skip_empty === true;
54785
54816
  if (outputDef.group_by) {
54786
54817
  const groupField = outputDef.group_by;
54787
54818
  const groups = /* @__PURE__ */ new Map();
@@ -54801,6 +54832,15 @@ async function generateGuardrails(options) {
54801
54832
  const compiled = Handlebars2.compile(templateContent, { noEscape: true });
54802
54833
  const output = compiled(groupCtx);
54803
54834
  const groupTarget = resolve6(targetPath, groupKey);
54835
+ if (shouldSkipEmpty && output.trim().length === 0) {
54836
+ if (!dryRun) {
54837
+ try {
54838
+ await unlink2(groupTarget);
54839
+ } catch {
54840
+ }
54841
+ }
54842
+ continue;
54843
+ }
54804
54844
  if (!dryRun) {
54805
54845
  await mkdir2(dirname6(groupTarget), { recursive: true });
54806
54846
  await writeFile2(groupTarget, output, "utf8");
@@ -54813,14 +54853,23 @@ async function generateGuardrails(options) {
54813
54853
  } else {
54814
54854
  const compiled = Handlebars2.compile(templateContent, { noEscape: true });
54815
54855
  const output = compiled(ctx);
54816
- if (!dryRun) {
54817
- await mkdir2(dirname6(targetPath), { recursive: true });
54818
- await writeFile2(targetPath, output, "utf8");
54819
- if (outputDef.executable) {
54820
- await chmod(targetPath, 493);
54856
+ if (shouldSkipEmpty && output.trim().length === 0) {
54857
+ if (!dryRun) {
54858
+ try {
54859
+ await unlink2(targetPath);
54860
+ } catch {
54861
+ }
54862
+ }
54863
+ } else {
54864
+ if (!dryRun) {
54865
+ await mkdir2(dirname6(targetPath), { recursive: true });
54866
+ await writeFile2(targetPath, output, "utf8");
54867
+ if (outputDef.executable) {
54868
+ await chmod(targetPath, 493);
54869
+ }
54821
54870
  }
54871
+ outputFiles.push(targetPath);
54822
54872
  }
54823
- outputFiles.push(targetPath);
54824
54873
  }
54825
54874
  }
54826
54875
  }