docspack 0.1.1 → 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 +11 -4
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +226 -29
- package/dist/build.js.map +1 -1
- package/dist/cli.js +95 -124
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +9 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +17 -2
- package/dist/db.js.map +1 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +47 -25
- package/dist/discovery.js.map +1 -1
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +8 -4
- package/dist/doctor.js.map +1 -1
- package/dist/eval.d.ts +52 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/eval.js +101 -0
- package/dist/eval.js.map +1 -0
- package/dist/help.d.ts +31 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +342 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +4 -2
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +12 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +36 -6
- package/dist/search.js.map +1 -1
- package/dist/spec.d.ts +23 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +47 -6
- package/dist/spec.js.map +1 -1
- package/dist/stopwords.d.ts +14 -0
- package/dist/stopwords.d.ts.map +1 -0
- package/dist/stopwords.js +121 -0
- package/dist/stopwords.js.map +1 -0
- package/dist/style.d.ts.map +1 -1
- package/dist/style.js +9 -2
- package/dist/style.js.map +1 -1
- package/dist/sync.js +1 -1
- package/dist/sync.js.map +1 -1
- package/dist/verify.d.ts +8 -2
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +48 -15
- package/dist/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +274 -29
- package/src/cli.ts +114 -124
- package/src/config.ts +35 -5
- package/src/db.ts +17 -2
- package/src/discovery.ts +44 -22
- package/src/doctor.ts +14 -5
- package/src/eval.ts +149 -0
- package/src/help.ts +382 -0
- package/src/index.ts +21 -0
- package/src/preview.ts +4 -1
- package/src/search.ts +50 -6
- package/src/spec.ts +69 -7
- package/src/stopwords.ts +120 -0
- package/src/style.ts +12 -2
- package/src/sync.ts +1 -1
- package/src/verify.ts +69 -19
package/src/search.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SearchHit, Store } from "./db.js";
|
|
2
2
|
import { discoverPackages } from "./discovery.js";
|
|
3
|
-
import { isCommunityPackage } from "./spec.js";
|
|
3
|
+
import { formatDocumentedLibrary, isCommunityPackage } from "./spec.js";
|
|
4
4
|
|
|
5
5
|
/** Ceiling on how much context one query may return, per the blueprint's context-exhaustion rule. */
|
|
6
6
|
export const DEFAULT_MAX_TOKENS = 3000;
|
|
@@ -25,6 +25,12 @@ export interface QueryHit extends SearchHit {
|
|
|
25
25
|
readonly name: string;
|
|
26
26
|
readonly version: string;
|
|
27
27
|
readonly trusted: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Libraries the package documents, from its manifest. A docs package version cannot always
|
|
30
|
+
* imply this — a monorepo documents many libraries at many versions from one surface — so the
|
|
31
|
+
* answer states it rather than leaving the reader to infer it from the package name.
|
|
32
|
+
*/
|
|
33
|
+
readonly documents?: readonly string[];
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface QueryResult {
|
|
@@ -32,13 +38,31 @@ export interface QueryResult {
|
|
|
32
38
|
readonly tokens: number;
|
|
33
39
|
/** True when any hit came from an unvetted community package. */
|
|
34
40
|
readonly untrusted: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Packages this project depends on that are installed but absent from the store. Nothing they
|
|
43
|
+
* document can match until `docspack sync` runs, which is a different answer from "nothing
|
|
44
|
+
* covers this question".
|
|
45
|
+
*/
|
|
46
|
+
readonly unindexed: readonly string[];
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
|
|
38
50
|
const scoped = options.scoped !== false;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
const installed = scoped ? (await discoverPackages(options.cwd)).packages : undefined;
|
|
52
|
+
const packageIds = installed?.map((pkg) => pkg.id);
|
|
53
|
+
const unindexed = (installed ?? [])
|
|
54
|
+
.filter((pkg) => !options.store.hasPackage(pkg.id))
|
|
55
|
+
.map((pkg) => pkg.id);
|
|
56
|
+
|
|
57
|
+
// Read from the installed manifests rather than the index: the store is a cache of chunk text,
|
|
58
|
+
// and adding a column to it would make every existing store need a rebuild to answer this.
|
|
59
|
+
const documented = new Map<string, readonly string[]>();
|
|
60
|
+
for (const pkg of installed ?? []) {
|
|
61
|
+
const documents = pkg.manifest.documents;
|
|
62
|
+
if (documents !== undefined && documents.length > 0) {
|
|
63
|
+
documented.set(pkg.id, documents.map(formatDocumentedLibrary));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
|
|
43
67
|
const hits = options.store
|
|
44
68
|
.search(options.query, {
|
|
@@ -51,13 +75,21 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
|
|
|
51
75
|
})
|
|
52
76
|
.map((hit): QueryHit => {
|
|
53
77
|
const { name, version } = splitPackageId(hit.packageId);
|
|
54
|
-
|
|
78
|
+
const documents = documented.get(hit.packageId);
|
|
79
|
+
return {
|
|
80
|
+
...hit,
|
|
81
|
+
name,
|
|
82
|
+
version,
|
|
83
|
+
trusted: !isCommunityPackage(name),
|
|
84
|
+
...(documents === undefined ? {} : { documents }),
|
|
85
|
+
};
|
|
55
86
|
});
|
|
56
87
|
|
|
57
88
|
return {
|
|
58
89
|
hits,
|
|
59
90
|
tokens: hits.reduce((total, hit) => total + hit.tokens, 0),
|
|
60
91
|
untrusted: hits.some((hit) => !hit.trusted),
|
|
92
|
+
unindexed,
|
|
61
93
|
};
|
|
62
94
|
}
|
|
63
95
|
|
|
@@ -71,14 +103,26 @@ const UNTRUSTED_NOTICE =
|
|
|
71
103
|
*/
|
|
72
104
|
export function renderAnswer(result: QueryResult, query: string): string {
|
|
73
105
|
if (result.hits.length === 0) {
|
|
106
|
+
// Forgetting `sync` is the likeliest first mistake, and the tool can tell it apart from a
|
|
107
|
+
// question nothing covers: it can see what is installed and what is in the store.
|
|
108
|
+
if (result.unindexed.length > 0) {
|
|
109
|
+
return `No local documentation matched "${query}", and ${result.unindexed.join(", ")} ${result.unindexed.length === 1 ? "is" : "are"} installed but not indexed. Run \`docspack sync\`, then ask again.`;
|
|
110
|
+
}
|
|
74
111
|
return `No local documentation matched "${query}". The project may not depend on a docspack package covering it.`;
|
|
75
112
|
}
|
|
76
113
|
|
|
77
114
|
const sections = result.hits.map((hit) => {
|
|
78
115
|
const trust = hit.trusted ? "" : " (community)";
|
|
116
|
+
// Which release the chunk describes, on the line that already carries provenance. An agent
|
|
117
|
+
// otherwise has to assume the docs package version is the library version, which a
|
|
118
|
+
// repository publishing eighteen packages from one docs surface cannot make true.
|
|
119
|
+
const describes =
|
|
120
|
+
hit.documents === undefined || hit.documents.length === 0
|
|
121
|
+
? ""
|
|
122
|
+
: ` · documents ${hit.documents.join(", ")}`;
|
|
79
123
|
return [
|
|
80
124
|
`## ${hit.chunkId}${trust}`,
|
|
81
|
-
`Source: ${hit.name}@${hit.version} — ${hit.filePath}`,
|
|
125
|
+
`Source: ${hit.name}@${hit.version} — ${hit.filePath}${describes}`,
|
|
82
126
|
"",
|
|
83
127
|
hit.content,
|
|
84
128
|
].join("\n");
|
package/src/spec.ts
CHANGED
|
@@ -6,20 +6,35 @@ export const LLMS_DIR = ".llms";
|
|
|
6
6
|
export const MANIFEST_FILE = "manifest.json";
|
|
7
7
|
export const CHUNKS_DIR = "chunks";
|
|
8
8
|
export const SCHEMA_URL = "https://docspack.dev/schema/v1.json";
|
|
9
|
+
/** Prose specification of the package format. The hint on a validation failure points here. */
|
|
10
|
+
export const SPEC_URL = "https://docspack.dev/spec";
|
|
9
11
|
|
|
10
12
|
/** One retrievable unit of documentation. */
|
|
11
13
|
export interface ChunkSpec {
|
|
12
14
|
readonly id: string;
|
|
13
15
|
/** Path relative to the package's `.llms/` directory. */
|
|
14
16
|
readonly file: string;
|
|
15
|
-
|
|
17
|
+
/** Absent means "estimate it". Never 0: an empty chunk is a different problem. */
|
|
18
|
+
readonly tokens?: number;
|
|
16
19
|
readonly tags: readonly string[];
|
|
17
20
|
readonly entities: readonly string[];
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/** A library release the package documents, e.g. `acme` at `1.4.0`. */
|
|
24
|
+
export interface DocumentedLibrary {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** A version or a range, as the author wrote it. Absent when the docs are not release-scoped. */
|
|
27
|
+
readonly version?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
export interface PackageManifest {
|
|
21
31
|
readonly name: string;
|
|
22
32
|
readonly version: string;
|
|
33
|
+
/**
|
|
34
|
+
* What the package documents, which the package's own version cannot always say: a monorepo
|
|
35
|
+
* publishes many libraries at many versions from one documentation surface.
|
|
36
|
+
*/
|
|
37
|
+
readonly documents?: readonly DocumentedLibrary[];
|
|
23
38
|
readonly chunks: readonly ChunkSpec[];
|
|
24
39
|
}
|
|
25
40
|
|
|
@@ -48,6 +63,21 @@ export function chunkId(pkgId: string, chunk: string): string {
|
|
|
48
63
|
return `${pkgId}/${chunk}`;
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Reads `acme`, `acme@1.4.0` or `@acme/sdk@^1.4.0` as a library and an optional version. The
|
|
68
|
+
* separator is the last `@`, since a scope puts one at the start of the name as well.
|
|
69
|
+
*/
|
|
70
|
+
export function parseDocumentedLibrary(spec: string): DocumentedLibrary {
|
|
71
|
+
const at = spec.lastIndexOf("@");
|
|
72
|
+
if (at <= 0) return { name: spec };
|
|
73
|
+
return { name: spec.slice(0, at), version: spec.slice(at + 1) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `acme@1.4.0`, or just the name when no version is scoped. The form an answer is labelled with. */
|
|
77
|
+
export function formatDocumentedLibrary(library: DocumentedLibrary): string {
|
|
78
|
+
return library.version === undefined ? library.name : `${library.name}@${library.version}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
51
81
|
/**
|
|
52
82
|
* Resolves a manifest `file` entry inside the package's `.llms/` directory, refusing anything
|
|
53
83
|
* that escapes it. Manifests are third-party input, so this is a security boundary, not a
|
|
@@ -73,7 +103,7 @@ export function parseManifest(raw: unknown, where: string): PackageManifest {
|
|
|
73
103
|
// The explicit annotation is what lets TypeScript treat a `fail(...)` call as unreachable-after.
|
|
74
104
|
const fail: (message: string) => never = (message: string): never => {
|
|
75
105
|
throw new DocspackError(`${where}: ${message}`, {
|
|
76
|
-
hint: `See the package specification: ${
|
|
106
|
+
hint: `See the package specification: ${SPEC_URL}`,
|
|
77
107
|
});
|
|
78
108
|
};
|
|
79
109
|
|
|
@@ -86,6 +116,7 @@ export function parseManifest(raw: unknown, where: string): PackageManifest {
|
|
|
86
116
|
if (typeof version !== "string" || version.length === 0) fail('missing string field "version"');
|
|
87
117
|
if (!Array.isArray(root.chunks)) fail('missing "chunks" array');
|
|
88
118
|
|
|
119
|
+
const documents = parseDocuments(root.documents, fail);
|
|
89
120
|
const seen = new Set<string>();
|
|
90
121
|
const chunks = (root.chunks as unknown[]).map((entry, index): ChunkSpec => {
|
|
91
122
|
if (typeof entry !== "object" || entry === null)
|
|
@@ -104,21 +135,40 @@ export function parseManifest(raw: unknown, where: string): PackageManifest {
|
|
|
104
135
|
return fail(`chunk "${id}" is missing its "file"`);
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
// 0 is refused rather than read as "estimate it": the schema accepts it as a deliberate
|
|
139
|
+
// count, the reader would treat it as absent, and no chunk is genuinely 0 tokens.
|
|
107
140
|
const tokens = chunk.tokens;
|
|
108
|
-
if (tokens !== undefined && (!Number.isInteger(tokens) || (tokens as number) <
|
|
109
|
-
return fail(`chunk "${id}" has an invalid "tokens" value`);
|
|
141
|
+
if (tokens !== undefined && (!Number.isInteger(tokens) || (tokens as number) < 1)) {
|
|
142
|
+
return fail(`chunk "${id}" has an invalid "tokens" value; omit it to have it estimated`);
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
return {
|
|
113
146
|
id,
|
|
114
147
|
file,
|
|
115
|
-
|
|
148
|
+
...(typeof tokens === "number" ? { tokens } : {}),
|
|
116
149
|
tags: stringArray(chunk.tags, `chunk "${id}" field "tags"`, fail),
|
|
117
150
|
entities: stringArray(chunk.entities, `chunk "${id}" field "entities"`, fail),
|
|
118
151
|
};
|
|
119
152
|
});
|
|
120
153
|
|
|
121
|
-
return { name, version, chunks };
|
|
154
|
+
return { name, version, ...(documents === undefined ? {} : { documents }), chunks };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `["acme@1.4.0", "@acme/sdk@^2"]`. An array even for one library, because that is what the
|
|
159
|
+
* schema declares — a reader that accepts a shape the validator rejects is the same defect as a
|
|
160
|
+
* validator that accepts a value the reader reinterprets.
|
|
161
|
+
*/
|
|
162
|
+
function parseDocuments(
|
|
163
|
+
value: unknown,
|
|
164
|
+
fail: (message: string) => never,
|
|
165
|
+
): readonly DocumentedLibrary[] | undefined {
|
|
166
|
+
if (value === undefined) return undefined;
|
|
167
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry === "")) {
|
|
168
|
+
fail('"documents" must be an array of package names, optionally with a version');
|
|
169
|
+
}
|
|
170
|
+
const specs = value as string[];
|
|
171
|
+
return specs.length === 0 ? undefined : specs.map(parseDocumentedLibrary);
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
function stringArray(
|
|
@@ -133,6 +183,18 @@ function stringArray(
|
|
|
133
183
|
return value as string[];
|
|
134
184
|
}
|
|
135
185
|
|
|
186
|
+
/** Writes the manifest, with `documents` back in the string form the schema declares. */
|
|
136
187
|
export function serializeManifest(manifest: PackageManifest): string {
|
|
137
|
-
|
|
188
|
+
const documents = manifest.documents ?? [];
|
|
189
|
+
return `${JSON.stringify(
|
|
190
|
+
{
|
|
191
|
+
$schema: SCHEMA_URL,
|
|
192
|
+
name: manifest.name,
|
|
193
|
+
version: manifest.version,
|
|
194
|
+
...(documents.length === 0 ? {} : { documents: documents.map(formatDocumentedLibrary) }),
|
|
195
|
+
chunks: manifest.chunks,
|
|
196
|
+
},
|
|
197
|
+
null,
|
|
198
|
+
2,
|
|
199
|
+
)}\n`;
|
|
138
200
|
}
|
package/src/stopwords.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Words that carry no discrimination in a documentation corpus.
|
|
3
|
+
*
|
|
4
|
+
* Heading words become tags, and tags are weighted 3× against prose in the ranking. A section
|
|
5
|
+
* titled "How to use it" therefore carried `how`, `to` and `it` as high-weight terms, and every
|
|
6
|
+
* question beginning "how do I…" matched it — ahead of the chunk that answers the question.
|
|
7
|
+
*
|
|
8
|
+
* The list is closed-class only: articles, pronouns, prepositions, conjunctions, auxiliaries,
|
|
9
|
+
* modals, demonstratives and question words. No content word is in it, however generic it
|
|
10
|
+
* looks. `use`, `add`, `get` and `set` all name real APIs, and a list that grows past the
|
|
11
|
+
* function words starts deciding which of an author's own words count.
|
|
12
|
+
*/
|
|
13
|
+
export const STOPWORDS: ReadonlySet<string> = new Set([
|
|
14
|
+
"about",
|
|
15
|
+
"after",
|
|
16
|
+
"all",
|
|
17
|
+
"also",
|
|
18
|
+
"an",
|
|
19
|
+
"and",
|
|
20
|
+
"any",
|
|
21
|
+
"are",
|
|
22
|
+
"as",
|
|
23
|
+
"at",
|
|
24
|
+
"be",
|
|
25
|
+
"been",
|
|
26
|
+
"before",
|
|
27
|
+
"being",
|
|
28
|
+
"between",
|
|
29
|
+
"both",
|
|
30
|
+
"but",
|
|
31
|
+
"by",
|
|
32
|
+
"can",
|
|
33
|
+
"could",
|
|
34
|
+
"did",
|
|
35
|
+
"do",
|
|
36
|
+
"does",
|
|
37
|
+
"doing",
|
|
38
|
+
"each",
|
|
39
|
+
"for",
|
|
40
|
+
"from",
|
|
41
|
+
"had",
|
|
42
|
+
"has",
|
|
43
|
+
"have",
|
|
44
|
+
"he",
|
|
45
|
+
"her",
|
|
46
|
+
"here",
|
|
47
|
+
"hers",
|
|
48
|
+
"him",
|
|
49
|
+
"his",
|
|
50
|
+
"how",
|
|
51
|
+
"if",
|
|
52
|
+
"in",
|
|
53
|
+
"into",
|
|
54
|
+
"is",
|
|
55
|
+
"it",
|
|
56
|
+
"its",
|
|
57
|
+
"may",
|
|
58
|
+
"me",
|
|
59
|
+
"might",
|
|
60
|
+
"must",
|
|
61
|
+
"my",
|
|
62
|
+
"no",
|
|
63
|
+
"nor",
|
|
64
|
+
"not",
|
|
65
|
+
"of",
|
|
66
|
+
"off",
|
|
67
|
+
"on",
|
|
68
|
+
"once",
|
|
69
|
+
"only",
|
|
70
|
+
"or",
|
|
71
|
+
"other",
|
|
72
|
+
"our",
|
|
73
|
+
"ours",
|
|
74
|
+
"out",
|
|
75
|
+
"over",
|
|
76
|
+
"own",
|
|
77
|
+
"same",
|
|
78
|
+
"shall",
|
|
79
|
+
"she",
|
|
80
|
+
"should",
|
|
81
|
+
"so",
|
|
82
|
+
"some",
|
|
83
|
+
"such",
|
|
84
|
+
"than",
|
|
85
|
+
"that",
|
|
86
|
+
"the",
|
|
87
|
+
"their",
|
|
88
|
+
"theirs",
|
|
89
|
+
"them",
|
|
90
|
+
"then",
|
|
91
|
+
"there",
|
|
92
|
+
"these",
|
|
93
|
+
"they",
|
|
94
|
+
"this",
|
|
95
|
+
"those",
|
|
96
|
+
"through",
|
|
97
|
+
"to",
|
|
98
|
+
"too",
|
|
99
|
+
"under",
|
|
100
|
+
"until",
|
|
101
|
+
"up",
|
|
102
|
+
"very",
|
|
103
|
+
"was",
|
|
104
|
+
"we",
|
|
105
|
+
"were",
|
|
106
|
+
"what",
|
|
107
|
+
"when",
|
|
108
|
+
"where",
|
|
109
|
+
"which",
|
|
110
|
+
"while",
|
|
111
|
+
"who",
|
|
112
|
+
"whom",
|
|
113
|
+
"why",
|
|
114
|
+
"will",
|
|
115
|
+
"with",
|
|
116
|
+
"would",
|
|
117
|
+
"you",
|
|
118
|
+
"your",
|
|
119
|
+
"yours",
|
|
120
|
+
]);
|
package/src/style.ts
CHANGED
|
@@ -34,6 +34,11 @@ const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
|
|
|
34
34
|
/** A heading or list item starts a new unit, whatever the previous line ended with. */
|
|
35
35
|
const BLOCK_START = /\n(?=\s*(?:[-*+]\s|\d+\.\s|#{1,6}\s|\|))/;
|
|
36
36
|
const LONG_SENTENCE_WORDS = 40;
|
|
37
|
+
/**
|
|
38
|
+
* A word carries a letter or a digit. Stripped code leaves its punctuation behind, so counting
|
|
39
|
+
* everything between spaces reports an enumeration of forty code spans as a forty-word sentence.
|
|
40
|
+
*/
|
|
41
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
37
42
|
|
|
38
43
|
export interface FillerHit {
|
|
39
44
|
readonly phrase: string;
|
|
@@ -68,7 +73,7 @@ export function findLongSentences(text: string, maxWords = LONG_SENTENCE_WORDS):
|
|
|
68
73
|
.flatMap((block) => block.split(BLOCK_START))
|
|
69
74
|
.flatMap((block) => block.split(SENTENCE_SPLIT))
|
|
70
75
|
.map((sentence) => sentence.replace(/\s+/g, " ").trim())
|
|
71
|
-
.filter((sentence) => sentence.split(" ").filter(
|
|
76
|
+
.filter((sentence) => sentence.split(" ").filter((word) => WORD.test(word)).length > maxWords);
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
|
@@ -94,11 +99,16 @@ export function contentFingerprint(text: string): string {
|
|
|
94
99
|
.trim();
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/** An authored directive, e.g. `<!-- docspack: tags=grid -->`. Metadata, not site boilerplate. */
|
|
103
|
+
const DIRECTIVE_LINE = /^\s*<!--\s*docspack:/i;
|
|
104
|
+
|
|
97
105
|
/** Removes lines a documentation site wraps around its content. */
|
|
98
106
|
export function stripBoilerplate(text: string): string {
|
|
99
107
|
return text
|
|
100
108
|
.split("\n")
|
|
101
|
-
.filter(
|
|
109
|
+
.filter(
|
|
110
|
+
(line) => DIRECTIVE_LINE.test(line) || !BOILERPLATE.some((pattern) => pattern.test(line)),
|
|
111
|
+
)
|
|
102
112
|
.join("\n");
|
|
103
113
|
}
|
|
104
114
|
|
package/src/sync.ts
CHANGED
|
@@ -102,7 +102,7 @@ async function readChunks(
|
|
|
102
102
|
chunks.push({
|
|
103
103
|
chunkId: chunkId(pkg.id, chunk.id),
|
|
104
104
|
filePath: chunk.file,
|
|
105
|
-
tokens: chunk.tokens
|
|
105
|
+
tokens: chunk.tokens ?? estimateTokens(text),
|
|
106
106
|
content: text,
|
|
107
107
|
tags: [...chunk.tags, ...chunk.entities],
|
|
108
108
|
});
|
package/src/verify.ts
CHANGED
|
@@ -3,8 +3,17 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readBuildConfig } from "./config.js";
|
|
4
4
|
import { discoverPackages, resolvePackageDir } from "./discovery.js";
|
|
5
5
|
import { DocspackError } from "./errors.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readExportSurface } from "./exports.js";
|
|
7
|
+
import {
|
|
8
|
+
chunkId,
|
|
9
|
+
type DocumentedLibrary,
|
|
10
|
+
formatDocumentedLibrary,
|
|
11
|
+
LLMS_DIR,
|
|
12
|
+
MANIFEST_FILE,
|
|
13
|
+
type PackageManifest,
|
|
14
|
+
parseDocumentedLibrary,
|
|
15
|
+
parseManifest,
|
|
16
|
+
} from "./spec.js";
|
|
8
17
|
|
|
9
18
|
export interface DriftFinding {
|
|
10
19
|
readonly chunkId: string;
|
|
@@ -22,8 +31,14 @@ export type VerifyStatus = "verified" | "skipped";
|
|
|
22
31
|
export interface VerifiedPackage {
|
|
23
32
|
readonly id: string;
|
|
24
33
|
readonly status: VerifyStatus;
|
|
25
|
-
/**
|
|
26
|
-
readonly documents?: string;
|
|
34
|
+
/** Libraries the package declares it documents, as written. */
|
|
35
|
+
readonly documents?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* Documented libraries whose declarations could not be read, when others could. The check ran
|
|
38
|
+
* against a smaller surface than the package claims, which is worth saying: an entity those
|
|
39
|
+
* libraries export is one this run could have accused.
|
|
40
|
+
*/
|
|
41
|
+
readonly unchecked?: readonly string[];
|
|
27
42
|
/** Why nothing was checked, when status is `skipped`. */
|
|
28
43
|
readonly reason?: string;
|
|
29
44
|
readonly checked: number;
|
|
@@ -181,32 +196,42 @@ async function verifyPackage(
|
|
|
181
196
|
): Promise<VerifiedPackage> {
|
|
182
197
|
const empty = { id, checked: 0, matched: 0, unrecognised: 0, findings: [] };
|
|
183
198
|
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
199
|
+
const declaredLibraries = await documentedLibraries(packageDir, manifest);
|
|
200
|
+
if (declaredLibraries.length === 0) {
|
|
186
201
|
return {
|
|
187
202
|
...empty,
|
|
188
203
|
status: "skipped",
|
|
189
|
-
reason: `declares no "
|
|
204
|
+
reason: `declares no "documents", so there is nothing to check against`,
|
|
190
205
|
};
|
|
191
206
|
}
|
|
192
207
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
208
|
+
const documents = declaredLibraries.map(formatDocumentedLibrary);
|
|
209
|
+
// An entity is fine if any documented library declares it: a monorepo documents eighteen
|
|
210
|
+
// packages from one surface, and a name belongs to whichever of them exports it.
|
|
211
|
+
const names = new Set<string>();
|
|
212
|
+
const missing: string[] = [];
|
|
213
|
+
for (const library of declaredLibraries) {
|
|
214
|
+
const libraryDir =
|
|
215
|
+
(await resolvePackageDir(library.name, packageDir)) ??
|
|
216
|
+
(await resolvePackageDir(library.name, cwd));
|
|
217
|
+
const surface = libraryDir === undefined ? undefined : await readExportSurface(libraryDir);
|
|
218
|
+
if (surface === undefined || surface.names.size === 0) {
|
|
219
|
+
missing.push(library.name);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for (const name of surface.names) names.add(name);
|
|
197
223
|
}
|
|
198
224
|
|
|
199
|
-
|
|
200
|
-
if (surface === undefined || surface.names.size === 0) {
|
|
225
|
+
if (names.size === 0) {
|
|
201
226
|
return {
|
|
202
227
|
...empty,
|
|
203
228
|
status: "skipped",
|
|
204
229
|
documents,
|
|
205
|
-
reason: `${
|
|
230
|
+
reason: `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not installed, or ship no type declarations`,
|
|
206
231
|
};
|
|
207
232
|
}
|
|
208
233
|
|
|
209
|
-
const declared = new Set([...
|
|
234
|
+
const declared = new Set([...names].map(normalize));
|
|
210
235
|
const findings: DriftFinding[] = [];
|
|
211
236
|
let checked = 0;
|
|
212
237
|
let matched = 0;
|
|
@@ -225,7 +250,7 @@ async function verifyPackage(
|
|
|
225
250
|
continue;
|
|
226
251
|
}
|
|
227
252
|
|
|
228
|
-
const suggestion = nearestName(symbol,
|
|
253
|
+
const suggestion = nearestName(symbol, names);
|
|
229
254
|
if (suggestion === undefined) {
|
|
230
255
|
unrecognised += 1;
|
|
231
256
|
continue;
|
|
@@ -241,7 +266,32 @@ async function verifyPackage(
|
|
|
241
266
|
}
|
|
242
267
|
}
|
|
243
268
|
|
|
244
|
-
return {
|
|
269
|
+
return {
|
|
270
|
+
id,
|
|
271
|
+
status: "verified",
|
|
272
|
+
documents,
|
|
273
|
+
...(missing.length === 0 ? {} : { unchecked: missing }),
|
|
274
|
+
checked,
|
|
275
|
+
matched,
|
|
276
|
+
unrecognised,
|
|
277
|
+
findings,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* What a package says it documents.
|
|
283
|
+
*
|
|
284
|
+
* The manifest is the answer when it carries one, because that is the only copy a consumer of an
|
|
285
|
+
* installed package can see. The `docspack` key is read as a fallback: it is build configuration
|
|
286
|
+
* rather than payload, and a package built before `documents` reached the manifest has only that.
|
|
287
|
+
*/
|
|
288
|
+
async function documentedLibraries(
|
|
289
|
+
packageDir: string,
|
|
290
|
+
manifest: PackageManifest,
|
|
291
|
+
): Promise<readonly DocumentedLibrary[]> {
|
|
292
|
+
if (manifest.documents !== undefined && manifest.documents.length > 0) return manifest.documents;
|
|
293
|
+
const { documents } = await readBuildConfig(packageDir);
|
|
294
|
+
return (documents ?? []).map(parseDocumentedLibrary);
|
|
245
295
|
}
|
|
246
296
|
|
|
247
297
|
/**
|
|
@@ -275,13 +325,13 @@ function normalize(name: string): string {
|
|
|
275
325
|
* is left alone. In particular a differing first word is never a match: `includeLanguages` and
|
|
276
326
|
* `excludeLanguages` are two edits apart and mean opposite things.
|
|
277
327
|
*/
|
|
278
|
-
function nearestName(symbol: string,
|
|
328
|
+
function nearestName(symbol: string, names: ReadonlySet<string>): string | undefined {
|
|
279
329
|
const lower = normalize(symbol);
|
|
280
330
|
const words = camelWords(symbol);
|
|
281
331
|
let best: string | undefined;
|
|
282
332
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
283
333
|
|
|
284
|
-
for (const candidate of
|
|
334
|
+
for (const candidate of names) {
|
|
285
335
|
if (candidate.length < MIN_SYMBOL) continue;
|
|
286
336
|
const candidateWords = camelWords(candidate);
|
|
287
337
|
if (isRequalified(words, candidateWords)) return candidate;
|