docgrity 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +109 -126
  2. package/{action/action.yml → action.yml} +4 -1
  3. package/{action/bin → bin}/action.js +12 -6
  4. package/{action/bin → bin}/docgrity.js +23 -9
  5. package/package.json +31 -164
  6. package/src/heuristics.js +146 -0
  7. package/{action/src → src}/issues.js +4 -1
  8. package/{action/src → src}/report.js +2 -1
  9. package/{action/src → src}/scan.js +32 -7
  10. package/.github/workflows/ci.yml +0 -54
  11. package/.vscodeignore +0 -13
  12. package/action/LICENSE +0 -21
  13. package/action/README.md +0 -104
  14. package/action/examples/docgrity.yml +0 -61
  15. package/action/package.json +0 -38
  16. package/action/test/corpus.test.mjs +0 -59
  17. package/action/test/issues.test.mjs +0 -89
  18. package/action/test/report.test.mjs +0 -76
  19. package/docgrity_logo.png +0 -0
  20. package/image.png +0 -0
  21. package/media/icon.png +0 -0
  22. package/media/icon.svg +0 -5
  23. package/samples/api-limits.md +0 -23
  24. package/samples/architecture-notes.md +0 -28
  25. package/samples/deployment-guide.md +0 -23
  26. package/samples/integration-guide.md +0 -21
  27. package/samples/release-process.md +0 -23
  28. package/src/agents/assess.ts +0 -187
  29. package/src/agents/prompts.ts +0 -94
  30. package/src/agents/selectModel.ts +0 -50
  31. package/src/core/json.ts +0 -58
  32. package/src/core/prefilter.ts +0 -56
  33. package/src/core/slug.ts +0 -10
  34. package/src/core/verify.ts +0 -15
  35. package/src/extension.ts +0 -142
  36. package/src/findings/diagnostics.ts +0 -78
  37. package/src/findings/report.ts +0 -68
  38. package/src/findings/store.ts +0 -60
  39. package/src/findings/tree.ts +0 -93
  40. package/src/github/issues.ts +0 -90
  41. package/src/github/owners.ts +0 -60
  42. package/src/log.ts +0 -22
  43. package/src/scanner/candidates.ts +0 -62
  44. package/src/scanner/corpus.ts +0 -75
  45. package/src/scanner/scan.ts +0 -215
  46. package/test/candidates.test.ts +0 -63
  47. package/test/json.test.ts +0 -87
  48. package/test/prefilter.test.ts +0 -65
  49. package/test/slug.test.ts +0 -31
  50. package/test/verify.test.ts +0 -47
  51. package/tsconfig.json +0 -15
  52. package/vitest.config.mts +0 -9
  53. /package/{action/src → src}/corpus.js +0 -0
  54. /package/{action/src → src}/llm.js +0 -0
  55. /package/{action/src → src}/prompts.js +0 -0
