@zenera/rag 1.1.24 → 1.1.26

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.
@@ -1,8 +1,7 @@
1
- import { tool } from '@zenera/neo';
2
- import { PatternError } from "../common/match.js";
1
+ import { PatternError, tool } from '@zenera/neo';
3
2
  import { assemble } from "./assemble.js";
4
3
  import { CHUNK_KINDS } from "./chunk.js";
5
- import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
4
+ import { grepLines, isFailure, listFiles, listSections, listTables, readMany, readRange, readSection, } from "./lookup.js";
6
5
  import { renderAssembly } from "./render.js";
7
6
  import { SEARCH_MODES } from "./search.js";
8
7
  // ---------------------------------------------------------------------------
@@ -248,7 +247,8 @@ export function docsTools(index, options = {}) {
248
247
  group: GROUP,
249
248
  description: 'Reads a document verbatim: a whole named section, or a line range as printed by ' +
250
249
  'search_docs or grep_docs. Use it when a passage was found and the lines around ' +
251
- 'it are needed in full, with nothing omitted and nothing summarised.',
250
+ 'it are needed in full, with nothing omitted and nothing summarised. To read ' +
251
+ 'several passages, name them all in `reads` in one call instead of calling again.',
252
252
  parameters: {
253
253
  type: 'object',
254
254
  properties: {
@@ -262,11 +262,38 @@ export function docsTools(index, options = {}) {
262
262
  },
263
263
  from: { type: 'integer', description: 'First line, 1-based. Default 1.' },
264
264
  to: { type: 'integer', description: 'Last line. Default the end of the document.' },
265
+ reads: {
266
+ type: 'array',
267
+ description: 'Instead of file: every passage to read, each as {file, section} or ' +
268
+ `{file, from, to}. They share a ceiling of ${MAX_LINES} lines.`,
269
+ items: {
270
+ type: 'object',
271
+ properties: {
272
+ file: { type: 'string' },
273
+ section: { type: 'string' },
274
+ from: { type: 'integer' },
275
+ to: { type: 'integer' },
276
+ },
277
+ required: ['file'],
278
+ additionalProperties: false,
279
+ },
280
+ },
265
281
  },
266
- required: ['file'],
267
282
  additionalProperties: false,
268
283
  },
269
284
  execute: async (args) => {
285
+ if (args.reads !== undefined) {
286
+ if (args.file !== undefined || args.reads.length === 0) {
287
+ return {
288
+ error: args.file ? 'send `file` or `reads`, not both' : '`reads` is empty',
289
+ hint: 'put every passage in `reads`, one {file, section} or {file, from, to} each',
290
+ };
291
+ }
292
+ return await readSeveral(args.reads);
293
+ }
294
+ if (!args.file) {
295
+ return { error: 'nothing to read', hint: 'send `file`, or `reads` for several' };
296
+ }
270
297
  const file = index.resolveFiles([args.file])[0];
271
298
  if (!file) {
272
299
  return {
@@ -293,6 +320,43 @@ export function docsTools(index, options = {}) {
293
320
  },
294
321
  });
295
322
  return [searchDocs, listDocs, grepDocs, readDocs];
323
+ async function readSeveral(reads) {
324
+ const names = new Set(index.manifest.sources.map((s) => s.name));
325
+ const resolved = await guard(() => reads.map((r) => ({
326
+ target: r.file,
327
+ file: names.has(r.file) ? r.file : (index.resolveFiles([r.file])[0] ?? r.file),
328
+ section: r.section,
329
+ from: r.from,
330
+ to: r.to,
331
+ })));
332
+ if ('error' in resolved) {
333
+ return resolved;
334
+ }
335
+ const read = await readMany(index, resolved, {
336
+ maxLines: MAX_LINES,
337
+ hints: {
338
+ document: () => 'call list_docs for the names',
339
+ section: () => 'call list_docs with what "sections" for its headings',
340
+ range: (_, total) => `it has ${total} lines`,
341
+ },
342
+ });
343
+ return {
344
+ printed: read.printed,
345
+ failed: read.failed,
346
+ results: read.results.map((r) => isFailure(r)
347
+ ? { file: r.file, error: r.error, hint: r.hint }
348
+ : {
349
+ file: r.file,
350
+ ...(r.section ? { section: r.section } : {}),
351
+ start: r.from,
352
+ end: r.to,
353
+ total: r.total,
354
+ truncated: r.truncated,
355
+ ...(r.continue ? { continue: r.continue } : {}),
356
+ text: r.lines.map((line, at) => `${r.from + at} | ${line}`).join('\n'),
357
+ }),
358
+ };
359
+ }
296
360
  }
297
361
  // ---------------------------------------------------------------------------
298
362
  const strings = (description) => ({
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
+ export { anyOf, isGlob, loose, matcher, MAX_PATTERN, PatternError, wildcard, type Matcher, type MatchOptions, } from '@zenera/neo';
1
2
  export * from './common/locate.ts';
2
3
  export * from './common/manifest.ts';
3
- export * from './common/match.ts';
4
4
  export * as docs from './docs/index.ts';
5
5
  export * from './schema/build.ts';
6
6
  export * from './schema/entities.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
+ export { anyOf, isGlob, loose, matcher, MAX_PATTERN, PatternError, wildcard, } from '@zenera/neo';
1
2
  export * from "./common/locate.js";
2
3
  export * from "./common/manifest.js";
3
- export * from "./common/match.js";
4
4
  // A namespace rather than a re-export: both subjects have a `Manifest`, a
5
5
  // `writeIndex` and a `search`, and flattening them would collide on every one.
6
6
  export * as docs from "./docs/index.js";
@@ -0,0 +1,17 @@
1
+ import { type HydrateOptions } from './schema/hydrate.ts';
2
+ import { type RenderOptions } from './schema/render.ts';
3
+ import type { Schema } from './schema/schema.ts';
4
+ import type { Operation } from './schema/spec.ts';
5
+ import type { Subgraph } from './schema/subgraph.ts';
6
+ export declare const FORMATS: readonly ["text", "mermaid", "mermaid-flowchart", "ts", "openapi"];
7
+ export type Format = (typeof FORMATS)[number];
8
+ export declare function isFormat(value: string): value is Format;
9
+ export interface OutputOptions extends RenderOptions, HydrateOptions {
10
+ }
11
+ /** Two of the four formats need what is on disk; an open index is enough. */
12
+ export interface SchemaSource {
13
+ schemas(): Promise<Record<string, Schema>>;
14
+ operations(): Promise<Operation[]>;
15
+ }
16
+ export declare function present(index: SchemaSource, subgraphs: readonly Subgraph[], format: Format, options?: OutputOptions): Promise<string>;
17
+ //# sourceMappingURL=present.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { toOpenApi, toTypeScript } from "./schema/hydrate.js";
2
+ import { render } from "./schema/render.js";
3
+ // ---------------------------------------------------------------------------
4
+ // One subgraph, in whichever of the four shapes was asked for
5
+ //
6
+ // The command, the prompt loop and the agent tool all print through here, so
7
+ // `--format ts` from a shell and `format: "ts"` from a model are the same
8
+ // bytes. Two of the four need the schemas off disk and two do not, which is
9
+ // the only reason this is async.
10
+ // ---------------------------------------------------------------------------
11
+ export const FORMATS = ['text', 'mermaid', 'mermaid-flowchart', 'ts', 'openapi'];
12
+ export function isFormat(value) {
13
+ return FORMATS.includes(value);
14
+ }
15
+ export async function present(index, subgraphs, format, options = {}) {
16
+ if (subgraphs.length === 0) {
17
+ return format === 'openapi' ? '{}' : '';
18
+ }
19
+ if (format === 'ts') {
20
+ const schemas = await index.schemas();
21
+ return subgraphs.map((s) => toTypeScript(s, schemas, options)).join('\n');
22
+ }
23
+ if (format === 'openapi') {
24
+ const [schemas, operations] = await Promise.all([index.schemas(), index.operations()]);
25
+ // One document for the whole answer: several would each have to repeat
26
+ // the components the others also touched.
27
+ const merged = subgraphs.reduce((all, one) => ({
28
+ nodes: [...all.nodes, ...one.nodes],
29
+ edges: [...all.edges, ...one.edges],
30
+ hits: [...all.hits, ...one.hits],
31
+ score: all.score + one.score,
32
+ truncated: all.truncated || one.truncated,
33
+ }), { nodes: [], edges: [], hits: [], score: 0, truncated: false });
34
+ return JSON.stringify(toOpenApi(merged, schemas, operations), null, 2);
35
+ }
36
+ return subgraphs
37
+ .map((s, at) => `${subgraphs.length > 1 ? `# result ${at + 1}\n` : ''}${render(s, format, options)}`)
38
+ .join('\n\n');
39
+ }
40
+ //# sourceMappingURL=present.js.map
@@ -0,0 +1,7 @@
1
+ import type { SchemaQuery } from './schema/search.ts';
2
+ export declare class QueryError extends Error {
3
+ }
4
+ export declare function parseQuery(value: unknown): SchemaQuery;
5
+ /** Whether anything was actually asked, as opposed to only filtered. */
6
+ export declare function isEmpty(query: SchemaQuery): boolean;
7
+ //# sourceMappingURL=query.d.ts.map
package/dist/query.js ADDED
@@ -0,0 +1,88 @@
1
+ // ---------------------------------------------------------------------------
2
+ // A query, from somewhere untrusted
3
+ //
4
+ // `--query` and the tool both hand over a whole object, which means the shape
5
+ // has to be checked rather than assumed. The rule is the same either way: a
6
+ // key that is not known is an error, because a silently ignored
7
+ // `output_propertys` looks exactly like a search that found nothing.
8
+ // ---------------------------------------------------------------------------
9
+ const LISTS = [
10
+ 'all',
11
+ 'methods',
12
+ 'types',
13
+ 'input_types',
14
+ 'output_types',
15
+ 'properties',
16
+ 'input_properties',
17
+ 'output_properties',
18
+ 'exclude_ids',
19
+ 'exclude_methods',
20
+ 'exclude_types',
21
+ 'exclude_properties',
22
+ ];
23
+ const NUMBERS = ['limit', 'max_hops', 'max_nodes'];
24
+ const DIRECTIONS = ['input', 'output', 'any'];
25
+ const METHOD_TYPES = ['read_only', 'read_write', 'any'];
26
+ const KNOWN = new Set([...LISTS, ...NUMBERS, 'direction', 'method_type']);
27
+ export class QueryError extends Error {
28
+ }
29
+ export function parseQuery(value) {
30
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
31
+ throw new QueryError('a query must be an object');
32
+ }
33
+ const input = value;
34
+ for (const key of Object.keys(input)) {
35
+ if (!KNOWN.has(key)) {
36
+ throw new QueryError(`unknown query field "${key}" — expected ${[...KNOWN].join(', ')}`);
37
+ }
38
+ }
39
+ const out = {};
40
+ for (const key of LISTS) {
41
+ const list = input[key];
42
+ if (list === undefined) {
43
+ continue;
44
+ }
45
+ if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
46
+ throw new QueryError(`${key} must be an array of strings`);
47
+ }
48
+ out[key] = list;
49
+ }
50
+ for (const key of NUMBERS) {
51
+ const number = input[key];
52
+ if (number === undefined) {
53
+ continue;
54
+ }
55
+ if (typeof number !== 'number' || !Number.isInteger(number) || number < 1) {
56
+ throw new QueryError(`${key} must be a whole number of at least 1`);
57
+ }
58
+ out[key] = number;
59
+ }
60
+ if (input.direction !== undefined) {
61
+ out.direction = oneOf('direction', input.direction, DIRECTIONS);
62
+ }
63
+ if (input.method_type !== undefined) {
64
+ out.method_type = oneOf('method_type', input.method_type, METHOD_TYPES);
65
+ }
66
+ return out;
67
+ }
68
+ /** Whether anything was actually asked, as opposed to only filtered. */
69
+ export function isEmpty(query) {
70
+ const terms = [
71
+ query.all,
72
+ query.methods,
73
+ query.types,
74
+ query.input_types,
75
+ query.output_types,
76
+ query.properties,
77
+ query.input_properties,
78
+ query.output_properties,
79
+ ];
80
+ return terms.every((list) => !list || list.length === 0);
81
+ }
82
+ function oneOf(key, value, allowed) {
83
+ if (typeof value !== 'string' || !allowed.includes(value)) {
84
+ throw new QueryError(`${key} must be one of ${allowed.join(', ')}`);
85
+ }
86
+ return value;
87
+ }
88
+ //# sourceMappingURL=query.js.map
package/dist/repl.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { type Format, type OutputOptions } from './present.ts';
2
+ import type { SchemaIndex, SchemaQuery } from './schema/search.ts';
3
+ interface Settings extends OutputOptions {
4
+ format: Format;
5
+ }
6
+ export declare function repl(index: SchemaIndex, initial: SchemaQuery, settings: Settings): Promise<void>;
7
+ export {};
8
+ //# sourceMappingURL=repl.d.ts.map
package/dist/repl.js ADDED
@@ -0,0 +1,119 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { bold, cyan, dim, note, write } from '@zenera/cli/lib';
3
+ import { isFormat, present } from "./present.js";
4
+ import { isEmpty, parseQuery, QueryError } from "./query.js";
5
+ const FIELDS = {
6
+ all: 'all',
7
+ method: 'methods',
8
+ type: 'types',
9
+ 'input-type': 'input_types',
10
+ 'output-type': 'output_types',
11
+ property: 'properties',
12
+ 'input-property': 'input_properties',
13
+ 'output-property': 'output_properties',
14
+ };
15
+ export async function repl(index, initial, settings) {
16
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
17
+ let query = { ...initial };
18
+ let format = settings.format;
19
+ const options = { docs: settings.docs, onlyHits: settings.onlyHits };
20
+ note(bold(`${index.manifest.sources.map((s) => s.title).join(', ')}`));
21
+ note(dim(' a bare line searches every kind; `help` lists the rest.'));
22
+ try {
23
+ if (!isEmpty(query)) {
24
+ query = await run(index, query, format, options);
25
+ }
26
+ for (;;) {
27
+ const line = (await rl.question(cyan('rag> '))).trim();
28
+ if (line === '') {
29
+ continue;
30
+ }
31
+ if (line === 'quit' || line === 'exit') {
32
+ return;
33
+ }
34
+ if (line === 'help') {
35
+ help();
36
+ continue;
37
+ }
38
+ if (line === 'reset') {
39
+ query = {};
40
+ note(dim(' cleared, exclusions and all'));
41
+ continue;
42
+ }
43
+ if (line === 'show') {
44
+ note(dim(` ${JSON.stringify(query)}`));
45
+ continue;
46
+ }
47
+ const [head, ...rest] = line.split(' ');
48
+ const tail = rest.join(' ').trim();
49
+ if (head === 'format') {
50
+ if (isFormat(tail)) {
51
+ format = tail;
52
+ note(dim(` format is ${tail}`));
53
+ }
54
+ else {
55
+ note(dim(' expected text, mermaid, mermaid-flowchart, ts or openapi'));
56
+ }
57
+ continue;
58
+ }
59
+ if (head === 'direction' || head === 'method-type') {
60
+ query = patch(query, head === 'direction' ? 'direction' : 'method_type', tail);
61
+ continue;
62
+ }
63
+ if (head && head in FIELDS && tail) {
64
+ query = { ...query, [FIELDS[head]]: [tail] };
65
+ query = await run(index, query, format, options);
66
+ continue;
67
+ }
68
+ query = await run(index, { ...query, all: [line] }, format, options);
69
+ }
70
+ }
71
+ finally {
72
+ rl.close();
73
+ }
74
+ }
75
+ /**
76
+ * Runs the search, prints it, and hands back the query with everything just
77
+ * shown added to the exclusions — so asking the same thing twice moves on
78
+ * instead of repeating itself.
79
+ */
80
+ async function run(index, query, format, options) {
81
+ const result = await index.search(query);
82
+ const text = await present(index, result.subgraphs, format, options);
83
+ if (text) {
84
+ write(text);
85
+ }
86
+ note(dim(` ${result.seeds.length} seed(s) · ${result.subgraphs.length} result(s)` +
87
+ (result.empty.length > 0 ? ` · nothing for: ${result.empty.join(', ')}` : '')));
88
+ if (result.seeds.length === 0) {
89
+ return query;
90
+ }
91
+ const seen = new Set([...(query.exclude_ids ?? []), ...result.seeds.map((s) => s.id)]);
92
+ note(dim(` ${seen.size} node(s) now excluded — \`reset\` to see them again`));
93
+ return { ...query, exclude_ids: [...seen] };
94
+ }
95
+ function patch(query, key, value) {
96
+ try {
97
+ return { ...query, ...parseQuery({ [key]: value }) };
98
+ }
99
+ catch (err) {
100
+ note(dim(` ${err instanceof QueryError ? err.message : String(err)}`));
101
+ return query;
102
+ }
103
+ }
104
+ function help() {
105
+ for (const line of [
106
+ ' <text> search everything',
107
+ ' all|method|type <text> search one field',
108
+ ' input-property <text> also: output-property, property, input-type, output-type',
109
+ ' direction <d> input | output | any',
110
+ ' method-type <t> read_only | read_write | any',
111
+ ' format <f> text | mermaid | mermaid-flowchart | ts | openapi',
112
+ ' show the query as it stands',
113
+ ' reset forget it, exclusions included',
114
+ ' quit',
115
+ ]) {
116
+ note(dim(line));
117
+ }
118
+ }
119
+ //# sourceMappingURL=repl.js.map
@@ -1,10 +1,10 @@
1
1
  import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
2
+ import { isGlob, loose, matcher, PatternError, wildcard } from '@zenera/neo';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { join, relative, resolve } from 'node:path';
4
5
  import { resolveEmbedder } from "../common/embedder.js";
5
6
  import { locateIndex, outputDir } from "../common/locate.js";
6
7
  import { assertSameEmbedding, inspectIndex } from "../common/manifest.js";
7
- import { isGlob, loose, matcher, PatternError, wildcard } from "../common/match.js";
8
8
  import { breakdown, indexing } from "../common/prose.js";
9
9
  import { announce, reportReady, stagingFor, swapIn } from "../common/restore.js";
10
10
  import { buildIndex } from "./build.js";
@@ -0,0 +1,20 @@
1
+ /** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
2
+ export declare const DEFAULT_DIR = "./schema-db";
3
+ export declare const DIR_ENV = "ZEN_SCHEMA_DB";
4
+ /** How the directory was arrived at, which is what decides whether to say so. */
5
+ export type DirSource = 'flag' | 'env' | 'found' | 'default';
6
+ export interface Located {
7
+ dir: string;
8
+ from: DirSource;
9
+ }
10
+ export interface LocateOptions {
11
+ env?: NodeJS.ProcessEnv;
12
+ /** do not climb above this; the home directory, or the root, by default */
13
+ ceiling?: string;
14
+ }
15
+ export declare function locateIndex(cwd: string, flag?: string, options?: LocateOptions): Located;
16
+ /** Where a new index goes: the same environment variable, minus the search. */
17
+ export declare function outputDir(cwd: string, flag?: string, env?: NodeJS.ProcessEnv): string;
18
+ /** An index is a directory with a manifest in it; nothing else is asserted here. */
19
+ export declare function isIndex(dir: string): boolean;
20
+ //# sourceMappingURL=locate.d.ts.map
@@ -0,0 +1,141 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
5
+ import { MANIFEST_FILE } from "./files.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Which index, when nobody said
8
+ //
9
+ // `--dir` and `$ZEN_SCHEMA_DB` are taken as written, missing or not: naming a
10
+ // directory that turns out not to hold an index has to fail saying so, because
11
+ // quietly using a different one would be a worse answer than an error.
12
+ //
13
+ // With neither, the directory is looked for. There is no list of blessed names
14
+ // here and there should not be — an index is self-describing, so what is being
15
+ // looked for is a `manifest.json`, not a directory called `schema-db`.
16
+ // `schema-db` is only the name a *new* index is given, and nothing reads it
17
+ // back. The search is nearest-first: this directory, then what is under it,
18
+ // then up a level and again, so `/assets/…/whatever` is reachable from
19
+ // `/workspace` because the two meet at a shared root on the way up.
20
+ //
21
+ // Three things bound it, and each is bounding a different kind of mistake.
22
+ // Depth and a visit budget bound the cost. The ceiling — the home directory,
23
+ // or the filesystem root when the search began outside it — bounds the
24
+ // blast radius, because an index in someone else's tree is not yours. And two
25
+ // indexes at the same distance is an ambiguity rather than a tie to break:
26
+ // choosing one silently is the one failure worth ruling out entirely, since
27
+ // the wrong index does not error, it answers confidently about another API.
28
+ // ---------------------------------------------------------------------------
29
+ /** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
30
+ export const DEFAULT_DIR = './schema-db';
31
+ export const DIR_ENV = 'ZEN_SCHEMA_DB';
32
+ /** Far enough to climb out of a package into its workspace, not far enough to roam. */
33
+ const MAX_LEVELS = 6;
34
+ /** How far below a directory an index may sit and still count as being in it. */
35
+ const MAX_DEPTH = 3;
36
+ /** A directory with more entries than this is a data store, not a place to keep an index. */
37
+ const MAX_ENTRIES = 128;
38
+ /** Directories the whole search may read, however it is shaped. */
39
+ const MAX_VISITS = 400;
40
+ export function locateIndex(cwd, flag, options = {}) {
41
+ const env = options.env ?? process.env;
42
+ if (flag) {
43
+ return { dir: resolve(cwd, flag), from: 'flag' };
44
+ }
45
+ const named = env[DIR_ENV]?.trim();
46
+ if (named) {
47
+ return { dir: resolve(cwd, named), from: 'env' };
48
+ }
49
+ const found = search(resolve(cwd), options.ceiling ?? ceilingFor(cwd));
50
+ // Nothing found still answers with the default, so the error names the
51
+ // directory everyone expects rather than the last place that was searched.
52
+ return found
53
+ ? { dir: found, from: 'found' }
54
+ : { dir: resolve(cwd, DEFAULT_DIR), from: 'default' };
55
+ }
56
+ /** Where a new index goes: the same environment variable, minus the search. */
57
+ export function outputDir(cwd, flag, env = process.env) {
58
+ return resolve(cwd, flag ?? env[DIR_ENV]?.trim() ?? DEFAULT_DIR);
59
+ }
60
+ /** An index is a directory with a manifest in it; nothing else is asserted here. */
61
+ export function isIndex(dir) {
62
+ try {
63
+ return statSync(join(dir, MANIFEST_FILE)).isFile();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function search(cwd, ceiling) {
70
+ const budget = { left: MAX_VISITS, seen: new Set() };
71
+ let dir = cwd;
72
+ for (let level = 0; level < MAX_LEVELS; level++) {
73
+ const found = nearest(dir, budget);
74
+ if (found.length === 1) {
75
+ return found[0];
76
+ }
77
+ if (found.length > 1) {
78
+ throw new CliError(`more than one index is equally close to here: ${found.join(', ')}`, EXIT.usage, `say which with --dir, or set ${DIR_ENV}`);
79
+ }
80
+ budget.seen.add(dir);
81
+ const up = dirname(dir);
82
+ if (up === dir || dir === ceiling) {
83
+ break;
84
+ }
85
+ dir = up;
86
+ }
87
+ return undefined;
88
+ }
89
+ /** Every index at the shallowest depth that has any, so a tie can be reported as one. */
90
+ function nearest(root, budget) {
91
+ let frontier = [root];
92
+ for (let depth = 0; depth <= MAX_DEPTH && frontier.length > 0; depth++) {
93
+ const found = frontier.filter(isIndex);
94
+ if (found.length > 0) {
95
+ return found;
96
+ }
97
+ const next = [];
98
+ for (const dir of frontier) {
99
+ if (budget.left <= 0) {
100
+ return [];
101
+ }
102
+ budget.left--;
103
+ next.push(...children(dir).filter((child) => !budget.seen.has(child)));
104
+ }
105
+ frontier = next;
106
+ }
107
+ return [];
108
+ }
109
+ /**
110
+ * The subdirectories of one directory. Hidden directories and `node_modules`
111
+ * are skipped: an index kept out of sight is not one anybody meant to be found
112
+ * by looking.
113
+ */
114
+ function children(dir) {
115
+ let entries;
116
+ try {
117
+ entries = readdirSync(dir, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ if (entries.length > MAX_ENTRIES) {
123
+ return [];
124
+ }
125
+ return entries
126
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
127
+ .map((e) => join(dir, e.name));
128
+ }
129
+ /**
130
+ * Home is the ceiling for anyone working inside it. Starting outside it —
131
+ * a container whose workspace is `/workspace`, a CI checkout — there is no
132
+ * home to stay within, so the root is the only stop.
133
+ */
134
+ function ceilingFor(cwd) {
135
+ const home = homedir();
136
+ const below = relative(home, resolve(cwd));
137
+ return below === '' || (!below.startsWith('..') && !isAbsolute(below))
138
+ ? home
139
+ : parse(resolve(cwd)).root;
140
+ }
141
+ //# sourceMappingURL=locate.js.map
@@ -1,4 +1,4 @@
1
- import { type Matcher } from '../common/match.ts';
1
+ import { type Matcher } from '@zenera/neo';
2
2
  import { type ApiGraph, type NodeAttrs, type NodeKind } from './graph.ts';
3
3
  export interface Row extends NodeAttrs {
4
4
  id: string;
@@ -1,4 +1,4 @@
1
- import { PatternError } from "../common/match.js";
1
+ import { PatternError } from '@zenera/neo';
2
2
  import { textOf } from "./entities.js";
3
3
  import { methodId } from "./graph.js";
4
4
  /** A scan is bounded, because a pattern may have come from a model. */
@@ -0,0 +1,40 @@
1
+ /** Long enough for any honest pattern, short enough to bound a bad one. */
2
+ export declare const MAX_PATTERN = 200;
3
+ export declare class PatternError extends Error {
4
+ }
5
+ export interface MatchOptions {
6
+ /** read the pattern as a regular expression rather than as a literal */
7
+ regex?: boolean;
8
+ caseSensitive?: boolean;
9
+ }
10
+ export type Matcher = (text: string) => boolean;
11
+ /**
12
+ * A predicate over a string. Literal by default: someone typing `user.id` means
13
+ * those seven characters, and a dot that quietly matched anything would be a
14
+ * worse answer than no answer.
15
+ */
16
+ export declare function matcher(pattern: string, options?: MatchOptions): Matcher;
17
+ /**
18
+ * A glob, matched against the whole string. Globs rather than regexes because
19
+ * these are for naming things — a path, a schema — and a star is what everyone
20
+ * reaches for first.
21
+ */
22
+ export declare function wildcard(pattern: string, options?: {
23
+ caseSensitive?: boolean;
24
+ }): Matcher;
25
+ /** Whether a pattern is asking to be read as a glob at all. */
26
+ export declare const isGlob: (pattern: string) => boolean;
27
+ /**
28
+ * What someone means when they type a name into a filter. With a star in it,
29
+ * a glob; without one, a substring — because `password` typed into `--name` is
30
+ * a search for the word, and a whole-string match would answer nothing and
31
+ * look like the field does not exist.
32
+ *
33
+ * `regex` settles it outright, and has to: a star is punctuation in both
34
+ * languages, so `^/(users|teams)/.*` read as a glob would match nothing and
35
+ * never say why.
36
+ */
37
+ export declare function loose(pattern: string, options?: MatchOptions): Matcher;
38
+ /** True when any of the patterns matches; no patterns means no opinion. */
39
+ export declare function anyOf(matchers: readonly Matcher[]): Matcher | undefined;
40
+ //# sourceMappingURL=match.d.ts.map