@aifabrix/builder 2.59.0 → 2.61.2

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 (145) hide show
  1. package/README.md +14 -11
  2. package/docs/README.md +80 -0
  3. package/docs/builder-help/evidence-patterns.json +155 -0
  4. package/docs/builder-help/golden-examples/crm-company.json +30 -0
  5. package/docs/builder-help/golden-examples/crm-deal.json +29 -0
  6. package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
  7. package/docs/builder-help/golden-examples/document-storage.json +30 -0
  8. package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
  9. package/docs/builder-help/golden-examples/repository-template.json +29 -0
  10. package/docs/builder-help/golden-examples/service-ticket.json +29 -0
  11. package/docs/builder-help/platform-roles.json +98 -0
  12. package/docs/builder-help/resource-type-catalog.json +402 -0
  13. package/lib/agent-kit/git-identity.js +180 -0
  14. package/lib/agent-kit/setup.js +3 -0
  15. package/lib/agent-kit/start.js +45 -1
  16. package/lib/api/configuration.api.js +131 -0
  17. package/lib/api/role-assistant-test-job.api.js +60 -0
  18. package/lib/api/system-secrets.api.js +72 -0
  19. package/lib/api/work-search.api.js +13 -6
  20. package/lib/app/deploy.js +8 -1
  21. package/lib/app/run-docker-fallback.js +6 -1
  22. package/lib/app/run-helpers.js +2 -1
  23. package/lib/app/run-parameter-sync.js +142 -0
  24. package/lib/app/show-display.js +1 -0
  25. package/lib/app/show-online.js +15 -0
  26. package/lib/build/docker-build-args.js +5 -2
  27. package/lib/build/index.js +3 -2
  28. package/lib/build/standard-docker-build.js +6 -3
  29. package/lib/cli/setup-app.js +19 -1
  30. package/lib/cli/setup-environment.js +156 -0
  31. package/lib/cli/setup-utility.js +24 -1
  32. package/lib/commands/datasource-capability-upsert-cli.js +112 -0
  33. package/lib/commands/datasource-capability.js +4 -2
  34. package/lib/commands/env-secret-context.js +113 -0
  35. package/lib/commands/env-secret-list.js +200 -0
  36. package/lib/commands/env-secret-push-confirm.js +54 -0
  37. package/lib/commands/env-secret-push-run.js +227 -0
  38. package/lib/commands/env-secret-push.js +168 -0
  39. package/lib/commands/repair-datasource-apply.js +2 -0
  40. package/lib/commands/repair-datasource-keyed-document.js +122 -0
  41. package/lib/commands/repair-datasource-run.js +1 -0
  42. package/lib/commands/role-assistant.js +7 -0
  43. package/lib/commands/setup-modes.js +1 -8
  44. package/lib/commands/setup-prompts.js +2 -180
  45. package/lib/commands/verify-operations-skip-e2e.js +25 -1
  46. package/lib/commands/verify-operations-steps.js +13 -1
  47. package/lib/commands/wizard-config-normalizer.js +7 -4
  48. package/lib/commands/wizard-core.js +5 -157
  49. package/lib/commands/wizard-file-saving.js +163 -0
  50. package/lib/core/env-platform-expand.js +97 -0
  51. package/lib/core/secrets-env-content.js +42 -4
  52. package/lib/core/secrets-env-write.js +10 -3
  53. package/lib/core/secrets-load.js +4 -2
  54. package/lib/datasource/binary-documents-validator.js +190 -0
  55. package/lib/datasource/capability/run-capability-upsert.js +202 -0
  56. package/lib/datasource/capability/upsert-ingredients.js +291 -0
  57. package/lib/datasource/capability/upsert-operations.js +138 -0
  58. package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
  59. package/lib/datasource/validate.js +12 -5
  60. package/lib/deployment/installation/azure-infra-stage.js +3 -1
  61. package/lib/deployment/installation/infra-catalog.js +2 -5
  62. package/lib/generator/builders.js +17 -0
  63. package/lib/generator/helpers.js +23 -2
  64. package/lib/generator/index.js +13 -6
  65. package/lib/lifecycle/product-model.js +4 -3
  66. package/lib/lifecycle/report-display.js +3 -2
  67. package/lib/parameters/infra-parameter-catalog.js +1 -1
  68. package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
  69. package/lib/programmatic/builder-help-governance.js +1 -1
  70. package/lib/programmatic/builder-help.js +1 -1
  71. package/lib/role-assistant/test-cases-search.js +3 -0
  72. package/lib/role-assistant/test-job-runner.js +125 -0
  73. package/lib/role-assistant/test-runner-search.js +44 -2
  74. package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
  75. package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
  76. package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
  77. package/lib/role-assistant/test-runner-workhub.js +28 -19
  78. package/lib/schema/application-schema.json +205 -2
  79. package/lib/schema/external-datasource.schema.json +23 -3
  80. package/lib/schema/infra-parameter.schema.json +139 -33
  81. package/lib/schema/infra.parameter.yaml +416 -66
  82. package/lib/schema/infrastructure-schema.json +10 -35
  83. package/lib/utils/compose-generate-docker-compose.js +16 -9
  84. package/lib/utils/datasource-binary-evidence.js +92 -0
  85. package/lib/utils/datasource-test-run-capability-scope.js +44 -1
  86. package/lib/utils/datasource-test-run-debug-display.js +2 -0
  87. package/lib/utils/datasource-test-run-display.js +8 -2
  88. package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
  89. package/lib/utils/datasource-test-run-tty-log.js +2 -0
  90. package/lib/utils/docker-build.js +29 -8
  91. package/lib/utils/docker-manifest-public-port.js +36 -0
  92. package/lib/utils/env-copy.js +11 -10
  93. package/lib/utils/external-system-system-test-tty.js +3 -2
  94. package/lib/utils/image-tags.js +2 -2
  95. package/lib/utils/platform-kv-ref.js +1 -1
  96. package/lib/utils/platform-resolution.js +226 -0
  97. package/lib/utils/prepare-local-data-mount.js +58 -0
  98. package/lib/utils/resolve-docker-image-ref.js +8 -3
  99. package/lib/utils/secrets-helpers.js +0 -1
  100. package/lib/utils/system-secret-mapping.js +125 -0
  101. package/lib/utils/test-log-writer.js +2 -1
  102. package/lib/validation/external-manifest-validator.js +5 -0
  103. package/lib/validation/openapi-contract-surface-validator.js +3 -1
  104. package/lib/validation/validate-external-file.js +5 -1
  105. package/package.json +5 -4
  106. package/templates/README.md +2 -1
  107. package/templates/agent-kit/agent-kit.yaml +4 -1
  108. package/templates/agent-kit/instructions/AGENTKIT.md +1 -1
  109. package/templates/agent-kit/instructions/root.AGENTS.md +2 -0
  110. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +35 -2
  111. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
  112. package/templates/agent-kit/skills/aifabrix-connected-system/scripts/delivery-verdict.js +176 -0
  113. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +6 -2
  114. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +28 -2
  115. package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
  116. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +28 -1
  117. package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
  118. package/templates/agent-kit/skills/shared/feedback.md +31 -0
  119. package/templates/agent-kit/skills/shared/hosts.md +13 -3
  120. package/templates/agent-kit/skills/shared/interaction.md +83 -0
  121. package/templates/agent-kit/skills/shared/status.md +76 -0
  122. package/templates/agent-kit/workspace/BUILDER_IMPROVEMENT_FINDINGS.md +27 -0
  123. package/templates/applications/builder-api/application.yaml +1 -1
  124. package/templates/applications/builder-api/env.template +5 -1
  125. package/templates/applications/dataplane/application.yaml +30 -2
  126. package/templates/applications/dataplane/env.template +36 -5
  127. package/templates/applications/keycloak/application.yaml +6 -1
  128. package/templates/applications/miso-controller/application.yaml +96 -1
  129. package/templates/applications/miso-controller/env.template +63 -37
  130. package/templates/applications/miso-controller/rbac.yaml +17 -0
  131. package/templates/external-system/external-datasource.yaml.hbs +7 -1
  132. package/templates/marketplace/main.json +73 -225
  133. package/templates/python/Dockerfile.hbs +2 -0
  134. package/templates/python/docker-compose.hbs +1 -1
  135. package/templates/typescript/Dockerfile.hbs +2 -0
  136. package/templates/typescript/docker-compose.hbs +12 -0
  137. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -34
  138. /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
  139. /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
  140. /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
  141. /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
  142. /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
  143. /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
  144. /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
  145. /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