@@ -1,60 +0,0 @@
1
- /**
2
- * Potential-owner inference — last git author of the file, always labelled
3
- * *potential* (mirrors the Forge app's inferred-ownership rule).
4
- */
5
- import * as vscode from 'vscode';
6
- import { execFile } from 'node:child_process';
7
- import * as path from 'node:path';
8
- import { parseGithubSlug } from '../core/slug';
9
-
10
- function git(args: string[], cwd: string): Promise<string> {
11
- return new Promise((resolve) => {
12
- execFile('git', args, { cwd, timeout: 10000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
13
- resolve(err ? '' : stdout.trim());
14
- });
15
- });
16
- }
17
-
18
- /**
19
- * Filter out paths that are git-ignored in the given repo root. Uses a single
20
- * `git check-ignore --stdin` batch call; on any git error (not a repo, git
21
- * missing) all paths are kept.
22
- */
23
- export async function filterGitIgnored(root: string, relPaths: string[]): Promise<Set<string>> {
24
- if (relPaths.length === 0) return new Set();
25
- return new Promise((resolve) => {
26
- const child = execFile(
27
- 'git',
28
- ['check-ignore', '--stdin', '-z'],
29
- { cwd: root, timeout: 10000, maxBuffer: 10 * 1024 * 1024 },
30
- (err, stdout) => {
31
- // Exit code 1 = nothing ignored (not an error); other errors → empty set.
32
- const code = (err as { code?: number | string } | null)?.code;
33
- if (err && code !== 1) {
34
- resolve(new Set());
35
- return;
36
- }
37
- resolve(new Set(stdout.split('\0').filter(Boolean)));
38
- }
39
- );
40
- child.stdin?.end(relPaths.join('\0'));
41
- });
42
- }
43
-
44
- export async function potentialOwner(uri: vscode.Uri): Promise<string | undefined> {
45
- const folder = vscode.workspace.getWorkspaceFolder(uri);
46
- if (!folder || uri.scheme !== 'file') return undefined;
47
- const out = await git(
48
- ['log', '-1', '--format=%an', '--', path.relative(folder.uri.fsPath, uri.fsPath)],
49
- folder.uri.fsPath
50
- );
51
- return out || undefined;
52
- }
53
-
54
- /** Derive owner/repo from the origin remote, if it's a GitHub remote. */
55
- export async function githubRepoSlug(): Promise<string | undefined> {
56
- const folder = vscode.workspace.workspaceFolders?.[0];
57
- if (!folder) return undefined;
58
- const url = await git(['remote', 'get-url', 'origin'], folder.uri.fsPath);
59
- return parseGithubSlug(url);
60
- }
package/src/log.ts DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Central logger — a VS Code LogOutputChannel ("Docgrity" in the Output panel).
3
- * Log level is controlled by the user via the standard Developer: Set Log Level
4
- * command. Never log document contents, tokens, or credentials.
5
- */
6
- import * as vscode from 'vscode';
7
-
8
- let channel: vscode.LogOutputChannel | undefined;
9
-
10
- export function initLog(context: vscode.ExtensionContext): vscode.LogOutputChannel {
11
- channel = vscode.window.createOutputChannel('Docgrity', { log: true });
12
- context.subscriptions.push(channel);
13
- return channel;
14
- }
15
-
16
- export const log = {
17
- trace: (msg: string, ...args: unknown[]) => channel?.trace(msg, ...args),
18
- debug: (msg: string, ...args: unknown[]) => channel?.debug(msg, ...args),
19
- info: (msg: string, ...args: unknown[]) => channel?.info(msg, ...args),
20
- warn: (msg: string, ...args: unknown[]) => channel?.warn(msg, ...args),
21
- error: (msg: string | Error, ...args: unknown[]) => channel?.error(msg, ...args),
22
- };
@@ -1,62 +0,0 @@
1
- /**
2
- * Candidate pair selection — local TF-IDF cosine similarity. Deterministic and
3
- * free: the LLM is only used for pairwise assessment of the top-scoring pairs.
4
- * (vscode.lm has no embeddings API; TF-IDF is sufficient at repo-docs scale.)
5
- */
6
- import type { Doc } from './corpus';
7
-
8
- export interface CandidatePair {
9
- a: Doc;
10
- b: Doc;
11
- similarity: number;
12
- }
13
-
14
- function tokenize(text: string): string[] {
15
- return text
16
- .toLowerCase()
17
- .replace(/```[\s\S]*?```/g, ' ') // ignore fenced code blocks
18
- .split(/[^a-z0-9]+/)
19
- .filter((t) => t.length > 2);
20
- }
21
-
22
- export function selectCandidatePairs(docs: Doc[], maxPairs: number): CandidatePair[] {
23
- const termFreqs = docs.map((d) => {
24
- const tf = new Map<string, number>();
25
- for (const t of tokenize(d.text)) tf.set(t, (tf.get(t) ?? 0) + 1);
26
- return tf;
27
- });
28
-
29
- const docFreq = new Map<string, number>();
30
- for (const tf of termFreqs) {
31
- for (const term of tf.keys()) docFreq.set(term, (docFreq.get(term) ?? 0) + 1);
32
- }
33
- const n = docs.length;
34
- const idf = (term: string) => Math.log(1 + n / (docFreq.get(term) ?? 1));
35
-
36
- const vectors = termFreqs.map((tf) => {
37
- const v = new Map<string, number>();
38
- let norm = 0;
39
- for (const [term, f] of tf) {
40
- const w = f * idf(term);
41
- v.set(term, w);
42
- norm += w * w;
43
- }
44
- return { v, norm: Math.sqrt(norm) || 1 };
45
- });
46
-
47
- const pairs: CandidatePair[] = [];
48
- for (let i = 0; i < n; i++) {
49
- for (let j = i + 1; j < n; j++) {
50
- const [small, large] =
51
- vectors[i].v.size <= vectors[j].v.size ? [vectors[i], vectors[j]] : [vectors[j], vectors[i]];
52
- let dot = 0;
53
- for (const [term, w] of small.v) {
54
- const w2 = large.v.get(term);
55
- if (w2) dot += w * w2;
56
- }
57
- const sim = dot / (vectors[i].norm * vectors[j].norm);
58
- if (sim > 0.15) pairs.push({ a: docs[i], b: docs[j], similarity: sim });
59
- }
60
- }
61
- return pairs.sort((x, y) => y.similarity - x.similarity).slice(0, maxPairs);
62
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * Corpus collection — markdown files only, by design. No code files, no
3
- * config: the extension's scope is repository documentation.
4
- */
5
- import * as vscode from 'vscode';
6
- import * as crypto from 'node:crypto';
7
- import * as path from 'node:path';
8
- import { filterGitIgnored } from '../github/owners';
9
- import { log } from '../log';
10
-
11
- export interface Doc {
12
- uri: vscode.Uri;
13
- relPath: string;
14
- text: string;
15
- hash: string;
16
- }
17
-
18
- /** Always excluded, regardless of what docgrity.exclude is set to. */
19
- const HARD_EXCLUDE = '**/{node_modules,bower_components,dist,out,build,.git,vendor,coverage,.venv,venv}/**';
20
-
21
- export async function collectCorpus(): Promise<Doc[]> {
22
- const cfg = vscode.workspace.getConfiguration('docgrity');
23
- const include = cfg.get<string>('include', '**/*.md');
24
- const userExclude = cfg.get<string>('exclude', '');
25
- const maxFiles = cfg.get<number>('maxFiles', 200);
26
-
27
- const exclude = userExclude ? `{${HARD_EXCLUDE},${userExclude}}` : HARD_EXCLUDE;
28
- let uris = await vscode.workspace.findFiles(include, exclude, maxFiles);
29
-
30
- // Respect .gitignore: findFiles only honours settings-based excludes, so
31
- // batch-check the candidates against git and drop anything ignored.
32
- uris = await dropGitIgnored(uris);
33
-
34
- const docs: Doc[] = [];
35
- for (const uri of uris) {
36
- const bytes = await vscode.workspace.fs.readFile(uri);
37
- const text = Buffer.from(bytes).toString('utf8');
38
- if (text.trim().length < 80) continue; // skip trivial files
39
- docs.push({
40
- uri,
41
- relPath: vscode.workspace.asRelativePath(uri),
42
- text,
43
- hash: crypto.createHash('sha256').update(text).digest('hex'),
44
- });
45
- }
46
- return docs;
47
- }
48
-
49
- async function dropGitIgnored(uris: vscode.Uri[]): Promise<vscode.Uri[]> {
50
- // Group by workspace folder so each repo's own .gitignore applies.
51
- const byFolder = new Map<string, { uri: vscode.Uri; rel: string }[]>();
52
- const passthrough: vscode.Uri[] = [];
53
- for (const uri of uris) {
54
- const folder = vscode.workspace.getWorkspaceFolder(uri);
55
- if (!folder || uri.scheme !== 'file') {
56
- passthrough.push(uri);
57
- continue;
58
- }
59
- const root = folder.uri.fsPath;
60
- const list = byFolder.get(root) ?? [];
61
- list.push({ uri, rel: path.relative(root, uri.fsPath) });
62
- byFolder.set(root, list);
63
- }
64
-
65
- const kept: vscode.Uri[] = [...passthrough];
66
- for (const [root, entries] of byFolder) {
67
- const ignored = await filterGitIgnored(root, entries.map((e) => e.rel));
68
- const dropped = entries.filter((e) => ignored.has(e.rel));
69
- if (dropped.length > 0) {
70
- log.info(`Skipping ${dropped.length} git-ignored file(s) in ${root}`);
71
- }
72
- kept.push(...entries.filter((e) => !ignored.has(e.rel)).map((e) => e.uri));
73
- }
74
- return kept;
75
- }
@@ -1,215 +0,0 @@
1
- /**
2
- * Scan orchestrator — deterministic code drives the loop; the LLM only does
3
- * pairwise/per-doc semantic assessment. Confidence gates come from settings.
4
- */
5
- import * as vscode from 'vscode';
6
- import * as crypto from 'node:crypto';
7
- import { collectCorpus, Doc } from './corpus';
8
- import { selectCandidatePairs } from './candidates';
9
- import { assessContradiction, assessDuplicate, assessOpenQuestions, assessPair, OpenQuestion } from '../agents/assess';
10
- import { Finding, FindingStore } from '../findings/store';
11
- import { potentialOwner } from '../github/owners';
12
- import { verifyExcerpts } from '../core/verify';
13
- import { hasOpenQuestionSignals, mapLimit } from '../core/prefilter';
14
- import { log } from '../log';
15
-
16
- /** Bounded parallelism for LLM assessments — keeps the UI responsive and stays
17
- * well under provider rate limits while roughly 4x-ing scan throughput. */
18
- const CONCURRENCY = 4;
19
-
20
- function mkId(): string {
21
- return crypto.randomUUID();
22
- }
23
-
24
- function verify(excerpts: { excerpt: string }[], docs: Doc[]): boolean {
25
- const ok = verifyExcerpts(excerpts, docs.map((d) => d.text));
26
- if (!ok) log.warn(`Evidence failed verbatim verification; finding dropped (${docs.map((d) => d.relPath).join(', ')})`);
27
- return ok;
28
- }
29
-
30
- export async function runScan(
31
- store: FindingStore,
32
- progress: vscode.Progress<{ message?: string }>,
33
- token: vscode.CancellationToken
34
- ): Promise<{ findings: number; docs: number; pairs: number; errors: number }> {
35
- const cfg = vscode.workspace.getConfiguration('docgrity');
36
- const maxPairs = cfg.get<number>('maxPairs', 25);
37
- const tDup = cfg.get<number>('thresholds.duplicate', 0.75);
38
- const tCon = cfg.get<number>('thresholds.contradiction', 0.7);
39
- const tOq = cfg.get<number>('thresholds.openQuestion', 0.6);
40
- const checkDup = cfg.get<boolean>('checks.duplicates', true);
41
- const checkCon = cfg.get<boolean>('checks.contradictions', true);
42
- const checkOq = cfg.get<boolean>('checks.openQuestions', true);
43
-
44
- if (!checkDup && !checkCon && !checkOq) {
45
- throw new Error('All checks are disabled — enable at least one docgrity.checks.* setting.');
46
- }
47
-
48
- progress.report({ message: 'Collecting markdown docs…' });
49
- const docs = await collectCorpus();
50
- // Pair selection is only needed for the pairwise checks.
51
- const pairs = checkDup || checkCon ? selectCandidatePairs(docs, maxPairs) : [];
52
- log.info(
53
- `Scan started: ${docs.length} docs, ${pairs.length} candidate pairs (maxPairs=${maxPairs}, ` +
54
- `checks: dup=${checkDup} con=${checkCon} oq=${checkOq})`
55
- );
56
- const findings: Finding[] = [];
57
- const errors: string[] = [];
58
- const now = () => new Date().toISOString();
59
-
60
- let i = 0;
61
- const pairResults = await mapLimit(pairs, CONCURRENCY, async ({ a, b }) => {
62
- if (token.isCancellationRequested) return null;
63
- i++;
64
- progress.report({ message: `Assessing pair ${i}/${pairs.length}: ${a.relPath} ↔ ${b.relPath}` });
65
-
66
- const out: Finding[] = [];
67
-
68
- if (checkDup && checkCon) {
69
- // Combined single-call assessment: the model reads the pair once.
70
- const pair = await assessPair(a, b, token);
71
- const dup = { output: pair.output.duplicate, model: pair.model, promptVersion: pair.promptVersion };
72
- const con = { output: pair.output.contradiction, model: pair.model, promptVersion: pair.promptVersion };
73
- const df = await duplicateFinding(dup, a, b, tDup);
74
- if (df) out.push(df);
75
- const cf = await contradictionFinding(con, a, b, tCon);
76
- if (cf) out.push(cf);
77
- return out;
78
- }
79
- if (checkDup) {
80
- const df = await duplicateFinding(await assessDuplicate(a, b, token), a, b, tDup);
81
- if (df) out.push(df);
82
- }
83
- if (checkCon) {
84
- const cf = await contradictionFinding(await assessContradiction(a, b, token), a, b, tCon);
85
- if (cf) out.push(cf);
86
- }
87
- return out;
88
- });
89
- for (let k = 0; k < pairResults.length; k++) {
90
- const r = pairResults[k];
91
- if (r.ok) {
92
- if (r.value) findings.push(...r.value);
93
- } else {
94
- const { a, b } = pairs[k];
95
- errors.push(`pair ${a.relPath}↔${b.relPath}: ${r.error.message}`);
96
- log.error(`Pair assessment failed for ${a.relPath} ↔ ${b.relPath}`, r.error);
97
- }
98
- }
99
-
100
- if (checkOq) {
101
- // Cheap heuristic gate: only docs showing open-question signals
102
- // (TODO/TBD/???, unanswered question lines) get an LLM call.
103
- const oqDocs = docs.filter((d) => hasOpenQuestionSignals(d.text));
104
- log.info(`Open-question pre-filter: ${oqDocs.length}/${docs.length} docs have signals`);
105
- let j = 0;
106
- const oqResults = await mapLimit(oqDocs, CONCURRENCY, async (doc) => {
107
- if (token.isCancellationRequested) return null;
108
- j++;
109
- progress.report({ message: `Open questions ${j}/${oqDocs.length}: ${doc.relPath}` });
110
- const oq = await assessOpenQuestions(doc, token);
111
- const kept: OpenQuestion[] = oq.output.questions.filter(
112
- (q: OpenQuestion) => q.confidence >= tOq && verify([q], [doc])
113
- );
114
- if (kept.length === 0) return null;
115
- const finding: Finding = {
116
- id: mkId(),
117
- type: 'open_question',
118
- severity: kept.some((q) => q.severity === 'HIGH') ? 'HIGH' : 'MEDIUM',
119
- confidence: Math.max(...kept.map((q) => q.confidence)),
120
- summary: `${kept.length} unresolved question(s) in ${doc.relPath}`,
121
- detail: { questions: kept },
122
- evidence: kept.map((q) => ({ sourceLabel: doc.relPath, excerpt: q.excerpt })),
123
- files: [doc.relPath],
124
- potentialOwners: await ownersFor([doc]),
125
- model: oq.model,
126
- promptVersion: oq.promptVersion,
127
- createdAt: now(),
128
- };
129
- return finding;
130
- });
131
- for (let k = 0; k < oqResults.length; k++) {
132
- const r = oqResults[k];
133
- if (r.ok) {
134
- if (r.value) findings.push(r.value);
135
- } else {
136
- errors.push(`open_question ${oqDocs[k].relPath}: ${r.error.message}`);
137
- log.error(`Open-question assessment failed for ${oqDocs[k].relPath}`, r.error);
138
- }
139
- }
140
- }
141
-
142
- await store.replaceAll(findings);
143
- log.info(
144
- `Scan finished: ${findings.length} finding(s), ${errors.length} assessment error(s)` +
145
- (token.isCancellationRequested ? ' (cancelled early)' : '')
146
- );
147
- return { findings: findings.length, docs: docs.length, pairs: pairs.length, errors: errors.length };
148
- }
149
-
150
- type Assessed<T> = { output: T; model: string; promptVersion: string };
151
-
152
- async function duplicateFinding(
153
- dup: Assessed<{ is_duplicate: boolean; confidence: number; summary: string; recommended_action: string; evidence: { page: string; excerpt: string }[] }>,
154
- a: Doc,
155
- b: Doc,
156
- threshold: number
157
- ): Promise<Finding | null> {
158
- if (!dup.output.is_duplicate || dup.output.confidence < threshold || !verify(dup.output.evidence, [a, b])) {
159
- return null;
160
- }
161
- return {
162
- id: mkId(),
163
- type: 'duplicate',
164
- severity: 'MEDIUM',
165
- confidence: dup.output.confidence,
166
- summary: dup.output.summary,
167
- detail: { recommended_action: dup.output.recommended_action },
168
- evidence: dup.output.evidence.map((e) => ({
169
- sourceLabel: e.page === 'A' ? a.relPath : b.relPath,
170
- excerpt: e.excerpt,
171
- })),
172
- files: [a.relPath, b.relPath],
173
- potentialOwners: await ownersFor([a, b]),
174
- model: dup.model,
175
- promptVersion: dup.promptVersion,
176
- createdAt: new Date().toISOString(),
177
- };
178
- }
179
-
180
- async function contradictionFinding(
181
- con: Assessed<{ is_contradiction: boolean; confidence: number; severity: string; summary: string; conflicting_claims: string[]; evidence: { page: string; excerpt: string }[] }>,
182
- a: Doc,
183
- b: Doc,
184
- threshold: number
185
- ): Promise<Finding | null> {
186
- if (!con.output.is_contradiction || con.output.confidence < threshold || !verify(con.output.evidence, [a, b])) {
187
- return null;
188
- }
189
- return {
190
- id: mkId(),
191
- type: 'contradiction',
192
- severity: con.output.severity,
193
- confidence: con.output.confidence,
194
- summary: con.output.summary,
195
- detail: { conflicting_claims: con.output.conflicting_claims },
196
- evidence: con.output.evidence.map((e) => ({
197
- sourceLabel: e.page === 'A' ? a.relPath : b.relPath,
198
- excerpt: e.excerpt,
199
- })),
200
- files: [a.relPath, b.relPath],
201
- potentialOwners: await ownersFor([a, b]),
202
- model: con.model,
203
- promptVersion: con.promptVersion,
204
- createdAt: new Date().toISOString(),
205
- };
206
- }
207
-
208
- async function ownersFor(docs: Doc[]): Promise<string[]> {
209
- const owners = new Set<string>();
210
- for (const d of docs) {
211
- const o = await potentialOwner(d.uri);
212
- if (o) owners.add(o);
213
- }
214
- return [...owners];
215
- }
@@ -1,63 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { selectCandidatePairs } from '../src/scanner/candidates';
3
- import type { Doc } from '../src/scanner/corpus';
4
-
5
- function doc(relPath: string, text: string): Doc {
6
- return { relPath, text, uri: undefined as never, hash: relPath };
7
- }
8
-
9
- const releaseA = doc(
10
- 'docs/release.md',
11
- 'Our release process: deploy pipeline runs tests, then ships to staging, then production. Releases are weekly on Thursdays after QA sign-off from the release manager.'
12
- );
13
- const releaseB = doc(
14
- 'docs/deploy.md',
15
- 'Deployment guide: the deploy pipeline runs tests and ships to staging then production. Releases happen weekly, coordinated by the release manager with QA sign-off.'
16
- );
17
- const unrelated = doc(
18
- 'docs/animals.md',
19
- 'Penguins are flightless birds living in the southern hemisphere. Their diet consists of krill, squid and fish caught while swimming.'
20
- );
21
-
22
- describe('selectCandidatePairs', () => {
23
- it('ranks similar docs above unrelated ones', () => {
24
- const pairs = selectCandidatePairs([releaseA, releaseB, unrelated], 10);
25
- expect(pairs.length).toBeGreaterThan(0);
26
- expect([pairs[0].a.relPath, pairs[0].b.relPath].sort()).toEqual([
27
- 'docs/deploy.md',
28
- 'docs/release.md',
29
- ]);
30
- });
31
-
32
- it('filters out low-similarity pairs', () => {
33
- const pairs = selectCandidatePairs([releaseA, unrelated], 10);
34
- expect(pairs).toHaveLength(0);
35
- });
36
-
37
- it('respects maxPairs', () => {
38
- const docs = Array.from({ length: 6 }, (_, i) =>
39
- doc(`d${i}.md`, `${releaseA.text} variant ${i}`)
40
- );
41
- const pairs = selectCandidatePairs(docs, 3);
42
- expect(pairs).toHaveLength(3);
43
- });
44
-
45
- it('returns pairs sorted by descending similarity', () => {
46
- const pairs = selectCandidatePairs([releaseA, releaseB, doc('c.md', releaseA.text)], 10);
47
- for (let i = 1; i < pairs.length; i++) {
48
- expect(pairs[i].similarity).toBeLessThanOrEqual(pairs[i - 1].similarity);
49
- }
50
- });
51
-
52
- it('handles empty and single-doc corpora', () => {
53
- expect(selectCandidatePairs([], 10)).toEqual([]);
54
- expect(selectCandidatePairs([releaseA], 10)).toEqual([]);
55
- });
56
-
57
- it('ignores fenced code blocks when tokenizing', () => {
58
- const codeOnlyA = doc('a.md', 'Short intro.\n```\nconst x = identicalCodeBlock();\n```');
59
- const codeOnlyB = doc('b.md', 'Other topic.\n```\nconst x = identicalCodeBlock();\n```');
60
- const pairs = selectCandidatePairs([codeOnlyA, codeOnlyB], 10);
61
- expect(pairs).toHaveLength(0);
62
- });
63
- });
package/test/json.test.ts DELETED
@@ -1,87 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { extractJson, num, str, validateEvidence } from '../src/core/json';
3
-
4
- describe('extractJson', () => {
5
- it('parses a bare JSON object', () => {
6
- expect(extractJson('{"a":1}')).toEqual({ a: 1 });
7
- });
8
-
9
- it('parses JSON wrapped in prose and code fences', () => {
10
- const text = 'Sure! Here is the result:\n```json\n{"is_duplicate": true, "confidence": 0.9}\n```\nLet me know.';
11
- expect(extractJson(text)).toEqual({ is_duplicate: true, confidence: 0.9 });
12
- });
13
-
14
- it('handles nested objects and braces inside strings', () => {
15
- const text = 'prefix {"summary": "uses {braces} and \\"quotes\\"", "detail": {"x": 1}} suffix {ignored}';
16
- expect(extractJson(text)).toEqual({
17
- summary: 'uses {braces} and "quotes"',
18
- detail: { x: 1 },
19
- });
20
- });
21
-
22
- it('throws when there is no JSON object', () => {
23
- expect(() => extractJson('no json here')).toThrow(/No JSON object/);
24
- });
25
-
26
- it('throws on unbalanced JSON', () => {
27
- expect(() => extractJson('{"a": 1')).toThrow(/Unbalanced/);
28
- });
29
- });
30
-
31
- describe('num', () => {
32
- it('clamps to [0,1] by default', () => {
33
- expect(num(2)).toBe(1);
34
- expect(num(-1)).toBe(0);
35
- expect(num(0.5)).toBe(0.5);
36
- });
37
-
38
- it('coerces non-numbers to 0', () => {
39
- expect(num('not a number')).toBe(0);
40
- expect(num(undefined)).toBe(0);
41
- expect(num(null)).toBe(0);
42
- });
43
-
44
- it('accepts numeric strings', () => {
45
- expect(num('0.7')).toBe(0.7);
46
- });
47
- });
48
-
49
- describe('str', () => {
50
- it('passes strings through', () => {
51
- expect(str('hello')).toBe('hello');
52
- });
53
-
54
- it('coerces null/undefined to empty string', () => {
55
- expect(str(null)).toBe('');
56
- expect(str(undefined)).toBe('');
57
- });
58
-
59
- it('stringifies other values', () => {
60
- expect(str(42)).toBe('42');
61
- });
62
- });
63
-
64
- describe('validateEvidence', () => {
65
- it('normalises page to A unless explicitly B', () => {
66
- const out = validateEvidence([
67
- { page: 'B', excerpt: 'x' },
68
- { page: 'C', excerpt: 'y' },
69
- { excerpt: 'z' },
70
- ]);
71
- expect(out.map((e) => e.page)).toEqual(['B', 'A', 'A']);
72
- });
73
-
74
- it('drops entries with empty excerpts', () => {
75
- expect(validateEvidence([{ page: 'A', excerpt: '' }, { page: 'A' }])).toEqual([]);
76
- });
77
-
78
- it('truncates excerpts to 1000 chars', () => {
79
- const out = validateEvidence([{ page: 'A', excerpt: 'a'.repeat(2000) }]);
80
- expect(out[0].excerpt).toHaveLength(1000);
81
- });
82
-
83
- it('tolerates non-array input', () => {
84
- expect(validateEvidence(null)).toEqual([]);
85
- expect(validateEvidence('nope')).toEqual([]);
86
- });
87
- });
@@ -1,65 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { hasOpenQuestionSignals, mapLimit } from '../src/core/prefilter';
3
-
4
- describe('hasOpenQuestionSignals', () => {
5
- it('detects TODO/TBD/FIXME markers', () => {
6
- expect(hasOpenQuestionSignals('Deploy steps\nTODO: add rollback')).toBe(true);
7
- expect(hasOpenQuestionSignals('Owner: TBD')).toBe(true);
8
- expect(hasOpenQuestionSignals('FIXME later')).toBe(true);
9
- expect(hasOpenQuestionSignals('This is to be decided by the team')).toBe(true);
10
- });
11
-
12
- it('detects placeholders', () => {
13
- expect(hasOpenQuestionSignals('Region: ???')).toBe(true);
14
- expect(hasOpenQuestionSignals('Contact: <add here>')).toBe(true);
15
- });
16
-
17
- it('detects unanswered question lines', () => {
18
- expect(hasOpenQuestionSignals('# Notes\nWho owns the billing service?\n')).toBe(true);
19
- });
20
-
21
- it('ignores questions inside fenced code blocks', () => {
22
- expect(hasOpenQuestionSignals('```\nis this ok?\n```\nAll settled.')).toBe(false);
23
- });
24
-
25
- it('returns false for clean docs', () => {
26
- expect(hasOpenQuestionSignals('# Runbook\nRestart the service with systemctl.')).toBe(false);
27
- });
28
-
29
- it('does not match marker substrings inside words', () => {
30
- expect(hasOpenQuestionSignals('The mastodon population is stable.')).toBe(false);
31
- });
32
- });
33
-
34
- describe('mapLimit', () => {
35
- it('preserves order and maps all items', async () => {
36
- const res = await mapLimit([1, 2, 3, 4, 5], 2, async (n) => n * 10);
37
- expect(res.map((r) => (r.ok ? r.value : -1))).toEqual([10, 20, 30, 40, 50]);
38
- });
39
-
40
- it('captures per-item errors without aborting the batch', async () => {
41
- const res = await mapLimit([1, 2, 3], 2, async (n) => {
42
- if (n === 2) throw new Error('boom');
43
- return n;
44
- });
45
- expect(res[0]).toEqual({ ok: true, value: 1 });
46
- expect(res[1].ok).toBe(false);
47
- expect(res[2]).toEqual({ ok: true, value: 3 });
48
- });
49
-
50
- it('respects the concurrency limit', async () => {
51
- let active = 0;
52
- let peak = 0;
53
- await mapLimit([1, 2, 3, 4, 5, 6], 2, async () => {
54
- active++;
55
- peak = Math.max(peak, active);
56
- await new Promise((r) => setTimeout(r, 5));
57
- active--;
58
- });
59
- expect(peak).toBeLessThanOrEqual(2);
60
- });
61
-
62
- it('handles empty input', async () => {
63
- expect(await mapLimit([], 4, async () => 1)).toEqual([]);
64
- });
65
- });
package/test/slug.test.ts DELETED
@@ -1,31 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseGithubSlug } from '../src/core/slug';
3
-
4
- describe('parseGithubSlug', () => {
5
- it('parses https remotes', () => {
6
- expect(parseGithubSlug('https://github.com/ujjavala/docgrity-vscode.git')).toBe(
7
- 'ujjavala/docgrity-vscode'
8
- );
9
- expect(parseGithubSlug('https://github.com/ujjavala/docgrity-vscode')).toBe(
10
- 'ujjavala/docgrity-vscode'
11
- );
12
- });
13
-
14
- it('parses ssh remotes', () => {
15
- expect(parseGithubSlug('git@github.com:owner/repo.git')).toBe('owner/repo');
16
- expect(parseGithubSlug('ssh://git@github.com/owner/repo.git')).toBe('owner/repo');
17
- });
18
-
19
- it('keeps dots in repo names', () => {
20
- expect(parseGithubSlug('git@github.com:owner/my.repo.name.git')).toBe('owner/my.repo.name');
21
- });
22
-
23
- it('handles trailing slash and whitespace', () => {
24
- expect(parseGithubSlug(' https://github.com/owner/repo/ \n')).toBe('owner/repo');
25
- });
26
-
27
- it('returns undefined for non-GitHub remotes', () => {
28
- expect(parseGithubSlug('https://gitlab.com/owner/repo.git')).toBeUndefined();
29
- expect(parseGithubSlug('')).toBeUndefined();
30
- });
31
- });