@rubytech/create-realagent-code 0.1.615 → 0.1.616

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 (193) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/aeo-llms-txt-writer/dist/__tests__/write-llms-txt.test.d.ts +2 -0
  3. package/payload/platform/lib/aeo-llms-txt-writer/dist/__tests__/write-llms-txt.test.d.ts.map +1 -0
  4. package/payload/platform/lib/aeo-llms-txt-writer/dist/__tests__/write-llms-txt.test.js +291 -0
  5. package/payload/platform/lib/aeo-llms-txt-writer/dist/__tests__/write-llms-txt.test.js.map +1 -0
  6. package/payload/platform/lib/aeo-llms-txt-writer/dist/index.d.ts +13 -1
  7. package/payload/platform/lib/aeo-llms-txt-writer/dist/index.d.ts.map +1 -1
  8. package/payload/platform/lib/aeo-llms-txt-writer/dist/index.js +127 -18
  9. package/payload/platform/lib/aeo-llms-txt-writer/dist/index.js.map +1 -1
  10. package/payload/platform/lib/aeo-llms-txt-writer/src/__tests__/write-llms-txt.test.ts +391 -0
  11. package/payload/platform/lib/aeo-llms-txt-writer/src/index.ts +156 -19
  12. package/payload/platform/lib/aeo-llms-txt-writer/vitest.config.ts +10 -0
  13. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts +15 -1
  14. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/mcp-spawn-tee/dist/index.js +129 -7
  16. package/payload/platform/lib/mcp-spawn-tee/dist/index.js.map +1 -1
  17. package/payload/platform/lib/mcp-spawn-tee/src/__tests__/spawn-tee.test.ts +227 -0
  18. package/payload/platform/lib/mcp-spawn-tee/src/index.ts +133 -6
  19. package/payload/platform/plugins/admin/lib/mcp-spawn-tee/index.js +129 -7
  20. package/payload/platform/plugins/admin/mcp/dist/index.js +11 -3
  21. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/tools/publish-site.d.ts +3 -0
  23. package/payload/platform/plugins/admin/mcp/dist/tools/publish-site.d.ts.map +1 -1
  24. package/payload/platform/plugins/admin/mcp/dist/tools/publish-site.js +38 -20
  25. package/payload/platform/plugins/admin/mcp/dist/tools/publish-site.js.map +1 -1
  26. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +6 -5
  27. package/payload/platform/plugins/admin/skills/publish-site/SKILL.md +3 -0
  28. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  29. package/payload/platform/plugins/aeo/PLUGIN.md +1 -1
  30. package/payload/platform/plugins/aeo/lib/mcp-spawn-tee/index.js +129 -7
  31. package/payload/platform/plugins/aeo/mcp/dist/index.js +17 -10
  32. package/payload/platform/plugins/aeo/mcp/dist/index.js.map +1 -1
  33. package/payload/platform/plugins/browser/lib/mcp-spawn-tee/index.js +129 -7
  34. package/payload/platform/plugins/connector/lib/mcp-spawn-tee/index.js +129 -7
  35. package/payload/platform/plugins/contacts/lib/mcp-spawn-tee/index.js +129 -7
  36. package/payload/platform/plugins/dispatch/lib/mcp-spawn-tee/index.js +129 -7
  37. package/payload/platform/plugins/docs/references/aeo.md +4 -3
  38. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  39. package/payload/platform/plugins/email/lib/mcp-spawn-tee/index.js +129 -7
  40. package/payload/platform/plugins/filesystem/lib/mcp-spawn-tee/index.js +129 -7
  41. package/payload/platform/plugins/google/lib/mcp-spawn-tee/index.js +129 -7
  42. package/payload/platform/plugins/graph-viewer/lib/mcp-spawn-tee/index.js +129 -7
  43. package/payload/platform/plugins/inventory/lib/mcp-spawn-tee/index.js +129 -7
  44. package/payload/platform/plugins/ledger/lib/mcp-spawn-tee/index.js +129 -7
  45. package/payload/platform/plugins/memory/lib/mcp-spawn-tee/index.js +129 -7
  46. package/payload/platform/plugins/outlook/lib/mcp-spawn-tee/index.js +129 -7
  47. package/payload/platform/plugins/quickbooks/lib/mcp-spawn-tee/index.js +129 -7
  48. package/payload/platform/plugins/replicate/lib/mcp-spawn-tee/index.js +129 -7
  49. package/payload/platform/plugins/scheduling/PLUGIN.md +3 -4
  50. package/payload/platform/plugins/scheduling/lib/mcp-spawn-tee/index.js +129 -7
  51. package/payload/platform/plugins/scheduling/mcp/dist/index.js +0 -6
  52. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js +65 -20
  54. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js.map +1 -1
  55. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-classification.test.js +20 -0
  56. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-classification.test.js.map +1 -1
  57. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +12 -4
  58. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  59. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  60. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -1
  61. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +39 -6
  62. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -1
  63. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js +21 -6
  64. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js.map +1 -1
  65. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +26 -5
  66. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  67. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-waiver.test.js +17 -51
  68. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-waiver.test.js.map +1 -1
  69. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +6 -4
  70. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  71. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +113 -1
  72. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  73. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +8 -18
  74. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  75. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +14 -27
  76. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  77. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  78. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +125 -56
  79. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  80. package/payload/platform/plugins/storage-broker/lib/mcp-spawn-tee/index.js +129 -7
  81. package/payload/platform/plugins/url-get/lib/mcp-spawn-tee/index.js +129 -7
  82. package/payload/platform/plugins/voice-mirror/lib/mcp-spawn-tee/index.js +129 -7
  83. package/payload/platform/plugins/whatsapp/lib/mcp-spawn-tee/index.js +129 -7
  84. package/payload/platform/plugins/work/lib/mcp-spawn-tee/index.js +129 -7
  85. package/payload/platform/plugins/workflows/lib/mcp-spawn-tee/index.js +129 -7
  86. package/payload/platform/services/claude-session-manager/dist/index.js +17 -0
  87. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/mcp-helper-census.d.ts +80 -0
  89. package/payload/platform/services/claude-session-manager/dist/mcp-helper-census.d.ts.map +1 -0
  90. package/payload/platform/services/claude-session-manager/dist/mcp-helper-census.js +201 -0
  91. package/payload/platform/services/claude-session-manager/dist/mcp-helper-census.js.map +1 -0
  92. package/payload/premium-plugins/real-agent/plugins/buyers/lib/mcp-spawn-tee/index.js +129 -7
  93. package/payload/premium-plugins/real-agent/plugins/estate-sales/lib/mcp-spawn-tee/index.js +129 -7
  94. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +12 -0
  95. package/payload/premium-plugins/real-agent/plugins/loop/lib/mcp-spawn-tee/index.js +129 -7
  96. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +13 -0
  97. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  98. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/field-census.d.ts +22 -0
  99. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/field-census.d.ts.map +1 -0
  100. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/field-census.js +81 -0
  101. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/field-census.js.map +1 -0
  102. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +17 -5
  103. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  104. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +90 -17
  105. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  106. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-schema-keys.d.ts +2 -0
  107. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-schema-keys.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-schema-keys.js +1010 -0
  109. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-schema-keys.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js +2 -2
  111. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/auto-responder.js.map +1 -1
  112. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/customer-preferences.js +3 -3
  113. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/customer-preferences.js.map +1 -1
  114. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/feedback.d.ts +1 -0
  115. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/feedback.d.ts.map +1 -1
  116. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/feedback.js +5 -5
  117. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/feedback.js.map +1 -1
  118. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match-batch.d.ts +1 -0
  119. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match-batch.d.ts.map +1 -1
  120. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match-batch.js +2 -2
  121. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match-batch.js.map +1 -1
  122. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match.d.ts +1 -0
  123. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match.d.ts.map +1 -1
  124. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match.js +4 -4
  125. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/marketing-match.js.map +1 -1
  126. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-detail.d.ts +1 -0
  127. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-detail.d.ts.map +1 -1
  128. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-detail.js +3 -3
  129. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-detail.js.map +1 -1
  130. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-search.d.ts +1 -0
  131. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-search.d.ts.map +1 -1
  132. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-search.js +3 -3
  133. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/people-search.js.map +1 -1
  134. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-detail.d.ts +1 -0
  135. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-detail.d.ts.map +1 -1
  136. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-detail.js +13 -6
  137. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-detail.js.map +1 -1
  138. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts +1 -0
  139. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.d.ts.map +1 -1
  140. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js +3 -3
  141. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-listed.js.map +1 -1
  142. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-search.d.ts +1 -0
  143. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-search.d.ts.map +1 -1
  144. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-search.js +3 -3
  145. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-search.js.map +1 -1
  146. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts +1 -0
  147. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.d.ts.map +1 -1
  148. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js +3 -3
  149. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/property-sold.js.map +1 -1
  150. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/supplier.js +4 -4
  151. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/supplier.js.map +1 -1
  152. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-availability.d.ts +1 -0
  153. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-availability.d.ts.map +1 -1
  154. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-availability.js +3 -3
  155. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-availability.js.map +1 -1
  156. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-info.d.ts +1 -0
  157. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-info.d.ts.map +1 -1
  158. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-info.js +3 -3
  159. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/team-info.js.map +1 -1
  160. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-detail.d.ts +1 -0
  161. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-detail.d.ts.map +1 -1
  162. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-detail.js +3 -3
  163. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-detail.js.map +1 -1
  164. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-search.d.ts +1 -0
  165. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-search.d.ts.map +1 -1
  166. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-search.js +3 -3
  167. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/viewing-search.js.map +1 -1
  168. package/payload/premium-plugins/real-agent/plugins/loop/mcp/package.json +2 -1
  169. package/payload/premium-plugins/real-agent/plugins/loop/mcp/scripts/generate-loop-schema-keys.mjs +87 -0
  170. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/new-tool-shape.test.ts +1 -1
  171. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/read-fidelity.test.ts +303 -0
  172. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/__tests__/swagger-write-coverage.test.ts +6 -6
  173. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +13 -0
  174. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/field-census.ts +86 -0
  175. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +116 -17
  176. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-schema-keys.ts +1010 -0
  177. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/auto-responder.ts +2 -2
  178. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/customer-preferences.ts +3 -3
  179. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/feedback.ts +6 -4
  180. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/marketing-match-batch.ts +4 -2
  181. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/marketing-match.ts +6 -4
  182. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/people-detail.ts +5 -3
  183. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/people-search.ts +5 -3
  184. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-detail.ts +17 -7
  185. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-listed.ts +5 -3
  186. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-search.ts +5 -3
  187. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/property-sold.ts +5 -3
  188. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/supplier.ts +4 -4
  189. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/team-availability.ts +5 -3
  190. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/team-info.ts +6 -3
  191. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/viewing-detail.ts +5 -3
  192. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/viewing-search.ts +5 -3
  193. package/payload/premium-plugins/real-agent/plugins/property-data/lib/mcp-spawn-tee/index.js +129 -7
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Read fidelity and API version provenance (Task 2792).
3
+ *
4
+ * Two defects these tests pin:
5
+ *
6
+ * 1. A prose formatter guards on truthiness, so a value of 0, false, "" or
7
+ * "None" renders identically to a field nobody entered. A pre-flight that
8
+ * must chase a missing service charge cannot be built on that.
9
+ * 2. Nothing named the API version, so a capability audit recorded "we are on
10
+ * v1" against a client that has always been v2.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { readFileSync, rmSync, statSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { resolve, dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { LOOP_API_VERSION, formatAggregationResult, formatSingleTeamRead } from '../lib/loop-api.js';
20
+ import type { AggregationResult } from '../lib/loop-api.js';
21
+ import { LOOP_SCHEMA_KEYS } from '../lib/loop-schema-keys.js';
22
+ import {
23
+ censusRecords,
24
+ falsyPresentKeys,
25
+ matchPathPattern,
26
+ normalisePath,
27
+ wireKeys,
28
+ } from '../lib/field-census.js';
29
+
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+
32
+ function emptyResult<T>(over: Partial<AggregationResult<T>> = {}): AggregationResult<T> {
33
+ return { results: [], failures: [], skipped: [], totalTeams: 0, ...over };
34
+ }
35
+
36
+ describe('API version provenance', () => {
37
+ it('matches the vendored swagger snapshot', () => {
38
+ const snapshot = JSON.parse(
39
+ readFileSync(resolve(__dirname, 'loop-swagger.snapshot.json'), 'utf-8'),
40
+ ) as { info: { title: string } };
41
+ // "Loop API V2 Documentation" -> v2
42
+ const fromSnapshot = /\bV(\d+)\b/.exec(snapshot.info.title);
43
+ expect(fromSnapshot, 'snapshot info.title carries no version token').not.toBeNull();
44
+ expect(LOOP_API_VERSION).toBe(`v${fromSnapshot![1]}`);
45
+ });
46
+
47
+ it('names the version on the no-teams branch', () => {
48
+ const out = formatAggregationResult(emptyResult(), () => '', 'properties');
49
+ expect(out).toContain(LOOP_API_VERSION);
50
+ });
51
+
52
+ it('names the version on the all-failed branch', () => {
53
+ const out = formatAggregationResult(
54
+ emptyResult({ totalTeams: 1, failures: [{ teamName: 'A', reason: 'boom', kind: 'other' }] }),
55
+ () => '',
56
+ 'properties',
57
+ );
58
+ expect(out).toContain(LOOP_API_VERSION);
59
+ });
60
+
61
+ it('names the version on the no-results branch', () => {
62
+ const out = formatAggregationResult(
63
+ emptyResult({ totalTeams: 1, results: [{ teamName: 'A', data: [] }] }),
64
+ () => '',
65
+ 'properties',
66
+ );
67
+ expect(out).toContain(LOOP_API_VERSION);
68
+ });
69
+
70
+ it('names the version on the records branch', () => {
71
+ const out = formatAggregationResult(
72
+ emptyResult({ totalTeams: 1, results: [{ teamName: 'A', data: [{ id: 1 }] }] }),
73
+ (r: { id: number }) => `- ${r.id}`,
74
+ 'properties',
75
+ );
76
+ expect(out).toContain(LOOP_API_VERSION);
77
+ });
78
+ });
79
+
80
+ describe('generated schema key map', () => {
81
+ const MAP_PATH = resolve(__dirname, '../lib/loop-schema-keys.ts');
82
+ const GENERATOR = resolve(__dirname, '../../scripts/generate-loop-schema-keys.mjs');
83
+
84
+ it('is byte-identical to what the generator produces from the current snapshot', () => {
85
+ // Written to a temp path, never over the committed map: a check that
86
+ // destroys the file it is checking cannot report what it found.
87
+ const tmp = join(tmpdir(), `loop-schema-keys-${process.pid}.ts`);
88
+ try {
89
+ execFileSync('node', [GENERATOR], {
90
+ stdio: 'pipe',
91
+ env: { ...process.env, LOOP_SCHEMA_KEYS_OUT: tmp },
92
+ });
93
+ expect(readFileSync(tmp, 'utf-8')).toBe(readFileSync(MAP_PATH, 'utf-8'));
94
+ } finally {
95
+ rmSync(tmp, { force: true });
96
+ }
97
+ });
98
+
99
+ it('carries the property detail path with the fields the audit called absent', () => {
100
+ const keys = LOOP_SCHEMA_KEYS['/property/residential/sales/{id}'];
101
+ expect(keys, 'sales property detail path missing from map').toBeDefined();
102
+ for (const field of ['tenure', 'serviceCharge', 'groundRent', 'saleBy', 'amlVerified']) {
103
+ expect(keys).toContain(field);
104
+ }
105
+ });
106
+
107
+ it('stays small enough for the runtime to import', () => {
108
+ // The runtime imports this module on every read; the 711KB snapshot it
109
+ // derives from is a test fixture and must never be what gets parsed.
110
+ // (Whether the bundle also copies __tests__ into the payload is Task 2795
111
+ // and does not bear on this.)
112
+ expect(statSync(MAP_PATH).size).toBeLessThan(100_000);
113
+ });
114
+ });
115
+
116
+ describe('raw mode', () => {
117
+ const result = {
118
+ results: [{ teamName: 'A', data: [{ id: 1, serviceCharge: 0, undeclaredByAnyInterface: 'x' }] }],
119
+ failures: [],
120
+ skipped: [],
121
+ totalTeams: 1,
122
+ };
123
+
124
+ it('emits keys the prose formatter drops', () => {
125
+ const out = formatAggregationResult(result, () => '- prose only', 'properties', { raw: true });
126
+ expect(out).toContain('serviceCharge');
127
+ expect(out).toContain('0');
128
+ });
129
+
130
+ it('emits a key no interface declares', () => {
131
+ // Raw serialises the wire payload, not the TypeScript interface.
132
+ const out = formatAggregationResult(result, () => '- prose only', 'properties', { raw: true });
133
+ expect(out).toContain('undeclaredByAnyInterface');
134
+ });
135
+
136
+ it('leaves default output as prose', () => {
137
+ const out = formatAggregationResult(result, () => '- prose only', 'properties');
138
+ expect(out).toContain('- prose only');
139
+ expect(out).not.toContain('undeclaredByAnyInterface');
140
+ });
141
+
142
+ it('still names the version in raw mode', () => {
143
+ const out = formatAggregationResult(result, () => '- prose only', 'properties', { raw: true });
144
+ expect(out).toContain(LOOP_API_VERSION);
145
+ });
146
+ });
147
+
148
+ describe('every Loop read names its API version', () => {
149
+ // The coverage set is "tools that read from Loop", not "tools routed through
150
+ // formatAggregationResult". Defining it the second way is what left four
151
+ // reads behind: the withTeamKey tools format their own output and named no
152
+ // version at all, and a test enumerating only the twelve stayed green over
153
+ // the gap.
154
+ const SINGLE_TEAM_READS = [
155
+ { tool: 'loop-feedback-get', source: 'tools/feedback.ts' },
156
+ { tool: 'loop-auto-responder', source: 'tools/auto-responder.ts' },
157
+ { tool: 'loop-supplier', source: 'tools/supplier.ts' },
158
+ { tool: 'loop-customer-preferences', source: 'tools/customer-preferences.ts' },
159
+ ];
160
+
161
+ it.each(SINGLE_TEAM_READS)('$tool stamps the version on its read output', ({ source }) => {
162
+ const src = readFileSync(resolve(__dirname, '..', source), 'utf-8');
163
+ expect(src, `${source} returns un-versioned output`).toContain('formatSingleTeamRead(');
164
+ });
165
+
166
+ it('leaves no read returning bare JSON.stringify output', () => {
167
+ for (const { source } of SINGLE_TEAM_READS) {
168
+ const src = readFileSync(resolve(__dirname, '..', source), 'utf-8');
169
+ expect(src, `${source} still returns an unstamped payload`).not.toMatch(
170
+ /return JSON\.stringify\(result, null, 2\);/,
171
+ );
172
+ }
173
+ });
174
+
175
+ it('stamps the version in both raw and prose form', () => {
176
+ expect(formatSingleTeamRead({ a: 0 }, 'prose')).toContain(LOOP_API_VERSION);
177
+ expect(formatSingleTeamRead({ a: 0 }, 'prose', { raw: true })).toContain(LOOP_API_VERSION);
178
+ });
179
+
180
+ it('shows a zero in raw form that the prose dropped', () => {
181
+ const raw = formatSingleTeamRead({ rating: 0 }, 'no rating given', { raw: true });
182
+ expect(raw).toContain('rating');
183
+ expect(raw).toContain('0');
184
+ });
185
+ });
186
+
187
+ describe('raw parameter coverage', () => {
188
+ // Every read routed through formatAggregationResult must offer the mode.
189
+ // A read that cannot return its raw field set is a read the pre-flight
190
+ // cannot trust, and the gap would be silent without this.
191
+ const READ_TOOLS = [
192
+ 'loop-feedback-get',
193
+ 'loop-property-detail',
194
+ 'loop-property-search',
195
+ 'loop-property-listed',
196
+ 'loop-property-sold',
197
+ 'loop-people-detail',
198
+ 'loop-people-search',
199
+ 'loop-viewing-detail',
200
+ 'loop-viewing-search',
201
+ 'loop-marketing-match',
202
+ 'loop-marketing-match-batch',
203
+ 'loop-team-info',
204
+ 'loop-team-availability',
205
+ ];
206
+
207
+ it.each(READ_TOOLS)('%s declares the raw parameter', (tool) => {
208
+ const src = readFileSync(resolve(__dirname, '../index.ts'), 'utf-8');
209
+ const start = src.indexOf(`"${tool}"`);
210
+ expect(start, `${tool} not registered`).toBeGreaterThan(-1);
211
+ // Bound on this tool's own registration, not a character count: a fixed
212
+ // window spills into the next lifelineTool call and a missing declaration
213
+ // reads as present because its neighbour has one.
214
+ const next = src.indexOf('lifelineTool(server,', start);
215
+ const block = src.slice(start, next === -1 ? src.length : next);
216
+ expect(block, `${tool} has no raw parameter`).toContain('raw: z.boolean().optional()');
217
+ });
218
+ });
219
+
220
+ describe('property detail field fidelity', () => {
221
+ // The renderer is a closure inside propertyDetail, so the guards are
222
+ // asserted at their source. They are the defect.
223
+ const SRC_PATH = resolve(__dirname, '../tools/property-detail.ts');
224
+
225
+ it('does not gate the money fields on truthiness', () => {
226
+ const src = readFileSync(SRC_PATH, 'utf-8');
227
+ expect(src, 'serviceCharge still behind a truthiness guard').not.toContain(
228
+ 'if (p.serviceCharge)',
229
+ );
230
+ expect(src, 'groundRent still behind a truthiness guard').not.toContain('if (p.groundRent)');
231
+ expect(src, 'tenure still dropped when None').not.toContain('p.tenure !== "None"');
232
+ });
233
+
234
+ it('declares and renders the two fields the audit called unavailable', () => {
235
+ const src = readFileSync(SRC_PATH, 'utf-8');
236
+ expect(src, 'sale method not declared').toContain('saleBy');
237
+ expect(src, 'money-laundering check status not declared').toContain('amlVerified');
238
+ });
239
+ });
240
+
241
+ describe('field census primitives', () => {
242
+ it('strips the query string before matching', () => {
243
+ expect(normalisePath('/property/residential/sales?MinPrice=1')).toBe(
244
+ '/property/residential/sales',
245
+ );
246
+ });
247
+
248
+ it('matches a live id-bearing path to its swagger pattern', () => {
249
+ const patterns = ['/property/residential/sales/{id}', '/property/residential/sales'];
250
+ expect(matchPathPattern('/property/residential/sales/4821', patterns)).toBe(
251
+ '/property/residential/sales/{id}',
252
+ );
253
+ });
254
+
255
+ it('prefers the literal pattern over the templated one', () => {
256
+ const patterns = ['/property/residential/sales/{id}', '/property/residential/sales'];
257
+ expect(matchPathPattern('/property/residential/sales', patterns)).toBe(
258
+ '/property/residential/sales',
259
+ );
260
+ });
261
+
262
+ it('matches a searched list path carrying a query string', () => {
263
+ const patterns = ['/property/residential/sales/{id}', '/property/residential/sales'];
264
+ expect(matchPathPattern('/property/residential/sales?SearchTerm=oak', patterns)).toBe(
265
+ '/property/residential/sales',
266
+ );
267
+ });
268
+
269
+ it('returns null for a path no pattern covers', () => {
270
+ expect(matchPathPattern('/nope/at/all', ['/property/residential/sales'])).toBeNull();
271
+ });
272
+
273
+ it('names every falsy-but-present key and no absent one', () => {
274
+ const rec = {
275
+ serviceCharge: 0,
276
+ groundRent: 0,
277
+ tenure: 'None',
278
+ alarm: false,
279
+ features: [],
280
+ price: 250000,
281
+ };
282
+ const found = falsyPresentKeys(rec);
283
+ expect(found).toEqual(['alarm', 'features', 'groundRent', 'serviceCharge', 'tenure']);
284
+ expect(found).not.toContain('price');
285
+ expect(found).not.toContain('saleBy');
286
+ });
287
+
288
+ it('treats null as absent, not falsy-present', () => {
289
+ // Loop saying nothing is a different fact from Loop saying zero.
290
+ expect(falsyPresentKeys({ serviceCharge: null })).toEqual([]);
291
+ });
292
+
293
+ it('unions keys across records', () => {
294
+ expect(wireKeys([{ a: 1 }, { b: 2 }])).toEqual(['a', 'b']);
295
+ });
296
+
297
+ it('normalises a single object, an array, and a non-object payload', () => {
298
+ expect(censusRecords({ a: 1 })).toEqual([{ a: 1 }]);
299
+ expect(censusRecords([{ a: 1 }])).toEqual([{ a: 1 }]);
300
+ expect(censusRecords('nope')).toEqual([]);
301
+ expect(censusRecords(null)).toEqual([]);
302
+ });
303
+ });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 1. Loop adds a new POST/PUT/PATCH/DELETE → no mapping → test fails
7
7
  * and names the orphan endpoint.
