@skyphusion/search-mcp 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -108,6 +108,89 @@ npm run sync:run # isolated clone root, sync all targets, optional reindex
108
108
 
109
109
  The sync remaps non-native extensions (`.ts`, `.tsx`, extensionless `Dockerfile`, `.service`, etc.) to `.txt` keys so AI Search indexes them. See `scripts/sync-ingest.mjs`.
110
110
 
111
+ ### Bounding a corpus: `includePaths` vs `excludePaths`
112
+
113
+ `excludePaths` is a denylist and is **fail-open**: add a new top-level file to a
114
+ repo and it silently joins the corpus. That is fine for a docs site. It is wrong
115
+ whenever the corpus boundary actually matters, because "we forgot to exclude it"
116
+ becomes a real incident.
117
+
118
+ `includePaths` is an allowlist and is **fail-closed**: when a repo has an entry,
119
+ only paths under those prefixes are eligible and everything else is refused.
120
+
121
+ ```json
122
+ {
123
+ "includePaths": { "my-repo": ["docs/", "_corpus/"] },
124
+ "excludePaths": { "my-repo": ["docs/internal/"] }
125
+ }
126
+ ```
127
+
128
+ The two compose subtractively: `includePaths` decides what is eligible, then
129
+ `excludePaths` subtracts from that. A denylist entry can never add a path back.
130
+
131
+ Omit a repo from `includePaths` to keep the previous behaviour. An entry that is
132
+ present but **empty** (`[]`) is a configuration error, not a way to say "index
133
+ nothing": those are different states and collapsing them into the permissive one
134
+ is how a whole repo joins a corpus by accident.
135
+
136
+ #### A present allowlist must earn its keep
137
+
138
+ An allowlist that quietly matches nothing is worse than no allowlist. The sync
139
+ plans zero objects, the mirror prune deletes the corpus that was there, the
140
+ reindex succeeds over nothing, and the answer surface returns a confident nothing
141
+ with every status light green. So each of these refuses the run (exit 2) before
142
+ anything uploads or is pruned:
143
+
144
+ | Refusal | Meaning |
145
+ | --- | --- |
146
+ | `include_paths_no_match` | An entry matched zero git-tracked files (a typo, or a directory that moved) |
147
+ | `include_paths_entry_empty` | The entry is `[]` |
148
+ | `include_paths_entry_invalid` | An entry cannot match a git path (absolute, `..`, backslash, non-string) |
149
+ | `include_paths_entry_not_array` / `include_paths_not_object` | Wrong config shape |
150
+ | `include_paths_unknown_repo` | The entry names a repo no target lists, so nothing reads it |
151
+ | `include_paths_repo_not_cloned` | An allowlisted repo is missing from the clone root |
152
+ | `include_paths_all_excluded` | `excludePaths` removed everything the allowlist selected |
153
+ | `include_paths_all_filtered` | Nothing the allowlist selected survived the ingest filters |
154
+
155
+ Shape and repo-name checks run for **every** target on every sync, not just the
156
+ one being synced, so a rule that has rotted is caught by the next run of any
157
+ target. A stale `excludePaths` repo name warns rather than refuses: denylist rot
158
+ grows a corpus, allowlist rot empties one.
159
+
160
+ Verify what a target will actually upload before you trust it:
161
+
162
+ ```sh
163
+ node scripts/sync.mjs my-target --dry-run
164
+ ```
165
+
166
+ ### Size cap
167
+
168
+ Objects over `SYNC_MAX_BYTES` (default 4 MB) are skipped. Skips are summarised at
169
+ the end of the run, not only warned inline, because a file silently missing from
170
+ the corpus looks exactly like a file the corpus does not contain -- the worst
171
+ failure mode for something that answers questions. Pass `--fail-on-skip` to turn
172
+ an incomplete corpus into a failed run.
173
+
174
+ ```sh
175
+ SYNC_MAX_BYTES=$((16 * 1024 * 1024)) node scripts/sync.mjs corpus --fail-on-skip
176
+ ```
177
+
178
+ ### Corpus manifest (optional)
179
+
180
+ A corpus producer can publish a `manifest.json` next to its objects so the widget
181
+ renders citations instead of raw R2 keys. Entries need a `key`; `title`, `url`,
182
+ `page`, and `total_pages` are used when present:
183
+
184
+ ```json
185
+ { "pages": [
186
+ { "key": "my-doc/p003.txt", "title": "Deploy guide", "url": "/docs/deploy/", "page": 3, "total_pages": 12 }
187
+ ] }
188
+ ```
189
+
190
+ Keys are matched exactly first, then by **suffix**, because the sync namespaces
191
+ every object under its repo name (`<repo>/<path>`) while a producer naturally
192
+ writes its manifest in terms of its own paths.
193
+
111
194
  ### Reindex dispatch