@@ -7,12 +7,12 @@
7
7
  "key": "infrastructure-schema",
8
8
  "name": "Installation Infrastructure Manifest Schema",
9
9
  "description": "JSON schema for infrastructure/<resource-group>/environment-infra.json",
10
- "version": "2.0.0",
10
+ "version": "2.1.0",
11
11
  "type": "schema",
12
12
  "category": "infrastructure",
13
13
  "author": "AI Fabrix Team",
14
14
  "createdAt": "2024-01-01T00:00:00Z",
15
- "updatedAt": "2026-09-17T00:00:00Z",
15
+ "updatedAt": "2026-09-24T00:00:00Z",
16
16
  "compatibility": {
17
17
  "minVersion": "2.0.0",
18
18
  "maxVersion": "2.0.0",
@@ -26,6 +26,14 @@
26
26
  ],
27
27
  "dependencies": [],
28
28
  "changelog": [
29
+ {
30
+ "version": "2.1.0",
31
+ "date": "2026-09-24T00:00:00Z",
32
+ "breaking": false,
33
+ "changes": [
34
+ "Removed moriCallbackApiKey and entraCallbackStateSecret: the shipped Marketplace template no longer declares them. The installation receives its Mori key at licence registration, and Entra consent state is issued server-side (Mori 227.5)."
35
+ ]
36
+ },
29
37
  {
30
38
  "version": "2.0.0",
31
39
  "date": "2026-09-16T00:00:00Z",
@@ -501,8 +509,6 @@
501
509
  "acrResourceId",
502
510
  "deployRoleAssignments",
503
511
  "moriBaseUrl",
504
- "moriCallbackApiKey",
505
- "entraCallbackStateSecret",
506
512
  "entraIdClientId",
507
513
  "entraIdClientSecret",
508
514
  "entraIdTenantId",
@@ -1058,37 +1064,6 @@
1058
1064
  }
1059
1065
  }
1060
1066
  },