8
- * 2. A tool's `server.tool("…")` registration is renamed or removed →
8
+ * 2. A tool's `lifelineTool(server, "…")` registration is renamed or removed →
9
9
  * the name lookup against `index.ts` source fails.
10
10
  * 3. A tool's source file is refactored such that the swagger path
11
11
  * family no longer appears literally → the path-fragment lookup
@@ -120,15 +120,15 @@ describe('Loop swagger write coverage', () => {
120
120
  });
121
121
 
122
122
  // 2. Every mapping's tool is registered in index.ts (catches deregistration).
123
- // Match by quoted tool name in proximity to a `server.tool(` call —
124
- // each registration is `server.tool(\n "<name>", ...)` or single-line.
123
+ // Match by quoted tool name in proximity to a `lifelineTool(` call —
124
+ // each registration is `lifelineTool(server,\n "<name>", ...)` or single-line.
125
125
  // A tool name that appears only in a comment or unrelated string doesn't
126
126
  // match because we require it to be the first non-whitespace argument
127
- // of a server.tool( invocation within ~32 chars.
127
+ // of a lifelineTool( invocation within ~32 chars.
128
128
  it.each(ENDPOINT_MAP)('$method $path → tool $tool registered in index.ts', ({ tool }) => {
129
- const registrationRe = new RegExp(`server\\.tool\\(\\s*"${tool.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\\\$&')}"`);
129
+ const registrationRe = new RegExp(`lifelineTool\\(\\s*server\\s*,\\s*"${tool.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\\\$&')}"`);
130
130
  const isRegistered = registrationRe.test(indexSrc);
131
- expect(isRegistered, `tool "${tool}" not registered as server.tool("${tool}", ...) in src/index.ts`).toBe(true);
131
+ expect(isRegistered, `tool "${tool}" not registered as lifelineTool(server, "${tool}", ...) in src/index.ts`).toBe(true);
132
132
  });
