@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 +104 -0
- package/package.json +4 -2
- package/public/ask-widget.js +121 -7
- package/scripts/sync-ingest.mjs +243 -9
- package/scripts/sync.mjs +88 -8
- package/scripts/targets.json.example +6 -0
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.
|
|
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"
|
package/public/ask-widget.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
})();
|
package/scripts/sync-ingest.mjs
CHANGED
|
@@ -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
|
-
*
|
|
128
|
-
* path: with a trailing "/" it
|
|
129
|
-
* exact file or the subtree rooted at
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
},
|