1061
- "moriCallbackApiKey": {
1062
- "$ref": "#/definitions/envReference"
1063
- },
1064
- "entraCallbackStateSecret": {
1065
- "type": "object",
1066
- "required": [
1067
- "value",
1068
- "source",
1069
- "description"
1070
- ],
1071
- "additionalProperties": false,
1072
- "properties": {
1073
- "value": {
1074
- "type": "null"
1075
- },
1076
- "source": {
1077
- "type": "string",
1078
- "minLength": 1
1079
- },
1080
- "description": {
1081
- "type": "string",
1082
- "minLength": 1
1083
- },
1084
- "required": {
1085
- "type": "boolean"
1086
- },
1087
- "requiredAt": {
1088
- "const": "tenantActivation"
1089
- }
1090
- }
1091
- },
1092
1067
  "entraIdClientId": {
1093
1068
  "type": "object",
1094
1069
  "required": [
@@ -11,6 +11,7 @@
11
11
  const fsSync = require('fs');
12
12
  const path = require('path');
13
13
  const buildCopy = require('./build-copy');
14
+ const { prepareLocalDataMount } = require('./prepare-local-data-mount');
14
15
  const paths = require('./paths');
15
16
  const { isSystemBuilderAppName } = require('./paths-system-builder-keys');
16
17
  const {
@@ -187,14 +188,14 @@ function resolveStorageMountSourceRoot(appName, buildContext) {
187
188
  }
188
189
 
189
190
  /**
190
- * Host `mount/` directory bind-mounted to `/mnt/data` when storage is required.
191
+ * Host `mount/data/<appName>/` directory bind-mounted to `/mnt/data` when storage is required.
191
192
  *
192
193
  * @param {object} ctx - Compose template context (`devMountPath`, `appName`, `appConfig`, `serviceConfig`)
193
194
  * @returns {string|null}
194
195
  */
195
196
  function resolveLocalDataMountPathForCompose(ctx) {
196
197
  if (ctx.devMountPath && typeof ctx.devMountPath === 'string') {
197
- return path.join(ctx.devMountPath, 'mount').replace(/\\/g, '/');
198
+ return path.join(ctx.devMountPath, 'mount', 'data', ctx.appName).replace(/\\/g, '/');
198
199
  }
199
200
  const requiresStorage =
200
201
  ctx.serviceConfig?.requiresStorage ||
@@ -212,7 +213,7 @@ function resolveLocalDataMountPathForCompose(ctx) {
212
213
  if (!sourceRoot) {
213
214
  return null;
214
215
  }
215
- return path.join(sourceRoot, 'mount').replace(/\\/g, '/');
216
+ return path.join(sourceRoot, 'mount', 'data', ctx.appName).replace(/\\/g, '/');
216
217
  } catch {
217
218
  return null;
218
219
  }
@@ -229,12 +230,8 @@ function buildComposeTemplateContext(ctx) {
229
230
  ctx.appConfig?.requires?.storage ||
230
231
  ctx.appConfig?.services?.storage ||
231
232
  false;
232
- if (localDataMountPath && requiresStorage && !fsSync.existsSync(localDataMountPath)) {
233
- try {
234
- fsSync.mkdirSync(localDataMountPath, { recursive: true });
235
- } catch {
236
- // Non-fatal: tests may use non-writable devMountPath; host must create mount/ before run.
237
- }
233
+ if (localDataMountPath && requiresStorage) {
234
+ prepareLocalDataMount(localDataMountPath, ctx.envFileAbsolutePath);
238
235
  }
239
236
  return {
240
237
  ...ctx.serviceConfig,
@@ -249,6 +246,16 @@ function buildComposeTemplateContext(ctx) {
249
246
  networkName: ctx.networkName,
250
247
  containerName: ctx.containerName,
251
248
  misoEnvironment: resolveMisoEnvironment(ctx.options),
249
+ // Containers are run as the invoking host user so bind-mounted files stay writable.
250
+ // An app that administers its own operating system — creating accounts, managing
251
+ // sshd — cannot work that way and declares `runAsRoot: true` to opt out.
252
+ runAsRoot: ctx.appConfig?.runAsRoot === true,
253
+ // Extra protocol ports use the main port offset formula to avoid developer collisions.
254
+ additionalPorts: (ctx.appConfig?.additionalPorts ?? []).map((entry) => ({
255
+ name: entry.name,
256
+ containerPort: entry.containerPort,
257
+ hostPort: ctx.idNum === 0 ? entry.hostPort : entry.hostPort + ctx.idNum * 100
258
+ })),
252
259
  devMountPath: ctx.devMountPath,
253
260
  localDataMountPath,
254
261
  reloadStartCommand: ctx.reloadStartCommand,
@@ -0,0 +1,92 @@
1
+ /** @fileoverview Safe binary metadata and keyed document persistence evidence. */
2
+ 'use strict';
3
+
4
+ const { keyedGetEvidence, hasExecutedKeyedGet } = require('./datasource-test-run-capability-scope');
5
+ const BINARY_FIELDS = new Set(['contentType', 'size', 'filename', 'sha256']);
6
+
7
+ /** @param {*} value @returns {Object} */
8
+ function binarySummary(value) {
9
+ if (!value || typeof value !== 'object') return {};
10
+ return Object.fromEntries(Object.entries(value).filter(([key, item]) =>
11
+ BINARY_FIELDS.has(key) && ['string', 'number'].includes(typeof item)));
12
+ }
13
+
14
+ /**
15
+ * Limit binary containers to metadata before printing or saving a report.
16
+ * Ordinary JSON record fields remain intact.
17
+ * @param {*} value
18
+ * @param {WeakSet} seen
19
+ * @returns {*}
20
+ */
21
+ function sanitizeBinaryEvidence(value, seen = new WeakSet()) {
22
+ if (!value || typeof value !== 'object') return value;
23
+ if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return '[binary omitted]';
24
+ if (seen.has(value)) return '[Circular]';
25
+ seen.add(value);
26
+ if (Array.isArray(value)) {
27
+ const items = value.map(item => sanitizeBinaryEvidence(item, seen));
28
+ seen.delete(value);
29
+ return items;
30
+ }
31
+ const out = {};
32
+ for (const [key, item] of Object.entries(value)) {
33
+ if (['getBinary', 'listBinary', '_binary'].includes(key)) out[key] = binarySummary(item);
34
+ else if (value.keyedGet === true && key === 'document') {
35
+ out[key] = { ...binarySummary(item) };
36
+ if (typeof item?.key === 'string') out[key].key = item.key;
37
+ if (typeof item?.persisted === 'boolean') out[key].persisted = item.persisted;
38
+ } else if (value.keyedGet === true && ['data', 'bytes', 'body', 'content', 'base64'].includes(key)) continue;
39
+ else out[key] = sanitizeBinaryEvidence(item, seen);
40
+ }
41
+ seen.delete(value);
42
+ return out;
43
+ }
44
+
45
+ /** @param {Object} value @param {string[]} lines */
46
+ function appendBinaryAndPersistence(value, lines) {
47
+ if (!value || typeof value !== 'object') return;
48
+ if (value.getBinary) lines.push(` getBinary: ${JSON.stringify(binarySummary(value.getBinary))}`);
49
+ if (value.scope?.expectedSource === 'get') {
50
+ const observed = value.observed || {};
51
+ lines.push(` Document persistence (expectedSource: get): documents=${Number(observed.documents) || 0}, chunks=${Number(observed.chunks) || 0}`);
52
+ }
53
+ for (const [key, child] of Object.entries(value)) {
54
+ if (key !== 'getBinary') appendBinaryAndPersistence(child, lines);
55
+ }
56
+ }
57
+
58
+ /** @param {Object} envelope @returns {string[]} */
59
+ function binaryEvidenceLines(envelope) {
60
+ const lines = [];
61
+ for (const job of keyedGetEvidence(envelope)) {
62
+ const status = job.skipped === true ? 'skipped' : job.ok === true ? 'executed' : 'failed';
63
+ lines.push(` Keyed get: ${status} (${job.datasourceKey || envelope.datasourceKey || 'unknown'})`);
64
+ if (job.document) lines.push(` Document: ${JSON.stringify(binarySummary(job.document))}`);
65
+ }
66
+ appendBinaryAndPersistence(envelope?.integration?.stepResults, lines);
67
+ return lines;
68
+ }
69
+
70
+ /** @param {Object} envelope @param {string} key @returns {boolean} */
71
+ function hasKeyedDocumentPersistence(envelope, key) {
72
+ if (!hasExecutedKeyedGet(envelope, key)) return false;
73
+ const steps = envelope?.integration?.stepResults || [];
74
+ return steps.some(step => {
75
+ if (step.name !== 'document_persistence' || step.success === false || step.status === 'failed') return false;
76
+ const row = step.evidence?.datasources?.[key];
77
+ const model = row?.model;
78
+ return model?.scope?.expectedSource === 'get' && model.observed?.documents > 0 &&
79
+ model.observed?.chunks > 0 && (!Array.isArray(row.failures) || row.failures.length === 0);
80
+ });
81
+ }
82
+
83
+ /** @param {Object} result @param {string[]} keys @returns {boolean} */
84
+ function hasAllKeyedDocumentPersistence(result, keys) {
85
+ return keys.length > 0 && keys.every(key => {
86
+ const row = result?.results?.find(item => item.key === key);
87
+ return row?.success === true && hasKeyedDocumentPersistence(row.datasourceTestRun, key);
88
+ });
89
+ }
90
+
91
+ module.exports = { binarySummary, sanitizeBinaryEvidence, binaryEvidenceLines, hasKeyedDocumentPersistence,
92
+ hasAllKeyedDocumentPersistence };
@@ -4,6 +4,43 @@
4
4
  * @version 2.0.0
5
5
  */
6
6
 
7
+ /**
8
+ * Keyed document retrieval has no list operation, so the `get` runs once against a business
9
+ * key instead of over a sync page. The dataplane records that run as step evidence
10
+ * (`sync_step_keyed_get.py`), which is why execution is read from evidence rather than from
11
+ * a row count. `IntegrationStepResult.evidence` is an open object by contract.
12
+ * @param {Object|null|undefined} envelope - DatasourceTestRun-like
13
+ * @returns {Array<Object>} Keyed-get evidence entries, newest first as reported
14
+ */
15
+ function keyedGetEvidence(envelope) {
16
+ const integration = envelope && typeof envelope === 'object' ? envelope.integration : null;
17
+ const steps = integration && Array.isArray(integration.stepResults) ? integration.stepResults : [];
18
+ return steps
19
+ .map(step => (step && typeof step.evidence === 'object' ? step.evidence : null))
20
+ .flatMap(evidence => evidence && Array.isArray(evidence.jobs) ? evidence.jobs : [evidence])
21
+ .filter(evidence => evidence && evidence.keyedGet === true);
22
+ }
23
+
24
+ /**
25
+ * A keyed `get` counts as executed only when it actually returned a document. A skipped run
26
+ * (no primary key value) or a failed one is not execution, and reporting it as such would
27
+ * turn `--strict-capability-scope` into a rubber stamp.
28
+ * @param {Object|null|undefined} envelope - DatasourceTestRun-like
29
+ * @param {string|undefined|null} datasourceKey - Restrict to one datasource when given
30
+ * @returns {boolean} True when a keyed get ran successfully
31
+ */
32
+ function hasExecutedKeyedGet(envelope, datasourceKey) {
33
+ const wanted = datasourceKey === undefined || datasourceKey === null || String(datasourceKey).trim() === ''
34
+ ? ''
35
+ : String(datasourceKey).trim();
36
+ return keyedGetEvidence(envelope).some(evidence => {
37
+ if (evidence.ok !== true || evidence.skipped === true) {
38
+ return false;
39
+ }
40
+ return wanted === '' || String(evidence.datasourceKey || '') === wanted;
41
+ });
42
+ }
43
+
7
44
  /**
8
45
  * @param {Object|null|undefined} envelope - DatasourceTestRun-like
9
46
  * @param {string|undefined|null} requestedCapabilityKey - From positional [capabilityKey]
@@ -23,6 +60,10 @@ function analyzeCapabilityScope(envelope, requestedCapabilityKey) {
23
60
  envelope && typeof envelope === 'object' && Array.isArray(envelope.capabilities)
24
61
  ? envelope.capabilities
25
62
  : [];
63
+ if (key === 'get' && keyedGetEvidence(envelope).length > 0 &&
64
+ !hasExecutedKeyedGet(envelope, envelope.datasourceKey)) {
65
+ return { violated: true, message: 'Capabilities scope: keyed get was not executed successfully.' };
66
+ }
26
67
  if (caps.length <= 1) {
27
68
  return { violated: false };
28
69
  }
@@ -39,5 +80,7 @@ function analyzeCapabilityScope(envelope, requestedCapabilityKey) {
39
80
  }
40
81
 
41
82
  module.exports = {
42
- analyzeCapabilityScope
83
+ analyzeCapabilityScope,
84
+ keyedGetEvidence,
85
+ hasExecutedKeyedGet
43
86
  };
@@ -4,6 +4,7 @@
4
4
  * @version 2.0.0
5
5
  */
6
6
 
7
+ const { sanitizeBinaryEvidence } = require('./datasource-binary-evidence');
7
8
  const { SEP, appendReferenceLayoutLines } = require('./datasource-test-run-display');
8
9
  const { buildDebugEnvelopeSlice } = require('./datasource-test-run-debug-slice');
9
10
  const {
@@ -250,6 +251,7 @@ function applyRawLineCap(text) {
250
251
  * @returns {string}
251
252
  */
252
253
  function formatDatasourceTestRunDebugBlock(envelope, mode, isTTY) {
254
+ envelope = sanitizeBinaryEvidence(envelope);
253
255
  if (!envelope || typeof envelope !== 'object') return '';
254
256
 
255
257
  const lines = ['', SEP, `Debug (${mode})`];
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  const chalk = require('chalk');
8
+ const { binaryEvidenceLines } = require('./datasource-binary-evidence');
8
9
  const { sectionTitle, headerKeyValue, colorAggregateGlyph, successGlyph, failureGlyph } = require('./cli-test-layout-chalk');
9
10
  const { appendCertificateTTY, appendCertificateIssuanceTTY } = require('./datasource-test-run-certificate-tty');
10
11
  const { buildTtyMetaLines: buildTtyMetaLinesCore } = require('./datasource-test-run-tty-meta-lines');
12
+ const { appendStructuredIssueGuidance } = require('./datasource-test-run-issue-guidance');
11
13
 
12
14
  const SEP = '────────────────────────────────';
13
15
 
@@ -297,9 +299,12 @@ function appendValidationIssueLines(lines, envelope, maxIssues = 5) {
297
299
  const cap = Math.min(maxIssues, issues.length);
298
300
  for (let i = 0; i < cap; i += 1) {
299
301
  const iss = issues[i];
300
- const code = iss && iss.code ? chalk.red(`[${iss.code}] `) : '';
302
+ const issueCode = iss && (iss.stableCode || iss.code);
303
+ const code = issueCode ? chalk.red(`[${issueCode}] `) : '';
304
+ const severity = iss?.severity ? chalk.gray(`[${String(iss.severity)}] `) : '';
301
305
  const msg = iss && iss.message ? String(iss.message) : JSON.stringify(iss);
302
- lines.push(` ${code}${chalk.yellow(msg)}`);
306
+ lines.push(` ${code}${severity}${chalk.yellow(msg)}`);
307
+ appendStructuredIssueGuidance(lines, iss);
303
308
  appendDpSec013Details(lines, iss);
304
309
  }
305
310
  if (issues.length > cap) {
@@ -448,6 +453,7 @@ function formatDatasourceTestRunTTY(envelope, options = {}) {
448
453
  appendRefsSectionIfEnabled(lines, envelope, options);
449
454
  appendValidationIssueLines(lines, envelope);
450
455
  appendIntegrationStepLines(lines, envelope);
456
+ lines.push(...binaryEvidenceLines(envelope));
451
457
  const focus = normalizedFocusCapabilityKey(options.focusCapabilityKey);
452
458
  if (focus) {
453
459
  lines.push(formatCapabilityFocusSection(envelope, focus));
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Structured human guidance for DatasourceTestRun validation issues.
3
+ *
4
+ * @fileoverview Preserve actionable issue fields in TTY output
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const chalk = require('chalk');
10
+
11
+ const STEP_VERBS = {
12
+ locate_file: 'open',
13
+ update_path: 'update',
14
+ add_mapping: 'add mapping',
15
+ set_value: 'set',
16
+ remove_value: 'remove',
17
+ add_item: 'add to',
18
+ remove_item: 'remove from'
19
+ };
20
+
21
+ const UPSERT_GUIDANCE = {
22
+ 'DP-CIP-030': {
23
+ observed: 'The declared upsert does not contain the required create, update, and records-output composition.',
24
+ consequence: 'The runtime cannot execute one deterministic create-or-update operation.',
25
+ aiFixable: true,
26
+ remediation: [
27
+ 'Run aifabrix datasource capability upsert <file-or-key> --overwrite --dry-run, review the patch, then apply and validate it.'
28
+ ],
29
+ prevention: 'Author upsert through the capability command and keep both source halves valid.',
30
+ residualRisk: 'Publication remains blocked while the composition is malformed.',
31
+ rerun: 'Rerun the same focused datasource validation or E2E case.'
32
+ },
33
+ 'DP-CIP-031': {
34
+ observed: 'The upsert declares no usable business identity.',
35
+ consequence: 'Existence cannot be resolved, so a retry may create a duplicate or update the wrong record.',
36
+ aiFixable: true,
37
+ remediation: [
38
+ 'Run aifabrix datasource capability upsert <file-or-key> --identity <fields> --dry-run with author-approved mapped non-primary fields.'
39
+ ],
40
+ prevention: 'Model business identity before exposing a composed write.',
41
+ residualRisk: 'Upsert must remain unavailable until identity is explicit and validated.',
42
+ rerun: 'Rerun the same focused datasource validation or E2E case.'
43
+ },
44
+ 'DP-CIP-040': {
45
+ observed: 'No declared read operation can constrain the complete business identity.',
46
+ consequence: 'The source record may be missed, creating a duplicate or selecting the wrong update target.',
47
+ aiFixable: false,
48
+ remediation: [
49
+ 'Declare a real exact lookup only if the source API supports it.',
50
+ 'Otherwise keep separate create and update capabilities and withhold upsert readiness.'
51
+ ],
52
+ prevention: 'Require complete equality lookup coverage before authoring upsert.',
53
+ residualRisk: 'AI cannot repair a source API that lacks exact lookup or uniqueness.',
54
+ rerun: 'After a supported lookup is declared, rerun the same focused validation and E2E proof.'
55
+ },
56
+ 'DP-CIP-041': {
57
+ observed: 'A covering identity read is declared, but live filtering has not been proven.',
58
+ consequence: 'Static coverage alone cannot show that the source applies the constraint or exposes writes in time.',
59
+ aiFixable: false,
60
+ remediation: ['Run the focused live validation and two-write no-duplicate E2E proof; do not add a duplicate read declaration.'],
61
+ prevention: 'Keep exact lookup and no-duplicate proof scenarios with every upsert datasource.',
62
+ residualRisk: 'Read-after-write delay can leave a bounded duplicate window until runtime protection is proven.',
63
+ rerun: 'Rerun the same focused datasource validation and upsert E2E case.'
64
+ }
65
+ };
66
+
67
+ function normalizeOptionalString(value) {
68
+ return typeof value === 'string' ? value.trim() : '';
69
+ }
70
+
71
+ function hasGuidanceValue(value) {
72
+ if (Array.isArray(value)) return value.some(hasGuidanceValue);
73
+ if (typeof value === 'boolean') return true;
74
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
75
+ return normalizeOptionalString(value) !== '';
76
+ }
77
+
78
+ function firstGuidanceValue(...values) {
79
+ return values.find(hasGuidanceValue);
80
+ }
81
+
82
+ function guidancePayload(item) {
83
+ return normalizeOptionalString(item.value) || normalizeOptionalString(item.template);
84
+ }
85
+
86
+ function guidanceVerb(step) {
87
+ return STEP_VERBS[step] || step.replace(/_/g, ' ') || 'update';
88
+ }
89
+
90
+ /**
91
+ * Render one guidance entry, which may be a string or a structured fix instruction.
92
+ *
93
+ * `fix.instructions[]` entries are objects ({step, path, value, template, note}),
94
+ * so stringifying them printed `[object Object]` and the remediation — the part a
95
+ * developer acts on — was lost.
96
+ *
97
+ * @param {unknown} item guidance entry
98
+ * @returns {string|null} display text, or null when there is nothing to show
99
+ */
100
+ function formatGuidanceItem(item) {
101
+ if (typeof item === 'string') return item.trim() || null;
102
+ if (!item || typeof item !== 'object') return null;
103
+
104
+ const step = normalizeOptionalString(item.step);
105
+ const note = normalizeOptionalString(item.note);
106
+ const target = normalizeOptionalString(item.path);
107
+ const payload = guidancePayload(item);
108
+
109
+ // `explain` carries no path or value; its note *is* the instruction.
110
+ if (step === 'explain' || (!target && !payload)) {
111
+ return note || (step ? step.replace(/_/g, ' ') : null);
112
+ }
113
+
114
+ const verb = guidanceVerb(step);
115
+ // With no path the payload *is* the target (e.g. locate_file carries a path in
116
+ // `value`), so it reads as "open <file>" rather than "open: <file>".
117
+ let text = target ? `${verb} ${target}` : `${verb} ${payload}`;
118
+ if (target && payload) text += step === 'set_value' ? ` = ${payload}` : `: ${payload}`;
119
+ return note ? `${text} — ${note}` : text;
120
+ }
121
+
122
+ function appendGuidanceValue(lines, label, value) {
123
+ const values = Array.isArray(value) ? value : [value];
124
+ values
125
+ .map(formatGuidanceItem)
126
+ .filter(Boolean)
127
+ .forEach((text) => {
128
+ lines.push(` ${chalk.gray(`${label}:`)} ${chalk.white(text)}`);
129
+ });
130
+ }
131
+
132
+ function enrichUpsertIssueGuidance(issue) {
133
+ if (!issue || typeof issue !== 'object') return issue;
134
+ const code = issue.stableCode || issue.code;
135
+ const fallback = UPSERT_GUIDANCE[code];
136
+ if (!fallback) return issue;
137
+ const enriched = { ...issue };
138
+ Object.entries(fallback).forEach(([key, value]) => {
139
+ if (!hasGuidanceValue(enriched[key])) enriched[key] = value;
140
+ });
141
+ return enriched;
142
+ }
143
+
144
+ function appendStructuredIssueGuidance(lines, issue) {
145
+ if (!issue || typeof issue !== 'object') return;
146
+ const display = enrichUpsertIssueGuidance(issue);
147
+ appendGuidanceValue(lines, 'Observed', firstGuidanceValue(
148
+ issue.observed, issue.details?.diagnosis, display.observed
149
+ ));
150
+ appendGuidanceValue(lines, 'Consequence', firstGuidanceValue(
151
+ issue.impact, issue.consequence, issue.reason, display.consequence
152
+ ));
153
+ if (typeof display.aiFixable === 'boolean') {
154
+ appendGuidanceValue(lines, 'Can AI fix?', display.aiFixable
155
+ ? 'Yes — only as an authorized deterministic local manifest change.'
156
+ : 'No automatic fix is safe for this source or live-proof limitation.');
157
+ }
158
+ appendGuidanceValue(lines, 'Safe action', firstGuidanceValue(
159
+ issue.fix?.instructions, issue.remediation, issue.hint, display.remediation
160
+ ));
161
+ appendGuidanceValue(lines, 'Prevention', firstGuidanceValue(
162
+ issue.prevention, issue.fix?.prevention, display.prevention
163
+ ));
164
+ appendGuidanceValue(lines, 'Residual risk', firstGuidanceValue(
165
+ issue.residualRisk, issue.risk, display.residualRisk
166
+ ));
167
+ appendGuidanceValue(lines, 'Rerun', firstGuidanceValue(
168
+ issue.rerun, issue.fix?.rerun, display.rerun
169
+ ));
170
+ }
171
+
172
+ module.exports = {
173
+ appendStructuredIssueGuidance,
174
+ enrichUpsertIssueGuidance,
175
+ formatGuidanceItem
176
+ };
@@ -5,6 +5,7 @@
5
5
  'use strict';
6
6
 
7
7
  const chalk = require('chalk');
8
+ const { sanitizeBinaryEvidence } = require('./datasource-binary-evidence');
8
9
  const logger = require('./logger');
9
10
  const { getReportVersionStderrMessage } = require('./datasource-test-run-report-version');
10
11
  const {
@@ -42,6 +43,7 @@ function emitCapabilityScopeDiagnostics(envelope, opts = {}) {
42
43
  * @param {string} [options.requestedCapabilityKey]
43
44
  */
44
45
  function printDatasourceTestRunForTTY(envelope, options = {}) {
46
+ envelope = sanitizeBinaryEvidence(envelope);
45
47
  const mode = resolveDebugDisplayMode(options.debug);
46
48
  const displayOpts = {
47
49
  focusCapabilityKey: options.requestedCapabilityKey,
@@ -96,8 +96,15 @@ function handleDockerClose(code, ctx) {
96
96
  }
97
97
  }
98
98
 
99
- function buildDockerCliArgs(imageName, tag, dockerfilePath, contextPath, buildArgs, noCache) {
99
+ function buildDockerCliArgs(imageName, tag, dockerfilePath, contextPath, buildArgs, options = {}) {
100
+ const { noCache = false, target } = options;
100
101
  const args = ['build', '-t', `${imageName}:${tag}`, '-f', dockerfilePath];
102
+ // A multi-stage Dockerfile builds its last stage unless told otherwise. `build.target`
103
+ // lets one Dockerfile serve several images from the same source, which is the only way
104
+ // an app can ship two variants without duplicating its build.
105
+ if (target) {
106
+ args.push('--target', String(target));
107
+ }
101
108
  for (const [key, value] of Object.entries(buildArgs)) {
102
109
  if (value !== null && value !== undefined && String(value).length > 0) {
103
110
  args.push('--build-arg', `${key}=${String(value)}`);
@@ -111,9 +118,12 @@ function buildDockerCliArgs(imageName, tag, dockerfilePath, contextPath, buildAr
111
118
  }
112
119
 
113
120
  function createDockerSpawnParams(options) {
114
- const { imageName, tag, dockerfilePath, contextPath, buildArgs, noCache, env } = options;
121
+ const { imageName, tag, dockerfilePath, contextPath, buildArgs, noCache, env, target } = options;
115
122
  const spawnEnv = { ...process.env, ...(env || {}) };
116
- const args = buildDockerCliArgs(imageName, tag, dockerfilePath, contextPath, buildArgs || {}, noCache);
123
+ const args = buildDockerCliArgs(imageName, tag, dockerfilePath, contextPath, buildArgs || {}, {
124
+ noCache,
125
+ target
126
+ });
117
127
  return { spawnEnv, args };
118
128
  }
119
129
 
@@ -163,7 +173,8 @@ function runDockerBuildProcess(buildOpts) {
163
173
  reject,
164
174
  env = {},
165
175
  buildArgs = {},
166
- noCache = false
176
+ noCache = false,
177
+ target
167
178
  } = buildOpts;
168
179
  const { spawnEnv, args } = createDockerSpawnParams({
169
180
  imageName,
@@ -172,7 +183,8 @@ function runDockerBuildProcess(buildOpts) {
172
183
  contextPath,
173
184
  buildArgs,
174
185
  noCache,
175
- env
186
+ env,
187
+ target
176
188
  });
177
189
  const dockerProcess = spawn('docker', args, {
178
190
  shell: process.platform === 'win32',
@@ -192,7 +204,12 @@ function runDockerBuildProcess(buildOpts) {
192
204
  * @returns {Promise<void>} Resolves when build completes
193
205
  * @throws {Error} If build fails
194
206
  */
195
- async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs = {}, noCache = false) {
207
+ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs = {}, options = false) {
208
+ // Historically the sixth argument was the `noCache` boolean. It is now an options
209
+ // object so a build stage can be named too; the boolean form still works, because
210
+ // this function is exported and callers outside this module pass it that way.
211
+ const { noCache = false, target } =
212
+ typeof options === 'boolean' ? { noCache: options } : options || {};
196
213
  const spinner = ora({ text: 'Starting Docker build...', spinner: 'dots' }).start();
197
214
  const fsSync = require('fs');
198
215
  const path = require('path');
@@ -228,7 +245,8 @@ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag, b
228
245
  reject,
229
246
  env: dockerCliEnv,
230
247
  buildArgs: resolvedBuildArgs,
231
- noCache
248
+ noCache,
249
+ target
232
250
  });
233
251
  });
234
252
  }
@@ -251,7 +269,10 @@ async function executeBuild(imageName, dockerfilePath, contextPath, tag, options
251
269
  options &&
252
270
  (options.cache === false || options.noCache === true || options['no-cache'] === true)
253
271
  );
254
- await executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs, noCache);
272
+ await executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs, {
273
+ noCache,
274
+ target: options && options.target
275
+ });
255
276
  await applyTagAliases(imageName, tag, resolveTagAliases(tag, options));
256
277
  }
257
278
 
@@ -81,6 +81,41 @@ async function mergeDockerManifestPublishedPort(envVars, appDoc) {
81
81
  envVars[publicPortKey] = String(devIdNum > 0 ? pubBase + devIdNum * 100 : pubBase);
82
82
  }
83
83
 
84
+ /**
85
+ * Publish `<NAME>_PUBLIC_PORT` for every `additionalPorts` entry.
86
+ *
87
+ * An application serving a second protocol needs to tell clients which port to use, and
88
+ * that is the *published* host port, not the one it listens on inside the container. The
89
+ * two differ by the developer-id offset, so a template that types the number by hand
90
+ * silently disagrees with the manifest the moment either changes — and the application
91
+ * then advertises a port nothing is listening on.
92
+ *
93
+ * Named after the port, so `additionalPorts: [{ name: ssh, hostPort: 2200 }]` gives
94
+ * `SSH_PUBLIC_PORT`, which an env.template consumes as `${SSH_PUBLIC_PORT}`.
95
+ *
96
+ * @param {Object} envVars - Mutated map from buildEnvVarMap
97
+ * @param {Object|null|undefined} appDoc - application.yaml root
98
+ * @returns {Promise<void>}
99
+ */
100
+ async function mergeAdditionalPublishedPorts(envVars, appDoc) {
101
+ const entries = appDoc && Array.isArray(appDoc.additionalPorts) ? appDoc.additionalPorts : [];
102
+ if (entries.length === 0) {
103
+ return;
104
+ }
105
+ const devIdNum = await require('./env-map').getDeveloperIdNumber(null);
106
+ for (const entry of entries) {
107
+ if (!entry || typeof entry.name !== 'string') {
108
+ continue;
109
+ }
110
+ const base = Number(entry.hostPort);
111
+ if (!Number.isFinite(base)) {
112
+ continue;
113
+ }
114
+ const key = `${entry.name.trim().toUpperCase().replace(/-/g, '_')}_PUBLIC_PORT`;
115
+ envVars[key] = String(devIdNum > 0 ? base + devIdNum * 100 : base);
116
+ }
117
+ }
118
+
84
119
  /**
85
120
  * Rewrite first line for the matched *_PUBLIC_PORT after interpolateEnvVars (early kv pass may have wrong value).
86
121
  * @param {string} resolved
@@ -111,6 +146,7 @@ function rewriteDockerManifestPublicPortEnvLine(resolved, envVars, appDoc) {
111
146
  module.exports = {
112
147
  findDockerHostKeyForAppKey,
113
148
  publicPortKeyForAppKey,
149
+ mergeAdditionalPublishedPorts,
114
150
  mergeDockerManifestPublishedPort,
115
151
  rewriteDockerManifestPublicPortEnvLine
116
152
  };