133
133
 
134
134
  // 3. Every mapping's path fragment appears in the named source file
@@ -149,6 +149,7 @@ lifelineTool(server,
149
149
  endDate: z.string().optional().describe("End date filter (sellers/landlords)"),
150
150
  teamName: z.string().optional().describe("Query a specific team only"),
151
151
  limit: z.number().int().optional().describe("Max results (default 200)"),
152
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
152
153
  },
153
154
  toolHandler(async (p) => peopleSearch(p as Parameters<typeof peopleSearch>[0]))
154
155
  );
@@ -160,6 +161,7 @@ lifelineTool(server,
160
161
  personId: z.number().int().describe("Person ID"),
161
162
  role: z.enum(["buyers", "sellers", "renters", "landlords"]).optional().describe("Role for role-specific detail view"),
162
163
  teamName: z.string().optional().describe("Query a specific team only"),
164
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
163
165
  },
164
166
  toolHandler(async (p) => peopleDetail(p as Parameters<typeof peopleDetail>[0]))
165
167
  );
@@ -182,6 +184,7 @@ lifelineTool(server,
182
184
  propertyTypes: z.string().optional().describe("Comma-separated property types"),
183
185
  teamName: z.string().optional().describe("Query a specific team only"),
184
186
  limit: z.number().int().optional().describe("Max results (default 200)"),
187
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
185
188
  },