112
195
 
113
196
  AI Search rejects a new reindex job for two distinct reasons, and `sync-runner` clears both
@@ -144,9 +227,30 @@ Copy `public/ask-widget.js` and `public/ask-widget.css` to your docs site:
144
227
  data-endpoint="https://search.example.com/ask"
145
228
  data-target="#docs-ask"
146
229
  data-label="Ask the docs"
230
+ data-manifest="/corpus-manifest.json"
231
+ data-empty-text="Nothing in the indexed corpus addresses that."
147
232
  data-sitekey="YOUR_TURNSTILE_SITEKEY"></script>
148
233
  ```
149
234
 
235
+ `data-manifest` is optional; without it sources render as raw object keys. A
236
+ missing or malformed manifest degrades to raw keys rather than breaking answers.
237
+
238
+ `data-empty-text` is shown when a query returns no retrieved sources, so an
239
+ unsourced answer is never left on screen looking authoritative.
240
+
241
+ ### Per-site system prompts
242
+
243
+ One deployment can serve several sites. `ORIGIN_PROFILES` is a JSON object in
244
+ `[vars]` mapping an exact request `Origin` to that site's system prompt:
245
+
246
+ ```toml
247
+ ORIGIN_PROFILES = '{"https://docs.example.com":"You are the docs assistant...","https://other.example":"You are..."}'
248
+ ```
249
+
250
+ Precedence is `ORIGIN_PROFILES`, then the legacy blog special-case, then
251
+ `ASSISTANT_SYSTEM_PROMPT`. A malformed value is logged and ignored so a bad var
252
+ cannot take `/ask` down.
253
+
150
254
  ## Who this is for
151
255
 
152
256
  Operators building documentation search, agent tooling, or internal knowledge bases on [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) with MCP and a browser widget.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyphusion/search-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bearer-gated MCP server and streaming query Worker over Cloudflare AI Search, plus a git-to-R2 corpus sync toolkit.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Conrad Rockenhaus <conrad@skyphusion.org>",
@@ -60,7 +60,9 @@
60
60
  "sync:internal": "node scripts/sync.mjs internal",
61
61
  "sync:dry": "node scripts/sync.mjs corpus --dry-run",
62
62
  "sync:run": "node scripts/sync-runner.mjs",
63
- "materialize-config": "node scripts/materialize-config.mjs"
63
+ "materialize-config": "node scripts/materialize-config.mjs",
64
+ "escrow": "node scripts/escrow-targets.mjs",
65
+ "guard:targets": "node scripts/guard-targets-additive.mjs"
64
66
  },
65
67
  "dependencies": {
66
68
  "@aws-sdk/client-s3": "^3.888.0"
@@ -16,6 +16,12 @@
16
16
  var label = script.getAttribute("data-label") || "Ask";
17
17
  var placeholder = script.getAttribute("data-placeholder") || "Ask a question about the docs";
18
18
  var sitekey = script.getAttribute("data-sitekey") || "";
19
+ // Optional corpus manifest: maps an R2 object key to a human title, a URL,
20
+ // and a page number, so sources render as citations instead of raw keys.
21
+ var manifestUrl = script.getAttribute("data-manifest") || "";
22
+ var emptyText =
23
+ script.getAttribute("data-empty-text") ||
24
+ "Nothing in the indexed corpus addresses that.";
19
25
 
20
26
  function ready(fn) {
21
27
  if (document.readyState !== "loading") fn();
@@ -69,6 +75,97 @@
69
75
  }
70
76
  }
71
77
 
78
+
79
+ // ---- corpus manifest -------------------------------------------------
80
+ //
81
+ // Without a manifest the widget can only show the R2 object key, which is
82
+ // an implementation detail ("repo/docs/DEPLOY.md"). With one it can show
83
+ // what a reader actually needs: the document title, a link, and a page.
84
+ //
85
+ // Entries are matched by exact key first, then by SUFFIX. Suffix matching
86
+ // matters because the sync prefixes every object with its repo name, so the
87
+ // key in R2 is "<repo>/<path>" while a corpus producer naturally writes its
88
+ // manifest in terms of its own paths. Suffix matching lets the producer stay
89
+ // ignorant of how the sync namespaces things.
90
+ var manifest = null;
91
+ var manifestIndex = null;
92
+
93
+ function indexManifest(data) {
94
+ var entries = [];
95
+ if (Array.isArray(data)) entries = data;
96
+ else if (data && Array.isArray(data.pages)) entries = data.pages;
97
+ else if (data && Array.isArray(data.objects)) entries = data.objects;
98
+ var byKey = {};
99
+ entries.forEach(function (e) {
100
+ if (e && typeof e.key === "string") byKey[e.key] = e;
101
+ });
102
+ return { byKey: byKey, entries: entries };
103
+ }
104
+
105
+ function loadManifest() {
106
+ if (!manifestUrl) return Promise.resolve(null);
107
+ if (manifest) return Promise.resolve(manifest);
108
+ return fetch(manifestUrl)
109
+ .then(function (r) {
110
+ return r.ok ? r.json() : null;
111
+ })
112
+ .then(function (data) {
113
+ if (!data) return null;
114
+ manifest = data;
115
+ manifestIndex = indexManifest(data);
116
+ return data;
117
+ })
118
+ .catch(function () {
119
+ // A missing or broken manifest must not break answers. Degrade to
120
+ // showing raw keys rather than failing the request.
121
+ return null;
122
+ });
123
+ }
124
+
125
+ function lookup(key) {
126
+ if (!manifestIndex) return null;
127
+ if (manifestIndex.byKey[key]) return manifestIndex.byKey[key];
128
+ for (var i = 0; i < manifestIndex.entries.length; i++) {
129
+ var e = manifestIndex.entries[i];
130
+ if (e && typeof e.key === "string" && key.indexOf(e.key) >= 0) {
131
+ var at = key.length - e.key.length;
132
+ if (at >= 0 && key.slice(at) === e.key) return e;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function citationNode(key) {
139
+ var entry = lookup(key);
140
+ var li = document.createElement("li");
141
+ if (!entry) {
142
+ li.textContent = key;
143
+ return li;
144
+ }
145
+ var label = entry.title || key;
146
+ if (entry.url) {
147
+ var a = document.createElement("a");
148
+ a.setAttribute("href", entry.url);
149
+ a.textContent = label;
150
+ li.appendChild(a);
151
+ } else {
152
+ li.appendChild(document.createTextNode(label));
153
+ }
154
+ var bits = [];
155
+ if (entry.page) {
156
+ bits.push("page " + entry.page + (entry.total_pages ? " of " + entry.total_pages : ""));
157
+ }
158
+ if (entry.case_number) bits.push("case " + entry.case_number);
159
+ if (entry.filed_date) bits.push("filed " + entry.filed_date);
160
+ if (bits.length) {
161
+ var span = document.createElement("span");
162
+ span.className = "vjask-source-meta";
163
+ span.textContent = " (" + bits.join(", ") + ")";
164
+ li.appendChild(span);
165
+ }
166
+ return li;
167
+ }
168
+
72
169
  function renderSources(chunks) {
73
170
  var seen = {};
74
171
  var items = [];
@@ -80,12 +177,18 @@
80
177
  }
81
178
  });
82
179
  if (!items.length) return;
83
- sources.innerHTML = "<li class='vjask-sources-title'>Sources</li>" +
84
- items.map(function (k) {
85
- return "<li>" + k.replace(/[&<>]/g, function (ch) {
86
- return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[ch];
87
- }) + "</li>";
88
- }).join("");
180
+ sawSources = true;
181
+ // Built via the DOM API rather than innerHTML: entries come from the
182
+ // corpus manifest, and concatenating them into markup would be an
183
+ // injection path through a data file.
184
+ sources.textContent = "";
185
+ var title = document.createElement("li");
186
+ title.className = "vjask-sources-title";
187
+ title.textContent = "Sources";
188
+ sources.appendChild(title);
189
+ items.forEach(function (k) {
190
+ sources.appendChild(citationNode(k));
191
+ });
89
192
  sources.hidden = false;
90
193
  }
91
194
 
@@ -109,6 +212,8 @@
109
212
  if (delta && delta.content) answer.textContent += delta.content;
110
213
  }
111
214
 
215
+ var sawSources = false;
216
+
112
217
  form.addEventListener("submit", function (ev) {
113
218
  ev.preventDefault();
114
219
  var q = input.value.trim();
@@ -116,8 +221,10 @@
116
221
  btn.disabled = true;
117
222
  answer.textContent = "";
118
223
  sources.hidden = true;
119
- sources.innerHTML = "";
224
+ sources.textContent = "";
225
+ sawSources = false;
120
226
 
227
+ loadManifest().then(function () {
121
228
  fetch(endpoint, {
122
229
  method: "POST",
123
230
  headers: { "content-type": "application/json" },
@@ -153,6 +260,12 @@
153
260
  answer.textContent = "Sorry, something went wrong (" + err.message + ").";
154
261
  })
155
262
  .finally(function () {
263
+ // No retrieved sources means nothing in the corpus matched. Say that
264
+ // outright rather than leaving a plausible-looking unsourced answer
265
+ // on screen.
266
+ if (!sawSources && !answer.textContent.trim()) {
267
+ answer.textContent = emptyText;
268
+ }
156
269
  btn.disabled = false;
157
270
  if (sitekey && window.turnstile) {
158
271
  try {
@@ -160,6 +273,7 @@
160
273
  } catch (e) {}
161
274
  }
162
275
  });
276
+ });
163
277
  });
164
278
  });
165
279
  })();
@@ -123,19 +123,253 @@ export function ingestKind(relPath) {
123
123
  return DOC_EXT.has(ext) || basename(relPath).startsWith("README") ? "doc" : "code";
124
124
  }
125
125
 
126
+
126
127
  /**
127
- * Per-repo corpus exclusion (targets.json `excludePaths`). An entry is a repo-relative
128
- * path: with a trailing "/" it excludes the whole subtree; without, it excludes that
129
- * exact file or the subtree rooted at that name.
128
+ * One prefix rule, shared by the allowlist and the denylist so the two can
129
+ * never drift. An entry is a repo-relative path: with a trailing "/" it means
130
+ * the subtree, without one it means that exact file or the subtree rooted at
131
+ * that name (so "docs" matches "docs" and "docs/a.md" but never "docsite/a.md").
132
+ */
133
+ export function matchesPathPrefix(relPath, prefix) {
134
+ if (typeof prefix !== "string" || prefix === "") return false;
135
+ if (prefix.endsWith("/")) return relPath.startsWith(prefix);
136
+ return relPath === prefix || relPath.startsWith(prefix + "/");
137
+ }
138
+
139
+ /**
140
+ * Per-repo corpus exclusion (targets.json `excludePaths`). Denylist, applied on
141
+ * top of the allowlist. Absent or empty means "exclude nothing".
130
142
  */
131
143
  export function isExcludedPath(relPath, prefixes) {
132
144
  for (const p of prefixes || []) {
133
- if (!p) continue;
134
- if (p.endsWith("/")) {
135
- if (relPath.startsWith(p)) return true;
136
- } else if (relPath === p || relPath.startsWith(p + "/")) {
137
- return true;
138
- }
145
+ if (matchesPathPrefix(relPath, p)) return true;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /**
151
+ * Per-repo corpus ALLOWLIST (targets.json `includePaths`). When a repo has an
152
+ * entry, ONLY paths under one of its prefixes are eligible for the corpus;
153
+ * everything else is refused. When a repo has NO entry (undefined/null) every
154
+ * path is eligible and `excludePaths` alone applies, so a config that never
155
+ * mentions includePaths behaves exactly as it did before.
156
+ *
157
+ * Why both exist. A denylist is fail-OPEN: add a new top-level file to a repo
158
+ * and it silently joins the corpus. That is fine for a docs site and wrong for
159
+ * a corpus whose boundary matters -- a court-record mirror, anything with a
160
+ * privileged directory, anything where "we forgot to exclude it" is an incident
161
+ * rather than noise. An allowlist is fail-CLOSED: a new path is ineligible
162
+ * until someone says otherwise.
163
+ *
164
+ * An entry that is PRESENT but empty (or malformed) matches nothing rather than
165
+ * everything. "The operator wrote an allowlist" and "the operator wrote no
166
+ * allowlist" are different states and must not collapse into the permissive
167
+ * one; the config validation below turns that state into a hard error before a
168
+ * sync ever reaches this matcher.
169
+ */
170
+ export function isIncludedPath(relPath, prefixes) {
171
+ if (prefixes === undefined || prefixes === null) return true;
172
+ if (!Array.isArray(prefixes)) return false;
173
+ for (const p of prefixes) {
174
+ if (matchesPathPrefix(relPath, p)) return true;
139
175
  }
140
176
  return false;
141
177
  }
178
+
179
+ /**
180
+ * Thrown for every includePaths refusal. `code` names the specific state so a
181
+ * test can prove a failure happened for the stated reason and not by accident.
182
+ */
183
+ export class IncludePathsError extends Error {
184
+ constructor(code, message) {
185
+ super(message);
186
+ this.name = "IncludePathsError";
187
+ this.code = code;
188
+ }
189
+ }
190
+
191
+ function describeType(v) {
192
+ if (v === null) return "null";
193
+ if (Array.isArray(v)) return "array";
194
+ return typeof v;
195
+ }
196
+
197
+ function q(v) {
198
+ return typeof v === "string" ? JSON.stringify(v) : describeType(v) + " " + JSON.stringify(v);
199
+ }
200
+
201
+ /** Every repo named by any target. An includePaths key outside this set reaches nothing. */
202
+ export function knownTargetRepos(cfg) {
203
+ const repos = new Set();
204
+ for (const target of Object.values(cfg?.targets || {})) {
205
+ for (const repo of target?.repos || []) repos.add(repo);
206
+ }
207
+ return repos;
208
+ }
209
+
210
+ /** Shape check for one repo entry. Returns {code, message} objects, never throws. */
211
+ export function validateIncludePathsEntry(repo, prefixes) {
212
+ const errors = [];
213
+ if (!Array.isArray(prefixes)) {
214
+ errors.push({
215
+ code: "include_paths_entry_not_array",
216
+ message:
217
+ `includePaths for repo ${q(repo)} must be an array of repo-relative path prefixes, ` +
218
+ `got ${describeType(prefixes)}. Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
219
+ });
220
+ return errors;
221
+ }
222
+ if (prefixes.length === 0) {
223
+ errors.push({
224
+ code: "include_paths_entry_empty",
225
+ message:
226
+ `includePaths for repo ${q(repo)} is an empty array, which is a configuration error, ` +
227
+ `not an instruction to index nothing. Remove the repo entry to index all of it, or ` +
228
+ `list the prefixes to index.`,
229
+ });
230
+ return errors;
231
+ }
232
+ for (const p of prefixes) {
233
+ if (typeof p !== "string" || p.trim() === "") {
234
+ errors.push({
235
+ code: "include_paths_entry_invalid",
236
+ message:
237
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: entries must be non-empty strings.`,
238
+ });
239
+ } else if (p.startsWith("/")) {
240
+ errors.push({
241
+ code: "include_paths_entry_invalid",
242
+ message:
243
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: entries are repo-relative and ` +
244
+ `must not start with "/". A git path never does, so this would match nothing.`,
245
+ });
246
+ } else if (p.split("/").includes("..")) {
247
+ errors.push({
248
+ code: "include_paths_entry_invalid",
249
+ message:
250
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: ".." is not something a git path ` +
251
+ `can start with, so this would match nothing.`,
252
+ });
253
+ } else if (p.includes("\\")) {
254
+ errors.push({
255
+ code: "include_paths_entry_invalid",
256
+ message:
257
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: git paths use "/" as the ` +
258
+ `separator, so a backslash would match nothing.`,
259
+ });
260
+ }
261
+ }
262
+ return errors;
263
+ }
264
+
265
+ /**
266
+ * Whole-config check for targets.json `includePaths`. Returns {code, message}
267
+ * objects, never throws, so a caller can report all of them at once.
268
+ *
269
+ * Rejects an entry naming a repo no target lists: a rule nothing reads is
270
+ * indistinguishable from a rule that works, which is how an allowlist rots.
271
+ */
272
+ export function validateIncludePathsConfig(cfg) {
273
+ const errors = [];
274
+ const includePaths = cfg?.includePaths;
275
+ if (includePaths === undefined || includePaths === null) return errors;
276
+ if (typeof includePaths !== "object" || Array.isArray(includePaths)) {
277
+ errors.push({
278
+ code: "include_paths_not_object",
279
+ message:
280
+ `targets.json includePaths must be an object keyed by repo, got ` +
281
+ `${describeType(includePaths)}. Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
282
+ });
283
+ return errors;
284
+ }
285
+ const known = knownTargetRepos(cfg);
286
+ for (const [repo, prefixes] of Object.entries(includePaths)) {
287
+ if (!known.has(repo)) {
288
+ errors.push({
289
+ code: "include_paths_unknown_repo",
290
+ message:
291
+ `includePaths names repo ${q(repo)}, which no target lists in its repos. Nothing reads ` +
292
+ `that rule, and a rule nothing reads is indistinguishable from a rule that works. Add ` +
293
+ `the repo to a target or delete the entry. Repos across all targets: ` +
294
+ `${[...known].join(", ") || "(none)"}.`,
295
+ });
296
+ }
297
+ errors.push(...validateIncludePathsEntry(repo, prefixes));
298
+ }
299
+ return errors;
300
+ }
301
+
302
+ /** Fail-closed wrapper: throws IncludePathsError carrying the first code and every message. */
303
+ export function assertIncludePathsConfig(cfg) {
304
+ const errors = validateIncludePathsConfig(cfg);
305
+ if (!errors.length) return;
306
+ throw new IncludePathsError(errors[0].code, errors.map((e) => e.message).join(" "));
307
+ }
308
+
309
+ /**
310
+ * excludePaths keys naming a repo no target lists. Denylist rot is quieter than
311
+ * allowlist rot (the corpus grows rather than empties), so this is a warning at
312
+ * the call site, not a refusal.
313
+ */
314
+ export function unknownExcludePathsRepos(cfg) {
315
+ const excludePaths = cfg?.excludePaths;
316
+ if (!excludePaths || typeof excludePaths !== "object" || Array.isArray(excludePaths)) return [];
317
+ const known = knownTargetRepos(cfg);
318
+ return Object.keys(excludePaths).filter((repo) => !known.has(repo));
319
+ }
320
+
321
+ /**
322
+ * Pure path selection for one repo: allowlist first, then denylist on top, so
323
+ * the two compose as defense in depth rather than one replacing the other.
324
+ *
325
+ * `relPaths` is the repo git-tracked file list. Refuses, loudly:
326
+ *
327
+ * include_paths_entry_* the entry is malformed or empty
328
+ * include_paths_no_match an entry matched ZERO tracked files
329
+ * include_paths_all_excluded the allowlist matched, excludePaths took it all
330
+ *
331
+ * The zero-match refusal is the point of the whole mechanism. A prefix that
332
+ * matches nothing (a rename, a misspelling, a directory that moved) otherwise
333
+ * plans an empty corpus, the mirror prune deletes what was there, the reindex
334
+ * succeeds over nothing, and the answer surface returns a confident nothing with
335
+ * every status light green. Coverage is measured against the tracked list before
336
+ * exclusion, so "this prefix names nothing in the repo" stays a distinct
337
+ * diagnostic from "everything it named was excluded".
338
+ */
339
+ export function selectRepoPaths(repo, relPaths, opts = {}) {
340
+ const { includePrefixes, excludePrefixes } = opts;
341
+ const paths = relPaths || [];
342
+ if (includePrefixes === undefined || includePrefixes === null) {
343
+ return paths.filter((rel) => !isExcludedPath(rel, excludePrefixes));
344
+ }
345
+
346
+ const shapeErrors = validateIncludePathsEntry(repo, includePrefixes);
347
+ if (shapeErrors.length) {
348
+ throw new IncludePathsError(shapeErrors[0].code, shapeErrors.map((e) => e.message).join(" "));
349
+ }
350
+
351
+ const unmatched = includePrefixes.filter((p) => !paths.some((rel) => matchesPathPrefix(rel, p)));
352
+ if (unmatched.length) {
353
+ throw new IncludePathsError(
354
+ "include_paths_no_match",
355
+ `includePaths for repo ${q(repo)} matched 0 git-tracked files: ` +
356
+ `${unmatched.map(q).join(", ")} (the repo has ${paths.length} tracked file(s)). ` +
357
+ `An allowlist entry that matches nothing would sync an empty corpus over a healthy one ` +
358
+ `and report success, so the sync refuses instead. Fix the prefix in targets.json ` +
359
+ `(repo-relative, trailing slash for a subtree), or drop the entry if the path is gone. ` +
360
+ `Only the prefixes named above matched nothing.`,
361
+ );
362
+ }
363
+
364
+ const included = paths.filter((rel) => isIncludedPath(rel, includePrefixes));
365
+ const kept = included.filter((rel) => !isExcludedPath(rel, excludePrefixes));
366
+ if (kept.length === 0) {
367
+ throw new IncludePathsError(
368
+ "include_paths_all_excluded",
369
+ `includePaths for repo ${q(repo)} selected ${included.length} file(s) and excludePaths ` +
370
+ `removed every one of them. That leaves nothing to sync for this repo, which is an ` +
371
+ `empty corpus dressed as a successful run. Widen includePaths or narrow excludePaths.`,
372
+ );
373
+ }
374
+ return kept;
375
+ }
package/scripts/sync.mjs CHANGED
@@ -36,7 +36,10 @@ import {
36
36
  ingestContentType,
37
37
  ingestKind,
38
38
  fileExt,
39
- isExcludedPath,
39
+ selectRepoPaths,
40
+ assertIncludePathsConfig,
41
+ unknownExcludePathsRepos,
42
+ IncludePathsError,
40
43
  } from "./sync-ingest.mjs";
41
44
  import {
42
45
  resolveTargetsPath,
@@ -47,11 +50,18 @@ import {
47
50
  const HERE = dirname(fileURLToPath(import.meta.url));
48
51
  const TARGETS_PATH = resolveTargetsPath(HERE);
49
52
  const REPO_ROOT = defaultRepoRoot(HERE, TARGETS_PATH);
50
- const MAX_BYTES = 4 * 1024 * 1024;
53
+ // Objects larger than this are skipped. Configurable because the right value
54
+ // depends on the corpus: 4 MB is fine for a source tree and wrong for scanned
55
+ // PDFs, where the single largest document is often the one that matters most.
56
+ // Skips are also summarised at the end of the run, not only warned inline: a
57
+ // warning in a long CI log is a silent omission in practice.
58
+ const MAX_BYTES = Number(process.env.SYNC_MAX_BYTES || 4 * 1024 * 1024);
51
59
 
52
60
  const args = process.argv.slice(2);
53
61
  const DRY = args.includes("--dry-run");
54
62
  const SKIP_GITHUB = args.includes("--no-github-verify") || process.env.SYNC_SKIP_GITHUB_VERIFY === "1";
63
+ // Opt-in strictness: turn an incomplete corpus into a failed run.
64
+ const FAIL_ON_SKIP = args.includes("--fail-on-skip");
55
65
  const targetName = args.find((a) => !a.startsWith("--"));
56
66
 
57
67
  const SKIP_EXT = new Set([
@@ -123,16 +133,33 @@ function loadConfig() {
123
133
  return JSON.parse(readFileSync(TARGETS_PATH, "utf8"));
124
134
  }
125
135
 
126
- function planRepo(repo, excludePrefixes = []) {
136
+ function planRepo(repo, excludePrefixes, includePrefixes, skipped = []) {
127
137
  const repoDir = join(REPO_ROOT, repo);
138
+ const hasAllowlist = includePrefixes !== undefined && includePrefixes !== null;
128
139
  if (!existsSync(repoDir)) {
140
+ // A repo with an allowlist has been described exactly: it contributes these
141
+ // paths and nothing else. A missing clone contributes nothing, which is the
142
+ // same empty-corpus-with-green-lights failure the allowlist exists to stop.
143
+ if (hasAllowlist) {
144
+ throw new IncludePathsError(
145
+ "include_paths_repo_not_cloned",
146
+ `includePaths for repo "${repo}" cannot be enforced: not cloned at ${repoDir}. ` +
147
+ `A repo with an allowlist is expected to contribute exactly those paths, so a ` +
148
+ `missing clone refuses the sync instead of quietly syncing nothing for it.`,
149
+ );
150
+ }
129
151
  console.warn(` ! skip ${repo}: not cloned at ${repoDir}`);
130
152
  return [];
131
153
  }
154
+ // Allowlist first, then denylist on top; both refusals happen here, before a
155
+ // single object is uploaded or pruned.
156
+ const candidates = selectRepoPaths(repo, trackedFiles(repoDir), {
157
+ includePrefixes,
158
+ excludePrefixes,
159
+ });
132
160
  const items = [];
133
- for (const rel of trackedFiles(repoDir)) {
161
+ for (const rel of candidates) {
134
162
  if (shouldSkip(rel)) continue;
135
- if (isExcludedPath(rel, excludePrefixes)) continue;
136
163
  const abs = join(repoDir, rel);
137
164
  let size;
138
165
  try {
@@ -141,7 +168,10 @@ function planRepo(repo, excludePrefixes = []) {
141
168
  continue;
142
169
  }
143
170
  if (size > MAX_BYTES) {
144
- console.warn(` ! skip ${repo}/${rel}: ${(size / 1048576).toFixed(1)} MB over 4 MB`);
171
+ const mb = (size / 1048576).toFixed(1);
172
+ const capMb = (MAX_BYTES / 1048576).toFixed(1);
173
+ console.warn(` ! skip ${repo}/${rel}: ${mb} MB over the ${capMb} MB cap`);
174
+ skipped.push({ repo, path: rel, mb });
145
175
  continue;
146
176
  }
147
177
  const sample = readTextSample(abs, size);
@@ -161,12 +191,23 @@ function planRepo(repo, excludePrefixes = []) {
161
191
  },
162
192
  });
163
193
  }
194
+ if (hasAllowlist && items.length === 0) {
195
+ throw new IncludePathsError(
196
+ "include_paths_all_filtered",
197
+ `includePaths for repo "${repo}" selected ${candidates.length} file(s) and none of them ` +
198
+ `survived the ingest filters (build/vendor directories, lockfiles, .env, binaries, the ` +
199
+ `size cap, or the text sniff). Nothing would upload for this repo, so the sync refuses ` +
200
+ `rather than publish an empty corpus.`,
201
+ );
202
+ }
164
203
  return items;
165
204
  }
166
205
 
167
206
  async function main() {
168
207
  if (!targetName) {
169
- console.error("usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify]");
208
+ console.error(
209
+ "usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify] [--fail-on-skip]",
210
+ );
170
211
  process.exit(2);
171
212
  }
172
213
  const cfg = loadConfig();
@@ -176,6 +217,17 @@ async function main() {
176
217
  process.exit(2);
177
218
  }
178
219
 
220
+ // Config-level allowlist checks run for every target, not just the one being
221
+ // synced: a rule that names a repo no target lists is rot, and rot found only
222
+ // when someone syncs that one target is rot found late.
223
+ assertIncludePathsConfig(cfg);
224
+ for (const repo of unknownExcludePathsRepos(cfg)) {
225
+ console.warn(
226
+ ` ! excludePaths names repo '${repo}', which no target lists in its repos. ` +
227
+ "That rule is inert; delete it or add the repo to a target.",
228
+ );
229
+ }
230
+
179
231
  assertPublicCorpusBoundary(cfg, targetName);
180
232
  if (targetName === "public" && !SKIP_GITHUB) {
181
233
  const gh = await assertPublicGithubVisibility(cfg);
@@ -195,13 +247,35 @@ async function main() {
195
247
  console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
196
248
 
197
249
  const plan = [];
250
+ const skipped = [];
198
251
  for (const repo of target.repos) {
199
- const items = planRepo(repo, cfg.excludePaths?.[repo]);
252
+ const items = planRepo(
253
+ repo,
254
+ cfg.excludePaths?.[repo],
255
+ cfg.includePaths?.[repo],
256
+ skipped,
257
+ );
200
258
  if (items.length) console.log(` + ${repo}: ${items.length} files`);
201
259
  plan.push(...items);
202
260
  }
203
261
  console.log(`Planned ${plan.length} objects for ${target.bucket}.`);
204
262
 
263
+ // Summarise oversize skips. A file silently missing from the corpus looks
264
+ // exactly like a file the corpus does not contain, which is the worst
265
+ // possible failure mode for something that answers questions.
266
+ if (skipped.length) {
267
+ console.warn(`\n ! ${skipped.length} file(s) skipped for exceeding the size cap:`);
268
+ for (const s of skipped) console.warn(` ${s.repo}/${s.path} (${s.mb} MB)`);
269
+ console.warn(
270
+ " These are NOT in the corpus. Raise SYNC_MAX_BYTES, extract their text into\n" +
271
+ " a smaller form, or accept that nothing can answer from them.",
272
+ );
273
+ if (FAIL_ON_SKIP) {
274
+ console.error("--fail-on-skip: refusing to continue with an incomplete corpus.");
275
+ process.exit(1);
276
+ }
277
+ }
278
+
205
279
  if (DRY) {
206
280
  for (const it of plan.slice(0, 25)) console.log(` ${it.key}`);
207
281
  if (plan.length > 25) console.log(` ... and ${plan.length - 25} more`);
@@ -283,6 +357,12 @@ async function main() {
283
357
  }
284
358
 
285
359
  main().catch((err) => {
360
+ // A config refusal is an operator error, not a crash: print the diagnostic
361
+ // without a stack, and exit 2 like the other targets.json failures.
362
+ if (err instanceof IncludePathsError) {
363
+ console.error(`\n${err.message}\n [${err.code}]`);
364
+ process.exit(2);
365
+ }
286
366
  console.error(err);
287
367
  process.exit(1);
288
368
  });
@@ -1,5 +1,11 @@
1
1
  {
2
2
  "_comment": "Copy to targets.json (./targets.json for npm CLI, or scripts/targets.json in a git clone). repos are directory names under SYNC_REPO_ROOT (default: cwd for npm, parent of this repo for dev clone). Only git-tracked files are uploaded.",
3
+ "_includePaths_comment": "ALLOWLIST, optional, per repo. When present, ONLY paths under these prefixes are eligible for the corpus and everything else is refused. Use it when the corpus boundary matters: a denylist is fail-OPEN, so a new top-level file silently joins the corpus, whereas an allowlist is fail-CLOSED. Omit a repo to keep the old behaviour of admitting everything; an entry that is present but EMPTY is an error, not a way to index nothing.",
4
+ "_includePaths_refusals": "A present allowlist must earn its keep: an entry matching zero git-tracked files, an entry naming a repo no target lists, a repo whose clone is missing, or a selection that excludePaths or the ingest filters empty out all refuse the sync (exit 2). An allowlist that quietly matches nothing would prune a healthy corpus and reindex over the emptiness with every status light green.",
5
+ "includePaths": {
6
+ "example-repo": ["docs/"]
7
+ },
8
+ "_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths.",
3
9
  "excludePaths": {
4
10
  "example-repo": ["vendor/private-notes/"]
5
11
  },