@intentius/chant 0.124.45 → 0.124.46

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 (190) hide show
  1. package/dist/capabilities/aws.paths +148 -0
  2. package/dist/capabilities/base-mappings.json +122 -0
  3. package/dist/capabilities/catalog.d.ts +89 -0
  4. package/dist/capabilities/catalog.d.ts.map +1 -0
  5. package/dist/capabilities/catalog.js +204 -0
  6. package/dist/capabilities/language.d.ts +103 -0
  7. package/dist/capabilities/language.d.ts.map +1 -0
  8. package/dist/capabilities/language.js +335 -0
  9. package/dist/capabilities/matcher.d.ts +65 -0
  10. package/dist/capabilities/matcher.d.ts.map +1 -0
  11. package/dist/capabilities/matcher.js +299 -0
  12. package/dist/capabilities/view.d.ts +207 -0
  13. package/dist/capabilities/view.d.ts.map +1 -0
  14. package/dist/capabilities/view.js +484 -0
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/lint.js +1 -0
  17. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  18. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  19. package/dist/cli/handlers/declared-graph.js +87 -0
  20. package/dist/cli/handlers/graph.d.ts +25 -0
  21. package/dist/cli/handlers/graph.d.ts.map +1 -1
  22. package/dist/cli/handlers/graph.js +93 -87
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  24. package/dist/cli/handlers/pr-env.js +2 -1
  25. package/dist/cli/handlers/search-count.d.ts +89 -0
  26. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  27. package/dist/cli/handlers/search-count.js +132 -0
  28. package/dist/cli/handlers/search.d.ts +358 -6
  29. package/dist/cli/handlers/search.d.ts.map +1 -1
  30. package/dist/cli/handlers/search.js +1065 -155
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/main.js +23 -3
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/server.js +3 -0
  35. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  36. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  37. package/dist/cli/mcp/tools/capabilities.js +27 -0
  38. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  39. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  40. package/dist/cli/mcp/workspace-tools.js +47 -2
  41. package/dist/cli/parse-args.d.ts.map +1 -1
  42. package/dist/cli/parse-args.js +64 -17
  43. package/dist/cli/registry.d.ts +22 -0
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/components/pr-env-ports.d.ts +29 -0
  46. package/dist/components/pr-env-ports.d.ts.map +1 -0
  47. package/dist/components/pr-env-ports.js +77 -0
  48. package/dist/components/pr-env-run.d.ts +5 -21
  49. package/dist/components/pr-env-run.d.ts.map +1 -1
  50. package/dist/components/pr-env-run.js +6 -70
  51. package/dist/components/pr-environment.d.ts +2 -1
  52. package/dist/components/pr-environment.d.ts.map +1 -1
  53. package/dist/components/pr-environment.js +1 -1
  54. package/dist/config-environments.d.ts +92 -0
  55. package/dist/config-environments.d.ts.map +1 -0
  56. package/dist/config-environments.js +102 -0
  57. package/dist/config.d.ts +24 -84
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +9 -96
  60. package/dist/graph-detail.d.ts.map +1 -1
  61. package/dist/graph-detail.js +15 -3
  62. package/dist/graph-effective.d.ts +33 -2
  63. package/dist/graph-effective.d.ts.map +1 -1
  64. package/dist/graph-effective.js +215 -68
  65. package/dist/graph-ir.d.ts +23 -0
  66. package/dist/graph-ir.d.ts.map +1 -1
  67. package/dist/graph-ir.js +130 -0
  68. package/dist/graph-sub-template.d.ts +11 -0
  69. package/dist/graph-sub-template.d.ts.map +1 -0
  70. package/dist/graph-sub-template.js +19 -0
  71. package/dist/lexicon.d.ts +9 -0
  72. package/dist/lexicon.d.ts.map +1 -1
  73. package/dist/lifecycle/observe.d.ts +28 -0
  74. package/dist/lifecycle/observe.d.ts.map +1 -1
  75. package/dist/lifecycle/observe.js +23 -1
  76. package/dist/lint/rule.d.ts +6 -0
  77. package/dist/lint/rule.d.ts.map +1 -1
  78. package/dist/live-endpoint.d.ts +1 -1
  79. package/dist/live-endpoint.d.ts.map +1 -1
  80. package/dist/live-endpoint.js +1 -1
  81. package/dist/observation.d.ts +48 -1
  82. package/dist/observation.d.ts.map +1 -1
  83. package/dist/observation.js +21 -5
  84. package/dist/op/activities/activity-contracts.d.ts +4 -4
  85. package/dist/wave-runtime.mjs +250 -249
  86. package/dist/workspace/capabilities-contract.d.ts +66 -0
  87. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  88. package/dist/workspace/capabilities-contract.js +107 -0
  89. package/dist/workspace/capabilities.schema.json +229 -0
  90. package/dist/workspace/compose-graph.d.ts +9 -0
  91. package/dist/workspace/compose-graph.d.ts.map +1 -1
  92. package/dist/workspace/compose-graph.js +3 -1
  93. package/dist/workspace/conformance/index.d.ts +1 -1
  94. package/dist/workspace/conformance/index.d.ts.map +1 -1
  95. package/dist/workspace/conformance/index.js +39 -2
  96. package/dist/workspace/graph-cache.d.ts +8 -0
  97. package/dist/workspace/graph-cache.d.ts.map +1 -1
  98. package/dist/workspace/graph-cache.js +27 -3
  99. package/dist/workspace/graph-cli.d.ts +39 -2
  100. package/dist/workspace/graph-cli.d.ts.map +1 -1
  101. package/dist/workspace/graph-cli.js +66 -24
  102. package/dist/workspace/graph.schema.json +148 -0
  103. package/dist/workspace/kind-readers.d.ts +3 -0
  104. package/dist/workspace/kind-readers.d.ts.map +1 -1
  105. package/dist/workspace/kind-readers.js +7 -1
  106. package/dist/workspace/member-commands.d.ts +10 -1
  107. package/dist/workspace/member-commands.d.ts.map +1 -1
  108. package/dist/workspace/member-commands.js +17 -1
  109. package/dist/workspace/nested-graph.d.ts +2 -0
  110. package/dist/workspace/nested-graph.d.ts.map +1 -1
  111. package/dist/workspace/nested-graph.js +32 -1
  112. package/dist/workspace/reason-codes.d.ts +10 -0
  113. package/dist/workspace/reason-codes.d.ts.map +1 -1
  114. package/dist/workspace/reason-codes.js +12 -0
  115. package/dist/workspace/search-cli.d.ts +204 -0
  116. package/dist/workspace/search-cli.d.ts.map +1 -0
  117. package/dist/workspace/search-cli.js +466 -0
  118. package/dist/workspace/search-contract.d.ts +56 -0
  119. package/dist/workspace/search-contract.d.ts.map +1 -0
  120. package/dist/workspace/search-contract.js +70 -0
  121. package/dist/workspace/search.schema.json +822 -0
  122. package/package.json +1 -1
  123. package/src/__snapshots__/okf.test.ts.snap +10 -0
  124. package/src/capabilities/aws.paths +148 -0
  125. package/src/capabilities/base-mappings.json +122 -0
  126. package/src/capabilities/catalog.ts +230 -0
  127. package/src/capabilities/language.test.ts +191 -0
  128. package/src/capabilities/language.ts +373 -0
  129. package/src/capabilities/matcher.ts +315 -0
  130. package/src/capabilities/view-compose.test.ts +76 -0
  131. package/src/capabilities/view.ts +631 -0
  132. package/src/cli/commands/lint.ts +1 -0
  133. package/src/cli/handlers/declared-graph.ts +99 -0
  134. package/src/cli/handlers/graph.test.ts +94 -3
  135. package/src/cli/handlers/graph.ts +123 -96
  136. package/src/cli/handlers/pr-env.ts +1 -2
  137. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  138. package/src/cli/handlers/search-count.test.ts +191 -0
  139. package/src/cli/handlers/search-count.ts +165 -0
  140. package/src/cli/handlers/search-json.test.ts +333 -0
  141. package/src/cli/handlers/search-live.test.ts +387 -0
  142. package/src/cli/handlers/search-paths.test.ts +235 -0
  143. package/src/cli/handlers/search.ts +1312 -166
  144. package/src/cli/main.test.ts +28 -3
  145. package/src/cli/main.ts +23 -3
  146. package/src/cli/mcp/server.test.ts +3 -3
  147. package/src/cli/mcp/server.ts +3 -0
  148. package/src/cli/mcp/tools/capabilities.ts +32 -0
  149. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  150. package/src/cli/mcp/workspace-tools.ts +47 -2
  151. package/src/cli/parse-args.ts +52 -13
  152. package/src/cli/registry.ts +22 -0
  153. package/src/components/pr-env-ports.ts +89 -0
  154. package/src/components/pr-env-run-bundle.test.ts +54 -0
  155. package/src/components/pr-env-run.ts +5 -76
  156. package/src/components/pr-environment.ts +2 -1
  157. package/src/config-environments.ts +133 -0
  158. package/src/config.ts +37 -124
  159. package/src/graph-detail.ts +15 -3
  160. package/src/graph-effective.ts +226 -75
  161. package/src/graph-ir.test.ts +163 -0
  162. package/src/graph-ir.ts +147 -0
  163. package/src/graph-sub-template.ts +22 -0
  164. package/src/lexicon.ts +10 -0
  165. package/src/lifecycle/observe.test.ts +27 -1
  166. package/src/lifecycle/observe.ts +43 -1
  167. package/src/lint/rule.ts +6 -0
  168. package/src/live-endpoint.ts +1 -1
  169. package/src/observation.test.ts +25 -0
  170. package/src/observation.ts +69 -5
  171. package/src/workspace/capabilities-contract.test.ts +88 -0
  172. package/src/workspace/capabilities-contract.ts +132 -0
  173. package/src/workspace/capabilities.schema.json +229 -0
  174. package/src/workspace/compose-graph.ts +10 -1
  175. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  176. package/src/workspace/conformance/index.ts +31 -2
  177. package/src/workspace/graph-cache.ts +27 -3
  178. package/src/workspace/graph-cli.ts +96 -24
  179. package/src/workspace/graph.schema.json +148 -0
  180. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  181. package/src/workspace/kind-readers.test.ts +1 -1
  182. package/src/workspace/kind-readers.ts +7 -1
  183. package/src/workspace/member-commands.ts +22 -1
  184. package/src/workspace/nested-graph.ts +32 -1
  185. package/src/workspace/reason-codes.test.ts +7 -0
  186. package/src/workspace/reason-codes.ts +12 -0
  187. package/src/workspace/search-cli.e2e.test.ts +305 -0
  188. package/src/workspace/search-cli.ts +591 -0
  189. package/src/workspace/search-contract.ts +95 -0
  190. package/src/workspace/search.schema.json +822 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * `chant workspace search <query> [--member <name>] [--env <name>]
3
+ * [--snapshot <ref> | --live] [--at <rev>] [--explain] [--count-by ...]
4
+ * [--show a,b] [--json]` (#4374): `chant search` over every member of a
5
+ * declared workspace, as one `search` document of the read contract.
6
+ *
7
+ * Members are read the way `chant workspace graph` reads them, through the same
8
+ * fan-out (`readMembers` in `graph-cli.ts`): the same plan, each member under
9
+ * its own chant (ws-021), one process per toolchain, `--at <rev>` from an
10
+ * export of the tree at that commit, and a member of a package's kind in its
11
+ * reader project. Each member runs its own `chant search ... --json`, so the
12
+ * terms evaluate in the member, under the member's chant, over the graph that
13
+ * member's `chant graph` draws. Ids are then `<member>/<id>`, the rule
14
+ * `workspace graph` composes with (`composedId`), so a row names the node the
15
+ * graph names.
16
+ *
17
+ * Each member's answer stands alone: a path that crosses from one member into
18
+ * another is not walked, and every searched member says so with `alone: true`.
19
+ * A member that could not be searched is listed with a reason code and no
20
+ * answer, never as zero matches. Members are stamped by the rule of ws-059.
21
+ *
22
+ * A query with a capability term is the exception: its rules run once over
23
+ * the composed graph, each member mapped by its own chant (#4375, #4378;
24
+ * `composedCapabilitySearch` below).
25
+ *
26
+ * Two times stay apart. `--at <rev>` is the git revision, as for every
27
+ * contract read, and the document's `at` is that commit. `--snapshot <ref>`
28
+ * replays a recorded lifecycle snapshot, and `source.snapshot` names it. The
29
+ * two are not taken together, and `--at` given a snapshot ref such as
30
+ * `latest` is refused with a hint naming `--snapshot`.
31
+ */
32
+
33
+ import { existsSync } from "node:fs";
34
+ import { resolve } from "node:path";
35
+ import { formatError } from "../cli/format";
36
+ import type { CommandContext, ParsedArgs } from "../cli/registry";
37
+ import { composedId, type ComposedMember } from "./compose-graph";
38
+ import { readerVersion, WorkspaceReadError } from "./declaration";
39
+ import { readMembers } from "./graph-cli";
40
+ import type { GraphIR } from "../graph-ir";
41
+ import type { Toolchain, UnitResult } from "./member-commands";
42
+ import {
43
+ SEARCH_CONTRACT_VERSION,
44
+ SEARCH_ERROR_CODES,
45
+ SEARCH_MEMBER_REASON_CODES,
46
+ SEARCH_OUTPUT_SCHEMA_ID,
47
+ type SearchErrorCode,
48
+ type SearchHead,
49
+ type SearchMemberReasonCode,
50
+ type WorkspaceSearchErrorCode,
51
+ } from "./search-contract";
52
+ import { handToRootChant } from "./which-chant";
53
+
54
+ const USAGE =
55
+ 'chant workspace search "<query>" [--member <name>] [--env <name>] [--snapshot <ref> | --live] [--at <rev>] [--explain] [--count-by kind|module|attr:<name>] [--show a,b] [--json]';
56
+
57
+ /** The flags of `chant search` a member gets as they were given. */
58
+ type SearchFlags = Pick<
59
+ Partial<ParsedArgs>,
60
+ "env" | "snapshot" | "live" | "ambient" | "explain" | "countBy" | "show" | "checkLive" | "checkSnapshot" | "failOnDrift"
61
+ >;
62
+
63
+ export interface WorkspaceSearchQuery {
64
+ /** Where the walk up to the declaration starts. */
65
+ cwd: string;
66
+ query: string;
67
+ /** The git revision to search the source at (`--at`). */
68
+ at?: string;
69
+ /** Only these members. */
70
+ members?: string[];
71
+ flags?: SearchFlags;
72
+ /** The chant for members with none of their own; the running chant by default. */
73
+ reader?: Toolchain;
74
+ /** Called with each member's stderr. */
75
+ onStderr?: (text: string) => void;
76
+ }
77
+
78
+ /** A row as a member's `chant search --json` prints it. */
79
+ interface MemberMatch {
80
+ id: string;
81
+ displayId: string;
82
+ kind?: string;
83
+ physicalId?: string;
84
+ unobserved?: string;
85
+ show?: Record<string, unknown>;
86
+ paths: {
87
+ term: string;
88
+ dir: "out" | "in";
89
+ hops: { from: string; fromAttr?: string; to: string; toAttr?: string; edge?: string }[];
90
+ /** On a `cap:` term (#4375): the sets the match relied on, each with its path. */
91
+ because?: { capability: string; id: string; hops: { from: string; to: string }[] }[];
92
+ }[];
93
+ }
94
+
95
+ /** The answer of one member's `chant search --json`, rows or groups. */
96
+ interface MemberAnswer {
97
+ query: string;
98
+ source: Record<string, unknown>;
99
+ matches?: MemberMatch[];
100
+ countBy?: string;
101
+ groups?: { group: string; count: number; ids?: string[] }[];
102
+ total?: number;
103
+ provenance?: string;
104
+ liveFailures?: string[];
105
+ explain?: {
106
+ universe: { matched: number; of: number; kinds: string[] };
107
+ evidence: { id: string; attr: string; via: string }[];
108
+ nearMisses: { id: string; fails?: string }[];
109
+ moreExcluded: number;
110
+ };
111
+ drift?: Record<string, unknown>;
112
+ }
113
+
114
+ export interface SearchMember {
115
+ name: string;
116
+ dir: string;
117
+ kind: string;
118
+ /** `searched` when the member answered, `skipped` when its kind isn't run, `failed` when it has no answer. */
119
+ status: "searched" | "skipped" | "failed";
120
+ reason: { code: SearchMemberReasonCode; message: string } | null;
121
+ /** The chant version that read the member, when its toolchain said. */
122
+ chant: string | null;
123
+ /** The member's stamp, by the rule of ws-059, or null. */
124
+ stamp: string | null;
125
+ /** On a searched member: its terms and paths saw only its own graph. */
126
+ alone?: true;
127
+ source?: Record<string, unknown>;
128
+ /** The member's matched ids, `<member>/<id>`. */
129
+ matches?: string[];
130
+ groups?: { group: string; count: number; ids?: string[] }[];
131
+ total?: number;
132
+ provenance?: string;
133
+ liveFailures?: string[];
134
+ explain?: MemberAnswer["explain"];
135
+ drift?: Record<string, unknown>;
136
+ }
137
+
138
+ interface WorkspaceHead extends SearchHead {
139
+ workspace: { name: string; root: string };
140
+ /** The commit searched, or null for the working tree. */
141
+ at: string | null;
142
+ query: string;
143
+ /** Which observation answered: `declared`, `live` or `snapshot`, with `env` and the `snapshot` ref asked for. */
144
+ source: { kind: "declared" | "live" | "snapshot"; env?: string; snapshot?: string };
145
+ members: SearchMember[];
146
+ }
147
+
148
+ export type WorkspaceSearchDocument =
149
+ | (WorkspaceHead & { matches: (MemberMatch & { member: string })[] })
150
+ | (WorkspaceHead & { countBy: string; groups: { group: string; count: number; ids?: string[] }[]; total: number })
151
+ | (SearchHead & { query: string; error: { code: WorkspaceSearchErrorCode | SearchErrorCode; message: string; hint?: string } });
152
+
153
+ export interface WorkspaceSearchResult {
154
+ doc: WorkspaceSearchDocument;
155
+ /** The search was refused, or a member failed, had a failed live read, or failed its drift check. */
156
+ failed: boolean;
157
+ }
158
+
159
+ /** The command line each member runs: its own `chant search`, the query after `--` so one opening with `-` stays a query. */
160
+ export function memberSearchArgv(query: string, flags: SearchFlags = {}): string[] {
161
+ return [
162
+ "search",
163
+ ...(flags.env ? ["--env", flags.env] : []),
164
+ ...(flags.snapshot ? ["--snapshot", flags.snapshot] : []),
165
+ ...(flags.live ? ["--live"] : []),
166
+ ...(flags.ambient ? ["--ambient"] : []),
167
+ ...(flags.explain ? ["--explain"] : []),
168
+ ...(flags.countBy ? ["--count-by", flags.countBy] : []),
169
+ ...(flags.show ? ["--show", flags.show] : []),
170
+ ...(flags.checkLive ? ["--check-live"] : []),
171
+ ...(flags.checkSnapshot ? ["--check-snapshot"] : []),
172
+ ...(flags.failOnDrift ? ["--fail-on-drift"] : []),
173
+ "--json",
174
+ "--",
175
+ query,
176
+ ];
177
+ }
178
+
179
+ /** The last line of `stdout` that parses as a JSON object, or undefined. */
180
+ function lastJsonObject(stdout: string): Record<string, unknown> | undefined {
181
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
182
+ for (let i = lines.length - 1; i >= 0; i--) {
183
+ if (!lines[i].startsWith("{")) continue;
184
+ try {
185
+ const v = JSON.parse(lines[i]) as unknown;
186
+ if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
187
+ } catch {
188
+ // Not this line.
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ const KNOWN_REFUSALS = new Set<string>(SEARCH_ERROR_CODES);
195
+
196
+ /**
197
+ * Read one member's run: its answer, or why it has none. A chant that prints
198
+ * no `search` document of the contract is `member-chant-too-old` when it
199
+ * predates `workspace member-run`, says it has no such command or flag, or
200
+ * prints `search --json` from before the contract (no `contract` field).
201
+ */
202
+ export function readMemberSearch(r: Pick<UnitResult, "exitCode" | "stdout" | "stderr" | "chant">): { answer: MemberAnswer } | { reason: { code: SearchMemberReasonCode; message: string } } {
203
+ const doc = lastJsonObject(r.stdout);
204
+ if (doc && doc.$schema === SEARCH_OUTPUT_SCHEMA_ID && doc.contract === SEARCH_CONTRACT_VERSION) {
205
+ const error = doc.error as { code?: unknown; message?: unknown; hint?: unknown } | undefined;
206
+ if (error && typeof error === "object") {
207
+ const code = String(error.code);
208
+ const message = `${String(error.message)}${typeof error.hint === "string" ? `; ${error.hint}` : ""}`;
209
+ return KNOWN_REFUSALS.has(code)
210
+ ? { reason: { code: code as SearchMemberReasonCode, message } }
211
+ : { reason: { code: "output-unreadable", message: `the member's search refused with ${code}, which this chant does not know: ${message}` } };
212
+ }
213
+ if (!Array.isArray(doc.matches) && !Array.isArray(doc.groups)) {
214
+ return { reason: { code: "output-unreadable", message: "the member's search document has neither matches nor groups" } };
215
+ }
216
+ return { answer: doc as unknown as MemberAnswer };
217
+ }
218
+ const version = r.chant ? `chant ${r.chant}` : "a chant older than workspace member-run";
219
+ const unknownSurface = /Unknown (command|flag)/i.test(r.stderr);
220
+ const preContract = doc !== undefined && doc.contract === undefined && ("matches" in doc || "groups" in doc || "error" in doc);
221
+ if (r.chant === null || unknownSurface || preContract) {
222
+ return {
223
+ reason: {
224
+ code: "member-chant-too-old",
225
+ message: `the member runs ${version}, which prints no search document of the read contract (#4374); install a newer @intentius/chant for this member`,
226
+ },
227
+ };
228
+ }
229
+ const tail = r.stderr.trim().split("\n").slice(-5).join("\n");
230
+ if (r.exitCode !== 0) return { reason: { code: "command-failed", message: `chant search exited ${r.exitCode}${tail ? `: ${tail}` : ""}` } };
231
+ return { reason: { code: "output-unreadable", message: "the member's chant search printed no search document" } };
232
+ }
233
+
234
+ /** `ids` with each one prefixed by its member. */
235
+ const prefixAll = (member: string, ids: unknown): unknown => (Array.isArray(ids) ? ids.map((id) => (typeof id === "string" ? composedId(member, id) : id)) : ids);
236
+
237
+ /** A member's drift, with every node id in it `<member>/<id>`. */
238
+ function prefixDrift(member: string, drift: Record<string, unknown>): Record<string, unknown> {
239
+ const out: Record<string, unknown> = { ...drift };
240
+ for (const key of ["missing", "orphan", "disappeared", "newlyObserved"]) if (key in out) out[key] = prefixAll(member, out[key]);
241
+ const rows = (key: string, fields: string[]) => {
242
+ if (!Array.isArray(out[key])) return;
243
+ out[key] = (out[key] as Record<string, unknown>[]).map((row) => {
244
+ const copy = { ...row };
245
+ for (const f of fields) if (typeof copy[f] === "string") copy[f] = composedId(member, copy[f] as string);
246
+ return copy;
247
+ });
248
+ };
249
+ rows("drifted", ["id"]);
250
+ rows("unobserved", ["id"]);
251
+ rows("runtimeChildren", ["id", "owner"]);
252
+ return out;
253
+ }
254
+
255
+ /** A member's row, with its id and each hop's ends `<member>/<id>`, as `workspace graph` names them. */
256
+ function prefixMatch(member: string, m: MemberMatch): MemberMatch & { member: string } {
257
+ return {
258
+ ...m,
259
+ id: composedId(member, m.id),
260
+ member,
261
+ paths: (m.paths ?? []).map((p) => ({
262
+ ...p,
263
+ hops: p.hops.map((h) => ({ ...h, from: composedId(member, h.from), to: composedId(member, h.to) })),
264
+ ...(p.because
265
+ ? { because: p.because.map((b) => ({ ...b, id: composedId(member, b.id), hops: b.hops.map((h) => ({ ...h, from: composedId(member, h.from), to: composedId(member, h.to) })) })) }
266
+ : {}),
267
+ })),
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Sort groups by count, highest first, then by name: the order `--count-by`
273
+ * prints. A group carries `ids` (#4382) when every member that counted into it
274
+ * listed its own; a member on an older chant lists none, and then the merged
275
+ * group has a count and no ids, rather than ids that fall short of it.
276
+ */
277
+ function sortGroups(groups: Map<string, { count: number; ids: string[] | null }>): { group: string; count: number; ids?: string[] }[] {
278
+ return [...groups]
279
+ .map(([group, g]) => ({ group, count: g.count, ...(g.ids ? { ids: g.ids } : {}) }))
280
+ .sort((a, b) => b.count - a.count || (a.group < b.group ? -1 : a.group > b.group ? 1 : 0));
281
+ }
282
+
283
+ /** The source the document's top names, from the flags: which observation answered. */
284
+ function topSource(flags: SearchFlags): WorkspaceHead["source"] {
285
+ if (flags.live) return { kind: "live", ...(flags.env ? { env: flags.env } : {}) };
286
+ if (flags.snapshot) return { kind: "snapshot", ...(flags.env ? { env: flags.env } : {}), snapshot: flags.snapshot };
287
+ return { kind: "declared" };
288
+ }
289
+
290
+ /** Search every member and compose the document. Never throws a {@link WorkspaceReadError}. */
291
+ export async function workspaceSearch(q: WorkspaceSearchQuery): Promise<WorkspaceSearchResult> {
292
+ const head: SearchHead = { $schema: SEARCH_OUTPUT_SCHEMA_ID, contract: SEARCH_CONTRACT_VERSION, chant: readerVersion() };
293
+ const flags = q.flags ?? {};
294
+ const query = q.query.trim();
295
+ const refuse = (code: WorkspaceSearchErrorCode | SearchErrorCode, message: string, hint?: string): WorkspaceSearchResult => ({
296
+ doc: { ...head, query, error: { code, message, ...(hint ? { hint } : {}) } },
297
+ failed: true,
298
+ });
299
+
300
+ // The two times (#4374): --at is the git revision, --snapshot the recorded estate.
301
+ if (flags.snapshot !== undefined && q.at !== undefined) {
302
+ return refuse(
303
+ "snapshot-at-revision",
304
+ `--snapshot ${flags.snapshot} replays a recorded snapshot over the source in the working tree, and --at ${q.at} reads the source at a revision; chant workspace search takes one of them`,
305
+ "search the source at a commit with --at <rev>, or answer from a recorded snapshot with --snapshot <ref>",
306
+ );
307
+ }
308
+ if (flags.live && q.at !== undefined) {
309
+ return refuse("live-at-revision", "--live reads the account as it stands now, and --at reads the source at a revision; chant workspace search takes one of them");
310
+ }
311
+ // The query and flags are refused once, here, with the codes a member would give.
312
+ const { checkSearchArgs } = await import("../cli/handlers/search");
313
+ const checked = checkSearchArgs(flags, query, { ref: flags.snapshot, flag: "--snapshot" });
314
+ if ("error" in checked) return refuse(checked.error.code, checked.error.message, checked.error.hint);
315
+ // Capability terms (#4375) run once, over the composed graph.
316
+ const { usesCapability } = await import("../cli/handlers/search");
317
+ if (checked.terms.some(usesCapability)) {
318
+ if (flags.live || flags.snapshot !== undefined || checked.countBy || flags.checkLive || flags.checkSnapshot) {
319
+ return refuse(
320
+ "usage-invalid",
321
+ "a capability term over a workspace answers from the composed declared graph, so it takes no --live, --snapshot, --check-live, --check-snapshot or --count-by",
322
+ "search a member's own project for a live answer: chant search \"cap:...\" --live --env <name>",
323
+ );
324
+ }
325
+ return composedCapabilitySearch(q, head, query, checked.terms);
326
+ }
327
+
328
+ try {
329
+ const read = await readMembers(
330
+ { cwd: q.cwd, at: q.at, members: q.members, reader: q.reader, onStderr: q.onStderr, args: {} },
331
+ { cache: false, argvFor: () => memberSearchArgv(query, flags) },
332
+ );
333
+ const { located, declaration, plan, results, stamps } = read;
334
+ const byId = new Map(results.map((r) => [r.id, r]));
335
+ const members: SearchMember[] = [];
336
+ const matches: (MemberMatch & { member: string })[] = [];
337
+ const groups = new Map<string, { count: number; ids: string[] | null }>();
338
+ let failed = plan.unreadable.length > 0;
339
+ for (const m of declaration.members) {
340
+ if (q.members?.length && !q.members.includes(m.name)) continue;
341
+ const entry: SearchMember = { name: m.name, dir: m.dir, kind: m.kind, status: "skipped", reason: null, chant: null, stamp: stamps.get(m.name) ?? null };
342
+ const r = byId.get(m.name);
343
+ if (!r) {
344
+ const unreadable = plan.unreadable.find((s) => s.name === m.name);
345
+ const skipped = plan.skipped.find((s) => s.name === m.name);
346
+ const why = unreadable ?? skipped;
347
+ entry.status = unreadable ? "failed" : "skipped";
348
+ entry.reason = why ? { code: why.reason.code as SearchMemberReasonCode, message: why.reason.message } : null;
349
+ members.push(entry);
350
+ continue;
351
+ }
352
+ entry.chant = r.chant;
353
+ const got = readMemberSearch(r);
354
+ if ("reason" in got) {
355
+ failed = true;
356
+ entry.status = "failed";
357
+ entry.reason = got.reason;
358
+ members.push(entry);
359
+ continue;
360
+ }
361
+ const a = got.answer;
362
+ // A member that answered and still exited 1 had a failed live read, or failed --fail-on-drift.
363
+ if (r.exitCode !== 0) failed = true;
364
+ entry.status = "searched";
365
+ entry.alone = true;
366
+ entry.source = a.source;
367
+ if (Array.isArray(a.matches)) {
368
+ const rows = a.matches.map((x) => prefixMatch(m.name, x));
369
+ matches.push(...rows);
370
+ entry.matches = rows.map((x) => x.id);
371
+ }
372
+ if (Array.isArray(a.groups)) {
373
+ // Each group's ids (#4382) as `workspace graph` gives the same nodes: `<member>/<id>`.
374
+ entry.groups = a.groups.map((g) => (Array.isArray(g.ids) ? { ...g, ids: g.ids.map((id) => composedId(m.name, id)) } : g));
375
+ entry.total = a.total ?? a.groups.reduce((n, g) => n + g.count, 0);
376
+ // A module is a member's own: `module.vpc` in two members is two modules.
377
+ for (const g of entry.groups) {
378
+ const name = checked.countBy?.by === "module" ? composedId(m.name, g.group) : g.group;
379
+ const was = groups.get(name);
380
+ const ids = Array.isArray(g.ids) && (!was || was.ids) ? [...(was?.ids ?? []), ...g.ids] : null;
381
+ groups.set(name, { count: (was?.count ?? 0) + g.count, ids });
382
+ }
383
+ }
384
+ if (a.provenance !== undefined) entry.provenance = a.provenance;
385
+ if (a.liveFailures?.length) entry.liveFailures = a.liveFailures;
386
+ if (a.explain) {
387
+ entry.explain = {
388
+ ...a.explain,
389
+ evidence: a.explain.evidence.map((e) => ({ ...e, id: composedId(m.name, e.id) })),
390
+ nearMisses: a.explain.nearMisses.map((n) => ({ ...n, id: composedId(m.name, n.id) })),
391
+ };
392
+ }
393
+ if (a.drift) entry.drift = prefixDrift(m.name, a.drift);
394
+ members.push(entry);
395
+ }
396
+ const top: WorkspaceHead = {
397
+ ...head,
398
+ workspace: { name: declaration.name, root: located.root },
399
+ at: located.at,
400
+ query,
401
+ source: topSource(flags),
402
+ members,
403
+ };
404
+ if (checked.countBy) {
405
+ const merged = sortGroups(groups);
406
+ return { doc: { ...top, countBy: flags.countBy!.trim(), groups: merged, total: merged.reduce((n, g) => n + g.count, 0) }, failed };
407
+ }
408
+ matches.sort((a, b) => a.id.localeCompare(b.id));
409
+ return { doc: { ...top, matches }, failed };
410
+ } catch (err) {
411
+ if (!(err instanceof WorkspaceReadError)) throw err;
412
+ // --at is a git revision here; a snapshot ref such as `latest` names no commit (#4374).
413
+ const hint =
414
+ err.code === "revision-unknown" && q.at !== undefined
415
+ ? `--at is a git revision in the read contract; to answer from a recorded snapshot, use --snapshot ${q.at === "true" ? "latest" : q.at} --env <name>`
416
+ : undefined;
417
+ return refuse(err.code as WorkspaceSearchErrorCode, err.message, hint);
418
+ }
419
+ }
420
+
421
+ /**
422
+ * A query with capability terms (#4375): the rules run once, under this
423
+ * chant, over the graph `chant workspace graph` composes, so a path may cross
424
+ * from one member into another wherever the composed graph has an edge.
425
+ *
426
+ * The lexicon-neutral view the rules read is not built here (#4378). Each
427
+ * member's own chant builds its part, through the mappings and adapters of
428
+ * the lexicons that member loads, and prints it with
429
+ * `chant graph --capability-view`; `workspace graph --capability-view`
430
+ * carries it on the member. This chant composes the parts, resolves ARNs one
431
+ * member names in another, and evaluates. A member whose chant printed no
432
+ * view is `member-chant-too-old`, never an empty answer. A member is
433
+ * `searched` without `alone`.
434
+ */
435
+ async function composedCapabilitySearch(
436
+ q: WorkspaceSearchQuery,
437
+ head: SearchHead,
438
+ query: string,
439
+ terms: Parameters<typeof import("../cli/handlers/search").answerOverGraph>[1],
440
+ ): Promise<WorkspaceSearchResult> {
441
+ const { answerOverGraph } = await import("../cli/handlers/search");
442
+ const { workspaceGraph } = await import("./graph-cli");
443
+ const { composeViews, isViewDocument } = await import("../capabilities/view");
444
+ // Detail 3, each member's full property trees, and each member's capability
445
+ // view (#4378): its chant maps its own nodes through its own lexicons, as
446
+ // its own `chant search` would. Only the rules run here, once.
447
+ const { doc: graph, failed: graphFailed } = await workspaceGraph({
448
+ cwd: q.cwd,
449
+ at: q.at,
450
+ members: q.members,
451
+ reader: q.reader,
452
+ onStderr: q.onStderr,
453
+ args: { detail: 3, capabilityView: true },
454
+ });
455
+ if ("error" in graph) {
456
+ const e = graph.error as { code: string; message: string };
457
+ return { doc: { ...head, query, error: { code: e.code as WorkspaceSearchErrorCode, message: e.message } }, failed: true };
458
+ }
459
+ const g = graph as unknown as {
460
+ workspace: { name: string; root: string };
461
+ at: string | null;
462
+ nodes: GraphIR["nodes"];
463
+ edges: GraphIR["edges"];
464
+ members: (ComposedMember & { chant?: string | null; stamp?: string | null })[];
465
+ };
466
+ const viewed = g.members.filter((m) => m.status === "composed" && isViewDocument(m.capabilityView));
467
+ const ir: GraphIR = { nodes: g.nodes, edges: g.edges, groups: {} } as GraphIR;
468
+ const answer = answerOverGraph(ir, terms, query, composeViews(viewed.map((m) => m.capabilityView!)));
469
+ if ("error" in answer) {
470
+ return { doc: { ...head, query, error: { code: "query-invalid", message: answer.error.message, ...(answer.error.hint ? { hint: answer.error.hint } : {}) } }, failed: true };
471
+ }
472
+ const known = new Set<string>(SEARCH_MEMBER_REASON_CODES);
473
+ // A member whose chant printed no view was read, but nothing mapped its
474
+ // nodes, so it has no answer rather than an empty one.
475
+ const tooOld = (m: (typeof g.members)[number]): { code: SearchMemberReasonCode; message: string } => ({
476
+ code: "member-chant-too-old",
477
+ message: `the member runs ${m.chant ? `chant ${m.chant}` : "a chant older than workspace member-run"}, which prints no capability view (#4378), so its nodes are not evaluated: a member's lexicon mappings run under its own chant; install a newer @intentius/chant for this member`,
478
+ });
479
+ const members: SearchMember[] = g.members.map((m) => {
480
+ const searched = m.status === "composed" && viewed.includes(m);
481
+ const reason = m.reason
482
+ ? { code: (known.has(m.reason.code) ? m.reason.code : "output-unreadable") as SearchMemberReasonCode, message: m.reason.message }
483
+ : m.status === "composed" && !searched
484
+ ? tooOld(m)
485
+ : null;
486
+ return {
487
+ name: m.name,
488
+ dir: m.dir,
489
+ kind: m.kind,
490
+ status: searched ? "searched" : reason && m.status !== "skipped" ? "failed" : "skipped",
491
+ reason,
492
+ chant: m.chant ?? null,
493
+ stamp: m.stamp ?? null,
494
+ ...(searched
495
+ ? {
496
+ source: { kind: "declared" },
497
+ matches: answer.matches.filter((x) => x.id.startsWith(`${m.name}/`)).map((x) => x.id),
498
+ provenance: "declared only · each member mapped by its own chant, capabilities evaluated once over the composed workspace graph",
499
+ }
500
+ : {}),
501
+ } as SearchMember;
502
+ });
503
+ const matches = answer.matches
504
+ .map((x) => {
505
+ const member = x.id.slice(0, x.id.indexOf("/"));
506
+ return { ...x, member, displayId: x.id.slice(member.length + 1) };
507
+ })
508
+ .sort((a, b) => a.id.localeCompare(b.id));
509
+ const doc = {
510
+ ...head,
511
+ workspace: g.workspace,
512
+ at: g.at,
513
+ query,
514
+ source: { kind: "declared" },
515
+ members,
516
+ matches,
517
+ capabilities: answer.capabilities,
518
+ } as unknown as WorkspaceSearchDocument;
519
+ return { doc, failed: graphFailed || viewed.length < g.members.filter((m) => m.status === "composed").length };
520
+ }
521
+
522
+ /** The text form: one row per match, or the merged groups, then each member that has no answer. */
523
+ function printText(doc: WorkspaceSearchDocument): void {
524
+ if ("error" in doc) return;
525
+ if ("groups" in doc) {
526
+ const width = String(doc.groups[0]?.count ?? 0).length;
527
+ for (const g of doc.groups) console.log(`${String(g.count).padStart(width)} ${g.group}`);
528
+ console.log(`— ${doc.total} total · ${doc.groups.length} group${doc.groups.length === 1 ? "" : "s"} by ${doc.countBy}`);
529
+ } else if (doc.matches.length === 0) {
530
+ console.log("(no matches)");
531
+ } else {
532
+ for (const m of doc.matches) {
533
+ const show = Object.entries(m.show ?? {}).map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`);
534
+ console.log([`${m.member}/${m.displayId}`, m.kind ?? "", ...(m.physicalId ? [m.physicalId] : []), ...show].join(" "));
535
+ }
536
+ }
537
+ const searched = doc.members.filter((m) => m.status === "searched").length;
538
+ // Capability terms (#4375) ran once over the composed graph, not per member.
539
+ const caps = (doc as { capabilities?: { name: string; notEvaluated: string[] }[] }).capabilities;
540
+ for (const c of caps ?? []) if (c.notEvaluated.length > 0) console.log(`— ${c.name} does not evaluate: ${c.notEvaluated.join("; ")}`);
541
+ const together = searched > 0 && doc.members.every((m) => m.status !== "searched" || !m.alone);
542
+ for (const m of doc.members) if (m.status === "failed" && m.reason) console.error(formatError({ message: `member ${m.name}: ${m.reason.code}: ${m.reason.message}` }));
543
+ const failed = doc.members.filter((m) => m.status === "failed").length;
544
+ const skipped = doc.members.filter((m) => m.status === "skipped").length;
545
+ console.error(`chant workspace search: ${searched} member${searched === 1 ? "" : "s"} searched, ${together ? "together over the composed graph" : "each alone"}; ${failed} failed; ${skipped} skipped`);
546
+ }
547
+
548
+ export async function runWorkspaceSearch(ctx: CommandContext): Promise<number> {
549
+ const { args } = ctx;
550
+ const cwd = resolve(".");
551
+ if (!existsSync(cwd)) {
552
+ console.error(formatError({ message: `${cwd} does not exist`, hint: USAGE }));
553
+ return 1;
554
+ }
555
+ // The root's chant reads the declaration (ws-021).
556
+ const handed = await handToRootChant(cwd, args.at);
557
+ if (handed !== undefined) return handed;
558
+ // The capability catalog (#4375): rules run once, under the workspace's
559
+ // chant, so the catalog is this chant's, for every lexicon a member loads.
560
+ if (args.capabilities) {
561
+ const { printCapabilities } = await import("../cli/handlers/search");
562
+ const { workspaceLexicons } = await import("./capabilities-contract");
563
+ return printCapabilities(args, args.extraPositional ?? "", await workspaceLexicons(cwd));
564
+ }
565
+ const { doc, failed } = await workspaceSearch({
566
+ cwd,
567
+ query: args.extraPositional ?? "",
568
+ at: args.at,
569
+ members: args.members,
570
+ flags: {
571
+ env: args.env,
572
+ snapshot: args.snapshot,
573
+ live: args.live,
574
+ ambient: args.ambient,
575
+ explain: args.explain,
576
+ countBy: args.countBy,
577
+ show: args.show,
578
+ checkLive: args.checkLive,
579
+ checkSnapshot: args.checkSnapshot,
580
+ failOnDrift: args.failOnDrift,
581
+ },
582
+ onStderr: (text) => process.stderr.write(text),
583
+ });
584
+ if (args.json) console.log(JSON.stringify(doc));
585
+ if ("error" in doc) {
586
+ console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: doc.error.hint ?? USAGE }));
587
+ return 1;
588
+ }
589
+ if (!args.json) printText(doc);
590
+ return failed ? 1 : 0;
591
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `search` document of the read contract (#4374, ws-017): what
3
+ * `chant search --json` and `chant workspace search` print, described by
4
+ * `search.schema.json` beside this file.
5
+ *
6
+ * Both forms print one document. `chant search` answers over one project, and
7
+ * `chant workspace search` runs that same command in every member, under the
8
+ * member's own chant (ws-021), and composes the answers with the `<member>/<id>`
9
+ * ids `chant workspace graph` gives the same nodes (`search-cli.ts`).
10
+ *
11
+ * This module holds the constants both forms share and imports nothing heavy,
12
+ * since `cli/handlers/search.ts` loads it on every `--json` run.
13
+ */
14
+
15
+ import type { ReasonCode } from "./reason-codes";
16
+
17
+ /** The version of the `search` document this chant writes. */
18
+ export const SEARCH_CONTRACT_VERSION = 1;
19
+
20
+ /** `$id` of the JSON Schema for the document, shipped beside this file. */
21
+ export const SEARCH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json";
22
+
23
+ /**
24
+ * Why `chant search` printed no answer. A member's own refusal under
25
+ * `chant workspace search` carries the same code as its reason.
26
+ */
27
+ export const SEARCH_ERROR_CODES = [
28
+ /** No query, a term the grammar refuses, or a --count-by that is not a grouping. */
29
+ "query-invalid",
30
+ /** A flag that needs another, or two that exclude each other. */
31
+ "usage-invalid",
32
+ /** --env names an environment the project's config does not declare. */
33
+ "environment-unknown",
34
+ /** --snapshot (or plain search's --at) names nothing replay can answer from. */
35
+ "snapshot-unavailable",
36
+ /** The build, or a build root, failed: the graph would be incomplete. */
37
+ "build-failed",
38
+ ] as const satisfies readonly ReasonCode[];
39
+ export type SearchErrorCode = (typeof SEARCH_ERROR_CODES)[number];
40
+
41
+ /**
42
+ * Why `chant workspace search` printed no answer: the declaration's codes and
43
+ * `--at`'s, the two times given together, and a query or usage refused before
44
+ * any member ran.
45
+ */
46
+ export const WORKSPACE_SEARCH_ERROR_CODES = [
47
+ "declaration-missing",
48
+ "declaration-ambiguous",
49
+ "declaration-unparseable",
50
+ "declaration-invalid",
51
+ "placement-invalid",
52
+ "reader-too-old",
53
+ "root-chant-required",
54
+ "not-a-git-repository",
55
+ "revision-unknown",
56
+ "live-at-revision",
57
+ "snapshot-at-revision",
58
+ "query-invalid",
59
+ "usage-invalid",
60
+ ] as const satisfies readonly ReasonCode[];
61
+ export type WorkspaceSearchErrorCode = (typeof WORKSPACE_SEARCH_ERROR_CODES)[number];
62
+
63
+ /** Every `error.code` a `search` document can carry, in either form. */
64
+ export const SEARCH_DOCUMENT_ERROR_CODES = [...new Set<ReasonCode>([...WORKSPACE_SEARCH_ERROR_CODES, ...SEARCH_ERROR_CODES])];
65
+
66
+ /**
67
+ * Why a member of `chant workspace search` has no answer. A member that ran
68
+ * and refused carries its own {@link SEARCH_ERROR_CODES} code; the rest are
69
+ * the per-member codes `workspace graph` uses, and `member-chant-too-old`.
70
+ */
71
+ export const SEARCH_MEMBER_REASON_CODES = [
72
+ "kind-not-run",
73
+ "dir-missing",
74
+ "unknown-kind",
75
+ "kind-probe-failed",
76
+ "command-failed",
77
+ "output-unreadable",
78
+ "member-chant-too-old",
79
+ ...SEARCH_ERROR_CODES,
80
+ ] as const satisfies readonly ReasonCode[];
81
+ export type SearchMemberReasonCode = (typeof SEARCH_MEMBER_REASON_CODES)[number];
82
+
83
+ /** The head every `search` document opens with. */
84
+ export interface SearchHead {
85
+ $schema: typeof SEARCH_OUTPUT_SCHEMA_ID;
86
+ contract: typeof SEARCH_CONTRACT_VERSION;
87
+ /** The chant that wrote the document. */
88
+ chant: string;
89
+ }
90
+
91
+ /** A refusal: no answer, exit 1. `hint` is the line stderr prints after the error, when there is one. */
92
+ export interface SearchFailure extends SearchHead {
93
+ query: string;
94
+ error: { code: string; message: string; hint?: string };
95
+ }