186
189
  toolHandler(async (p) => propertySearch(p as Parameters<typeof propertySearch>[0]))
187
190
  );
@@ -194,6 +197,7 @@ lifelineTool(server,
194
197
  department: z.enum(["sales", "lettings"]).describe("sales or lettings"),
195
198
  previewHash: z.number().int().optional().describe("Preview hash for public preview URL"),
196
199
  teamName: z.string().optional().describe("Query a specific team only"),
200
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
197
201
  },
198
202
  toolHandler(async (p) => propertyDetail(p as Parameters<typeof propertyDetail>[0]))
199
203
  );
@@ -206,6 +210,7 @@ lifelineTool(server,
206
210
  department: z.enum(["sales", "lettings", "both"]).optional().describe("Department (default: both)"),
207
211
  teamName: z.string().optional().describe("Query a specific team only"),
208
212
  limit: z.number().int().optional().describe("Max results (default 200)"),
213
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
209
214
  },
210
215
  toolHandler(async (p) => propertyListed(p as Parameters<typeof propertyListed>[0]))
211
216
  );
@@ -217,6 +222,7 @@ lifelineTool(server,
217
222
  channel: z.enum(["rightmove", "onTheMarket", "zoopla", "website"]).describe("Listing channel"),
218
223
  teamName: z.string().optional().describe("Query a specific team only"),
219
224
  limit: z.number().int().optional().describe("Max results (default 200)"),
225
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
220
226
  },
