@zerotal/arch 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `routes` — the routes the app actually registered, not the ones its files
3
+ * suggest.
4
+ *
5
+ * Read from the router after boot, so a route a provider added programmatically
6
+ * appears beside the ones in `routes/`. The `name` column is the load-bearing
7
+ * one: `route()` is type-checked against generated names, so an agent that
8
+ * guesses a name writes code that will not compile, and this is where it stops
9
+ * guessing.
10
+ */
11
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
12
+ import type { RouteEntry, RouteReport } from "../probe/topics.ts";
13
+ import type { ToolContext } from "./context.ts";
14
+
15
+ export function routesTool(ctx: ToolContext): ArchTool {
16
+ return {
17
+ name: "routes",
18
+ title: "Routes",
19
+ description:
20
+ "List the HTTP routes this app has registered, with the controller, action, route name " +
21
+ "and middleware for each. Includes routes registered programmatically by providers, which " +
22
+ "reading the routes/ directory would miss. Use it to get a route name right before calling " +
23
+ "route(), and to check a route you added is actually reachable.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ path: {
28
+ type: "string",
29
+ description: "Only routes whose path contains this substring.",
30
+ },
31
+ method: {
32
+ type: "string",
33
+ description: "Only routes with this HTTP method. Case-insensitive.",
34
+ },
35
+ named: {
36
+ type: "boolean",
37
+ description: "Only routes that have a name.",
38
+ },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ outputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ total: { type: "number", description: "Routes matching the filters." },
46
+ registered: { type: "number", description: "Routes in the app, before filtering." },
47
+ routes: {
48
+ type: "array",
49
+ items: {
50
+ type: "object",
51
+ properties: {
52
+ method: { type: "string" },
53
+ path: { type: "string" },
54
+ controller: { type: "string" },
55
+ action: { type: "string" },
56
+ name: { type: "string" },
57
+ middleware: { type: "array", items: { type: "string" } },
58
+ domain: { type: "string" },
59
+ },
60
+ required: ["method", "path", "controller", "action", "middleware"],
61
+ },
62
+ },
63
+ },
64
+ required: ["total", "registered", "routes"],
65
+ },
66
+
67
+ async run(args, signal): Promise<ToolOutcome> {
68
+ const result = await ctx.probe.run("routes", signal);
69
+ if (!result.ok) return { text: result.message, failed: true };
70
+
71
+ const report = result.data as RouteReport;
72
+ const routes = filter(report.routes, args);
73
+ const data = { total: routes.length, registered: report.total, routes };
74
+
75
+ if (routes.length === 0) {
76
+ return {
77
+ text:
78
+ report.total === 0
79
+ ? "This app has registered no routes."
80
+ : `No route matches those filters. The app has ${report.total} route(s).`,
81
+ data,
82
+ };
83
+ }
84
+
85
+ return { text: render(routes, report.total), data };
86
+ },
87
+ };
88
+ }
89
+
90
+ function filter(routes: RouteEntry[], args: Record<string, unknown>): RouteEntry[] {
91
+ const path = typeof args["path"] === "string" ? args["path"] : undefined;
92
+ const method = typeof args["method"] === "string" ? args["method"].toUpperCase() : undefined;
93
+ const named = args["named"] === true;
94
+
95
+ return routes.filter((route) => {
96
+ if (path !== undefined && !route.path.includes(path)) return false;
97
+ if (method !== undefined && route.method !== method) return false;
98
+ if (named && route.name === undefined) return false;
99
+ return true;
100
+ });
101
+ }
102
+
103
+ /**
104
+ * A fixed-width table.
105
+ *
106
+ * Aligned columns rather than prose because a model reads this the way a
107
+ * developer does — scanning one column for the row it needs.
108
+ */
109
+ function render(routes: RouteEntry[], registered: number): string {
110
+ const rows = routes.map((route) => [
111
+ route.method,
112
+ route.path,
113
+ `${route.controller}.${route.action}`,
114
+ route.name ?? "",
115
+ ]);
116
+ const headers = ["METHOD", "PATH", "HANDLER", "NAME"];
117
+ const widths = headers.map((header, column) =>
118
+ Math.max(header.length, ...rows.map((row) => row[column]!.length)),
119
+ );
120
+
121
+ const line = (cells: string[]): string =>
122
+ cells
123
+ .map((cell, column) => cell.padEnd(widths[column]!))
124
+ .join(" ")
125
+ .trimEnd();
126
+
127
+ const header =
128
+ routes.length === registered
129
+ ? `${registered} route${registered === 1 ? "" : "s"}`
130
+ : `${routes.length} of ${registered} routes`;
131
+
132
+ return [header, "", line(headers), ...rows.map(line)].join("\n");
133
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `schema` — the model layer as the ORM understands it.
3
+ *
4
+ * Read from the `@column` metadata the models registered at import time, not
5
+ * from the database and not from the migration files. That is deliberate: it is
6
+ * the schema the *code* declares, which is the one an agent writing a query or a
7
+ * migration has to agree with.
8
+ */
9
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
10
+ import type { SchemaModel, SchemaReport } from "../probe/topics.ts";
11
+ import type { ToolContext } from "./context.ts";
12
+
13
+ export function schemaTool(ctx: ToolContext): ArchTool {
14
+ return {
15
+ name: "schema",
16
+ title: "Model schema",
17
+ description:
18
+ "The database schema this app's models declare: table, primary key, timestamps, soft " +
19
+ "deletes, and every column with its type, nullability and indexes. Read from the models' " +
20
+ "own decorator metadata, so it is what the code says rather than what the database " +
21
+ "currently holds. Check it before writing a query, a factory, or a migration.",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ table: {
26
+ type: "string",
27
+ description: "Only models whose table name contains this. Omit for all.",
28
+ },
29
+ },
30
+ additionalProperties: false,
31
+ },
32
+ outputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ total: { type: "number" },
36
+ note: { type: "string", description: "Why the list is empty, when it is." },
37
+ models: {
38
+ type: "array",
39
+ items: {
40
+ type: "object",
41
+ properties: {
42
+ table: { type: "string" },
43
+ primaryKey: { type: "string" },
44
+ timestamps: { type: "boolean" },
45
+ softDeletes: { type: "boolean" },
46
+ columns: {
47
+ type: "array",
48
+ items: {
49
+ type: "object",
50
+ properties: {
51
+ name: { type: "string" },
52
+ type: { type: "string" },
53
+ nullable: { type: "boolean" },
54
+ primary: { type: "boolean" },
55
+ unique: { type: "boolean" },
56
+ indexed: { type: "boolean" },
57
+ },
58
+ required: ["name", "type", "nullable", "primary", "unique", "indexed"],
59
+ },
60
+ },
61
+ },
62
+ required: ["table", "primaryKey", "timestamps", "softDeletes", "columns"],
63
+ },
64
+ },
65
+ },
66
+ required: ["total", "models"],
67
+ },
68
+
69
+ async run(args, signal): Promise<ToolOutcome> {
70
+ const result = await ctx.probe.run("schema", signal);
71
+ if (!result.ok) return { text: result.message, failed: true };
72
+
73
+ const report = result.data as SchemaReport;
74
+ const wanted = typeof args["table"] === "string" ? args["table"].toLowerCase() : undefined;
75
+ const models =
76
+ wanted === undefined
77
+ ? report.models
78
+ : report.models.filter((model) => model.table.toLowerCase().includes(wanted));
79
+
80
+ const data = {
81
+ total: models.length,
82
+ models,
83
+ ...(report.note !== undefined ? { note: report.note } : {}),
84
+ };
85
+
86
+ if (models.length === 0) {
87
+ return {
88
+ text:
89
+ report.note ??
90
+ `No model matches "${args["table"] as string}". Tables: ` +
91
+ report.models.map((model) => model.table).join(", "),
92
+ data,
93
+ };
94
+ }
95
+
96
+ return { text: models.map(renderModel).join("\n\n"), data };
97
+ },
98
+ };
99
+ }
100
+
101
+ function renderModel(model: SchemaModel): string {
102
+ const traits = [
103
+ `primary key: ${model.primaryKey}`,
104
+ model.timestamps ? "timestamps" : "no timestamps",
105
+ ...(model.softDeletes ? ["soft deletes"] : []),
106
+ ];
107
+
108
+ const columns = model.columns.map((column) => {
109
+ const flags = [
110
+ column.primary ? "primary" : "",
111
+ column.nullable ? "nullable" : "not null",
112
+ column.unique ? "unique" : "",
113
+ column.indexed ? "indexed" : "",
114
+ ].filter(Boolean);
115
+ return ` ${column.name}: ${column.type} (${flags.join(", ")})`;
116
+ });
117
+
118
+ return `${model.table} [${traits.join(", ")}]\n${columns.join("\n")}`;
119
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * `search_docs` — the framework's own documentation, version-matched.
3
+ *
4
+ * The corpus ships inside this package, so the pages an app searches are the
5
+ * ones released with the `@zerotal/arch` it installed. That is the whole design:
6
+ * no embeddings, no hosted API, no index to keep in sync with a release, and no
7
+ * possibility of answering from documentation for a version the app is not
8
+ * running. A semantic search over a hosted corpus buys ranking; being unable to
9
+ * be wrong about the version buys more.
10
+ *
11
+ * Ranking is deliberately plain — term frequency weighted by where the term
12
+ * appears. The corpus is 125 curated pages, not a web index, and a page's title
13
+ * is a very good predictor of what it is about.
14
+ */
15
+ import { basename } from "node:path";
16
+ import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
17
+ import type { ToolContext } from "./context.ts";
18
+
19
+ /** Terms shorter than this are dropped: they match everywhere and rank nothing. */
20
+ const MIN_TERM_LENGTH = 2;
21
+ const DEFAULT_LIMIT = 5;
22
+ const MAX_LIMIT = 20;
23
+ /** How much of a matching section to return, in characters. */
24
+ const EXCERPT_BUDGET = 1200;
25
+
26
+ const WEIGHT = { title: 12, description: 6, heading: 3, body: 1 } as const;
27
+
28
+ interface DocSection {
29
+ heading: string;
30
+ text: string;
31
+ }
32
+
33
+ interface DocPage {
34
+ /** Path relative to the corpus root, e.g. `orm/casts.md`. */
35
+ path: string;
36
+ /** The docs-site slug, e.g. `/docs/orm/casts`. */
37
+ slug: string;
38
+ title: string;
39
+ description: string;
40
+ sections: DocSection[];
41
+ }
42
+
43
+ export interface DocHit {
44
+ path: string;
45
+ slug: string;
46
+ title: string;
47
+ description: string;
48
+ heading: string;
49
+ excerpt: string;
50
+ score: number;
51
+ }
52
+
53
+ // ── Corpus ────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Read the corpus once per process.
57
+ *
58
+ * Safe to cache in a way the probe's answers are not: these files live inside
59
+ * an installed package and cannot change while the server runs. Rebuilding the
60
+ * index per call would re-read a couple of megabytes for no possible difference
61
+ * in the result.
62
+ */
63
+ const indexes = new Map<string, Promise<DocPage[]>>();
64
+
65
+ function corpus(dir: string): Promise<DocPage[]> {
66
+ let index = indexes.get(dir);
67
+ if (!index) {
68
+ index = readCorpus(dir);
69
+ indexes.set(dir, index);
70
+ }
71
+ return index;
72
+ }
73
+
74
+ async function readCorpus(dir: string): Promise<DocPage[]> {
75
+ const pages: DocPage[] = [];
76
+ let files: string[];
77
+ try {
78
+ files = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: dir, onlyFiles: true }));
79
+ } catch {
80
+ return pages;
81
+ }
82
+
83
+ for (const file of files.sort()) {
84
+ try {
85
+ pages.push(parsePage(file.replace(/\\/g, "/"), await Bun.file(`${dir}/${file}`).text()));
86
+ } catch {
87
+ /* an unreadable page is one page missing from a search, not a failure */
88
+ }
89
+ }
90
+ return pages;
91
+ }
92
+
93
+ /** Split a page into its frontmatter and its `##`-delimited sections. */
94
+ export function parsePage(path: string, raw: string): DocPage {
95
+ const { frontmatter, body } = splitFrontmatter(raw);
96
+
97
+ const sections: DocSection[] = [];
98
+ let heading = "";
99
+ let buffer: string[] = [];
100
+ const flush = (): void => {
101
+ const text = buffer.join("\n").trim();
102
+ if (text.length > 0 || heading.length > 0) sections.push({ heading, text });
103
+ buffer = [];
104
+ };
105
+
106
+ for (const line of body.split("\n")) {
107
+ const match = /^(#{1,3})\s+(.*)$/.exec(line);
108
+ if (match) {
109
+ flush();
110
+ heading = match[2]!.trim();
111
+ continue;
112
+ }
113
+ buffer.push(line);
114
+ }
115
+ flush();
116
+
117
+ return {
118
+ path,
119
+ slug: `/docs/${path.replace(/\.md$/, "").replace(/\/index$/, "")}`,
120
+ title: frontmatter["title"] ?? basename(path, ".md"),
121
+ description: frontmatter["description"] ?? "",
122
+ sections,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Read the leading `---` block.
128
+ *
129
+ * Deliberately a two-key reader rather than a YAML parser: every page in this
130
+ * corpus carries exactly `title` and `description`, and a dependency to read two
131
+ * strings would be the only one this package has.
132
+ */
133
+ function splitFrontmatter(raw: string): { frontmatter: Record<string, string>; body: string } {
134
+ if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
135
+ const end = raw.indexOf("\n---", 3);
136
+ if (end === -1) return { frontmatter: {}, body: raw };
137
+
138
+ const frontmatter: Record<string, string> = {};
139
+ for (const line of raw.slice(4, end).split("\n")) {
140
+ const separator = line.indexOf(":");
141
+ if (separator === -1) continue;
142
+ const key = line.slice(0, separator).trim();
143
+ const value = line
144
+ .slice(separator + 1)
145
+ .trim()
146
+ .replace(/^["']|["']$/g, "");
147
+ if (key.length > 0) frontmatter[key] = value;
148
+ }
149
+
150
+ return { frontmatter, body: raw.slice(end + 4) };
151
+ }
152
+
153
+ // ── Ranking ───────────────────────────────────────────────────────────────────
154
+
155
+ export function terms(query: string): string[] {
156
+ return [
157
+ ...new Set(
158
+ query
159
+ .toLowerCase()
160
+ .split(/[^a-z0-9_.-]+/)
161
+ .filter((term) => term.length >= MIN_TERM_LENGTH),
162
+ ),
163
+ ];
164
+ }
165
+
166
+ function occurrences(haystack: string, needle: string): number {
167
+ if (needle.length === 0) return 0;
168
+ let count = 0;
169
+ let at = haystack.indexOf(needle);
170
+ while (at !== -1) {
171
+ count++;
172
+ at = haystack.indexOf(needle, at + needle.length);
173
+ }
174
+ return count;
175
+ }
176
+
177
+ /** Rank the corpus against a query, best first. */
178
+ export function search(pages: DocPage[], query: string, limit: number): DocHit[] {
179
+ const wanted = terms(query);
180
+ if (wanted.length === 0) return [];
181
+
182
+ const hits: DocHit[] = [];
183
+
184
+ for (const page of pages) {
185
+ const title = page.title.toLowerCase();
186
+ const description = page.description.toLowerCase();
187
+
188
+ let pageScore = 0;
189
+ for (const term of wanted) {
190
+ pageScore += occurrences(title, term) * WEIGHT.title;
191
+ pageScore += occurrences(description, term) * WEIGHT.description;
192
+ }
193
+
194
+ // The best section decides which excerpt to return; every section still
195
+ // contributes to the page's score, so a term spread across a long page
196
+ // ranks it even when no single section is dense in it.
197
+ let best: { section: DocSection; score: number } | undefined;
198
+ let bodyScore = 0;
199
+
200
+ for (const section of page.sections) {
201
+ const heading = section.heading.toLowerCase();
202
+ const text = section.text.toLowerCase();
203
+ let score = 0;
204
+ for (const term of wanted) {
205
+ score += occurrences(heading, term) * WEIGHT.heading;
206
+ score += occurrences(text, term) * WEIGHT.body;
207
+ }
208
+ bodyScore += score;
209
+ if (score > 0 && (best === undefined || score > best.score)) best = { section, score };
210
+ }
211
+
212
+ const total = pageScore + bodyScore;
213
+ if (total === 0) continue;
214
+
215
+ const section = best?.section ?? page.sections[0];
216
+ hits.push({
217
+ path: page.path,
218
+ slug: page.slug,
219
+ title: page.title,
220
+ description: page.description,
221
+ heading: section?.heading ?? "",
222
+ excerpt: excerpt(section?.text ?? page.description, wanted),
223
+ score: total,
224
+ });
225
+ }
226
+
227
+ return hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, limit);
228
+ }
229
+
230
+ /**
231
+ * A window of the section around its first matching term.
232
+ *
233
+ * Returning the whole section would be the honest thing for a human reader and
234
+ * the wrong thing here: a long page can be tens of kilobytes, and a search that
235
+ * fills a context window with one result has answered nothing.
236
+ */
237
+ function excerpt(text: string, wanted: string[]): string {
238
+ if (text.length <= EXCERPT_BUDGET) return text.trim();
239
+
240
+ const lower = text.toLowerCase();
241
+ let at = -1;
242
+ for (const term of wanted) {
243
+ const found = lower.indexOf(term);
244
+ if (found !== -1 && (at === -1 || found < at)) at = found;
245
+ }
246
+ if (at === -1) return text.slice(0, EXCERPT_BUDGET).trim() + "\n…";
247
+
248
+ const start = Math.max(0, at - Math.floor(EXCERPT_BUDGET / 3));
249
+ const window = text.slice(start, start + EXCERPT_BUDGET).trim();
250
+ return `${start > 0 ? "…" : ""}${window}${start + EXCERPT_BUDGET < text.length ? "\n…" : ""}`;
251
+ }
252
+
253
+ // ── Tool ──────────────────────────────────────────────────────────────────────
254
+
255
+ export function searchDocsTool(ctx: ToolContext): ArchTool {
256
+ return {
257
+ name: "search_docs",
258
+ title: "Search docs",
259
+ description:
260
+ "Search the Zerotal documentation that shipped with this project's installed version — " +
261
+ "routing, models, migrations, validation, auth, queues, Flow components, Inertia, admin, " +
262
+ "deployment and the rest. Returns the matching section of each page rather than the whole " +
263
+ "page. Use it for how a subsystem works or which approach the framework intends; use " +
264
+ "api_surface when you need an exact signature.",
265
+ inputSchema: {
266
+ type: "object",
267
+ properties: {
268
+ query: {
269
+ type: "string",
270
+ description: 'What to look for, e.g. "soft deletes" or "route model binding".',
271
+ },
272
+ limit: {
273
+ type: "number",
274
+ description: `How many pages to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
275
+ },
276
+ },
277
+ required: ["query"],
278
+ additionalProperties: false,
279
+ },
280
+ outputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ query: { type: "string" },
284
+ total: { type: "number" },
285
+ results: {
286
+ type: "array",
287
+ items: {
288
+ type: "object",
289
+ properties: {
290
+ path: { type: "string" },
291
+ slug: { type: "string", description: "The page's URL on the docs site." },
292
+ title: { type: "string" },
293
+ description: { type: "string" },
294
+ heading: { type: "string", description: "The section this excerpt came from." },
295
+ excerpt: { type: "string" },
296
+ score: { type: "number" },
297
+ },
298
+ required: ["path", "slug", "title", "description", "heading", "excerpt", "score"],
299
+ },
300
+ },
301
+ },
302
+ required: ["query", "total", "results"],
303
+ },
304
+
305
+ async run(args): Promise<ToolOutcome> {
306
+ const query = typeof args["query"] === "string" ? args["query"] : "";
307
+ if (query.trim().length === 0) return { text: "`query` is required.", failed: true };
308
+
309
+ const pages = await corpus(ctx.docsDir);
310
+ if (pages.length === 0) {
311
+ return {
312
+ text:
313
+ `No documentation corpus at ${ctx.docsDir}. It ships inside @zerotal/arch; a ` +
314
+ `missing one means the package was installed without its docs/ directory.`,
315
+ failed: true,
316
+ };
317
+ }
318
+
319
+ const limit = clampLimit(args["limit"]);
320
+ const results = search(pages, query, limit);
321
+ const data = { query, total: results.length, results };
322
+
323
+ if (results.length === 0) {
324
+ return {
325
+ text: `Nothing in the ${pages.length}-page corpus matches "${query}".`,
326
+ data,
327
+ };
328
+ }
329
+
330
+ const rendered = results
331
+ .map((hit) => {
332
+ const where = hit.heading ? `${hit.title} → ${hit.heading}` : hit.title;
333
+ return `## ${where}\n${hit.slug} (${hit.path})\n\n${hit.excerpt}`;
334
+ })
335
+ .join("\n\n---\n\n");
336
+
337
+ return { text: rendered, data };
338
+ },
339
+ };
340
+ }
341
+
342
+ function clampLimit(raw: unknown): number {
343
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_LIMIT;
344
+ return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
345
+ }