221
227
  toolHandler(async (p) => propertySold(p as Parameters<typeof propertySold>[0]))
222
228
  );
@@ -285,6 +291,7 @@ lifelineTool(server,
285
291
  status: z.string().optional().describe("Viewing status filter"),
286
292
  teamName: z.string().optional().describe("Query a specific team only"),
287
293
  limit: z.number().int().optional().describe("Max results (default 200)"),
294
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
288
295
  },
289
296
  toolHandler(async (p) => viewingSearch(p as Parameters<typeof viewingSearch>[0]))
290
297
  );
@@ -296,6 +303,7 @@ lifelineTool(server,
296
303
  viewingId: z.number().int().describe("Viewing ID"),
297
304
  department: z.enum(["sales", "lettings"]).describe("sales or lettings"),
298
305
  teamName: z.string().optional().describe("Query a specific team only"),
306
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
299
307
  },
300
308
  toolHandler(async (p) => viewingDetail(p as Parameters<typeof viewingDetail>[0]))
301
309
  );
@@ -341,6 +349,7 @@ lifelineTool(server,
341
349
  teamName: z.string().min(1).describe("Team that owns the viewing"),
342
350
  viewingId: z.number().int().describe("Viewing ID"),
343
351
  department: z.enum(["sales", "lettings"]).describe("sales or lettings"),
352
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
344
353
  },
345
354
  toolHandler(async (p) => feedbackGet(p as Parameters<typeof feedbackGet>[0]))
346
355
  );
@@ -366,6 +375,7 @@ lifelineTool(server,
366
375
  "Get team details from Loop CRM. Returns team name, address, phone, and email.",
367
376
  {
368
377
  teamName: z.string().optional().describe("Query a specific team only (omit for all teams)"),
378
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
369
379
  },
370
380
  toolHandler(async (p) => teamInfo(p as Parameters<typeof teamInfo>[0]))
371
381
  );
@@ -377,6 +387,7 @@ lifelineTool(server,
377
387
  agentId: z.string().describe("Agent GUID identifier"),
378
388
  searchDate: z.string().describe("Date to check availability (YYYY-MM-DD)"),
379
389
  teamName: z.string().optional().describe("Query a specific team only"),
390
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
380
391
  },
381
392
  toolHandler(async (p) => teamAvailability(p as Parameters<typeof teamAvailability>[0]))
382
393
  );
@@ -393,6 +404,7 @@ lifelineTool(server,
393
404
  department: z.enum(["sales", "lettings"]).describe("sales or lettings"),
394
405
  includeTeamProfile: z.boolean().optional().describe("Include the team profile for this match (default: false)"),
395
406
  teamName: z.string().optional().describe("Query a specific team only"),
407
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
396
408
  },
397
409
  toolHandler(async (p) => marketingMatchDetail(p as Parameters<typeof marketingMatchDetail>[0]))
398
410
  );
@@ -404,6 +416,7 @@ lifelineTool(server,
404
416
  propertyIds: z.array(z.number().int()).describe("Array of property IDs to match"),
405
417
  department: z.enum(["sales", "lettings"]).describe("sales or lettings"),
406
418
  teamName: z.string().optional().describe("Query a specific team only"),
419
+ raw: z.boolean().optional().describe("Return the raw field set received from Loop instead of prose. Use when a decision turns on whether a field was supplied at all: a key present carrying 0 is a zero, a key absent is one Loop did not send."),
407
420
  },
408
421
  toolHandler(async (p) => marketingMatchBatch(p as Parameters<typeof marketingMatchBatch>[0]))
409
422
  );
@@ -0,0 +1,86 @@
1
+ // Per-request read fidelity signals.
2
+ //
3
+ // A truthiness guard in a formatter drops any value that is 0, false, "" or
4
+ // "None", so a caller cannot tell a zero service charge from one nobody
5
+ // entered. `falsyPresentKeys` names exactly that set, which is what makes the
6
+ // loss visible without guessing what a formatter emitted.
7
+ //
8
+ // Guessing was the alternative and all three forms of it are worse. Tracking
9
+ // key access with a proxy gives the wrong answer by construction, because
10
+ // `if (p.serviceCharge)` reads the key and then discards it. Searching the
11
+ // output string for each value is fragile, since 1250000 prints as £1,250,000.
12
+ // Rewriting all twelve formatters around a shared emitter would be exact and
13
+ // is far outside this change.
14
+
15
+ export function normalisePath(path: string): string {
16
+ const q = path.indexOf("?");
17
+ return q === -1 ? path : path.slice(0, q);
18
+ }
19
+
20
+ function patternToRegExp(pattern: string): RegExp {
21
+ const escaped = pattern
22
+ .split("/")
23
+ .map((seg) =>
24
+ /^\{.+\}$/.test(seg) ? "[^/]+" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
25
+ )
26
+ .join("/");
27
+ return new RegExp(`^${escaped}$`);
28
+ }
29
+
30
+ /**
31
+ * Resolve a live request path to the swagger pattern that describes it.
32
+ * Literal patterns win over templated ones, so a concrete path never resolves
33
+ * to a templated sibling that also happens to match.
34
+ */
35
+ export function matchPathPattern(path: string, patterns: string[]): string | null {
36
+ const live = normalisePath(path);
37
+ const exact = patterns.find((p) => p === live);
38
+ if (exact) return exact;
39
+ for (const p of patterns.filter((c) => c.includes("{"))) {
40
+ if (patternToRegExp(p).test(live)) return p;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Normalise a payload to the records the census walks. A Loop read returns
47
+ * either one object or an array of them; anything else carries no fields to
48
+ * census and yields nothing rather than a misleading zero-key record.
49
+ */
50
+ export function censusRecords(payload: unknown): Record<string, unknown>[] {
51
+ if (Array.isArray(payload)) {
52
+ return payload.filter(
53
+ (r): r is Record<string, unknown> =>
54
+ r !== null && typeof r === "object" && !Array.isArray(r)
55
+ );
56
+ }
57
+ if (payload !== null && typeof payload === "object") {
58
+ return [payload as Record<string, unknown>];
59
+ }
60
+ return [];
61
+ }
62
+
63
+ export function wireKeys(payload: unknown): string[] {
64
+ const out = new Set<string>();
65
+ for (const r of censusRecords(payload)) {
66
+ for (const k of Object.keys(r)) out.add(k);
67
+ }
68
+ return [...out].sort();
69
+ }
70
+
71
+ /**
72
+ * A key is falsy-present when it arrived carrying a value every truthiness
73
+ * guard discards. `null` and `undefined` are NOT included: those are Loop
74
+ * saying nothing, which is a different fact from Loop saying zero, and
75
+ * collapsing the two would rebuild the defect this exists to expose.
76
+ */
77
+ export function falsyPresentKeys(payload: unknown): string[] {
78
+ const out = new Set<string>();
79
+ for (const r of censusRecords(payload)) {
80
+ for (const [k, v] of Object.entries(r)) {
81
+ if (v === 0 || v === false || v === "" || v === "None") out.add(k);
82
+ else if (Array.isArray(v) && v.length === 0) out.add(k);
83
+ }
84
+ }
85
+ return [...out].sort();
86
+ }