@intentic/fileq 1.337.0 → 1.339.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 +29 -3
- package/bin/fileq-rg-pre +66 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +6 -0
- package/dist/app.js.map +1 -1
- package/dist/commands/derive.command.d.ts.map +1 -1
- package/dist/commands/derive.command.js +10 -0
- package/dist/commands/derive.command.js.map +1 -1
- package/dist/commands/read.command.js +1 -1
- package/dist/commands/read.command.js.map +1 -1
- package/dist/commands/ripgrep-config.command.d.ts +3 -0
- package/dist/commands/ripgrep-config.command.d.ts.map +1 -0
- package/dist/commands/ripgrep-config.command.js +24 -0
- package/dist/commands/ripgrep-config.command.js.map +1 -0
- package/dist/commands/stamps.command.d.ts +3 -0
- package/dist/commands/stamps.command.d.ts.map +1 -0
- package/dist/commands/stamps.command.js +13 -0
- package/dist/commands/stamps.command.js.map +1 -0
- package/dist/lib/check/check-pdf.d.ts.map +1 -1
- package/dist/lib/check/check-pdf.js +2 -1
- package/dist/lib/check/check-pdf.js.map +1 -1
- package/dist/lib/check/package.d.ts.map +1 -1
- package/dist/lib/check/package.js +2 -1
- package/dist/lib/check/package.js.map +1 -1
- package/dist/lib/derive.d.ts.map +1 -1
- package/dist/lib/derive.js +39 -8
- package/dist/lib/derive.js.map +1 -1
- package/dist/lib/derivers/pdf.d.ts +1 -0
- package/dist/lib/derivers/pdf.d.ts.map +1 -1
- package/dist/lib/derivers/pdf.js +17 -9
- package/dist/lib/derivers/pdf.js.map +1 -1
- package/dist/lib/formats.d.ts +4 -0
- package/dist/lib/formats.d.ts.map +1 -1
- package/dist/lib/formats.js +15 -0
- package/dist/lib/formats.js.map +1 -1
- package/dist/lib/render/render.d.ts.map +1 -1
- package/dist/lib/render/render.js +5 -7
- package/dist/lib/render/render.js.map +1 -1
- package/dist/lib/sidecar.d.ts +14 -0
- package/dist/lib/sidecar.d.ts.map +1 -1
- package/dist/lib/sidecar.js +40 -4
- package/dist/lib/sidecar.js.map +1 -1
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +23 -2
- package/dist/skill.js.map +1 -1
- package/package.json +9 -7
- package/src/app.ts +6 -0
- package/src/cli.integration.test.ts +9 -0
- package/src/commands/derive.command.ts +12 -0
- package/src/commands/read.command.ts +3 -1
- package/src/commands/ripgrep-config.command.ts +31 -0
- package/src/commands/stamps.command.ts +14 -0
- package/src/lib/check/check-pdf.ts +2 -1
- package/src/lib/check/package.ts +2 -1
- package/src/lib/derive.test.ts +27 -1
- package/src/lib/derive.ts +68 -8
- package/src/lib/derivers/pdf.ts +25 -10
- package/src/lib/formats.ts +32 -0
- package/src/lib/render/render.ts +7 -7
- package/src/lib/sidecar.test.ts +32 -1
- package/src/lib/sidecar.ts +67 -4
- package/src/rg-preprocessor.integration.test.ts +139 -0
- package/src/skill.ts +27 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// https://github.com/MODSetter/SurfSense; see this package's NOTICE). Changed here: a page is blank only when nothing
|
|
8
8
|
// at all is drawn on it (a cover image with no text is not), and the fourteen standard PDF fonts, which every reader
|
|
9
9
|
// carries, are never reported as missing.
|
|
10
|
+
import { errorMessage } from "@intentic/base/errors";
|
|
10
11
|
import { getDocumentProxy, getResolvedPDFJS } from "unpdf";
|
|
11
12
|
import { error, plural, warning, type CheckReport, type Finding } from "./finding.js";
|
|
12
13
|
|
|
@@ -182,7 +183,7 @@ const openPdf = async (bytes: Uint8Array): Promise<PdfDocument | CheckReport> =>
|
|
|
182
183
|
const reason =
|
|
183
184
|
cause instanceof Error && cause.name === "PasswordException"
|
|
184
185
|
? "it is password-protected, so nothing past the password can be checked"
|
|
185
|
-
: `it is not a readable PDF (${
|
|
186
|
+
: `it is not a readable PDF (${errorMessage(cause)})`;
|
|
186
187
|
return { format: "pdf", findings: [error("unreadable", "", reason)], notes: [] };
|
|
187
188
|
}
|
|
188
189
|
};
|
package/src/lib/check/package.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
|
+
import { errorMessage } from "@intentic/base/errors";
|
|
2
3
|
import { unzipSync, type UnzipFileInfo } from "fflate";
|
|
3
4
|
import { error, type Finding } from "./finding.js";
|
|
4
5
|
import { childrenNamed, parseXml, type XmlElement } from "./xml-tree.js";
|
|
@@ -61,7 +62,7 @@ export const openPackage = (bytes: Uint8Array): OoxmlPackage => {
|
|
|
61
62
|
},
|
|
62
63
|
});
|
|
63
64
|
} catch (cause) {
|
|
64
|
-
throw new NotAPackage(`not a readable zip package (${
|
|
65
|
+
throw new NotAPackage(`not a readable zip package (${errorMessage(cause)})`);
|
|
65
66
|
}
|
|
66
67
|
const decoder = new TextDecoder();
|
|
67
68
|
const texts = new Map<string, string>();
|
package/src/lib/derive.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { STATE_DIR } from "@intentic/constants";
|
|
1
|
+
import { SHADOWED_DOCUMENT_EXTENSIONS, STATE_DIR } from "@intentic/constants";
|
|
2
|
+
import { documentExtensions } from "./formats.js";
|
|
3
|
+
import { ocrPageCap } from "./derivers/pdf.js";
|
|
4
|
+
import { ocrCapOf } from "./sidecar.js";
|
|
2
5
|
import { isDeriveIgnored } from "./derive.js";
|
|
3
6
|
|
|
4
7
|
describe("the derive floor (what never gets a shadow, whoever asks)", () => {
|
|
@@ -19,3 +22,26 @@ describe("the derive floor (what never gets a shadow, whoever asks)", () => {
|
|
|
19
22
|
}
|
|
20
23
|
});
|
|
21
24
|
});
|
|
25
|
+
|
|
26
|
+
describe("the OCR page cap (interactive 20, the background keeper more)", () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
delete process.env["FILEQ_OCR_PAGES"];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("FILEQ_OCR_PAGES raises it; nonsense leaves the default", () => {
|
|
32
|
+
expect(ocrPageCap()).toBe(20);
|
|
33
|
+
process.env["FILEQ_OCR_PAGES"] = "500";
|
|
34
|
+
expect(ocrPageCap()).toBe(500);
|
|
35
|
+
process.env["FILEQ_OCR_PAGES"] = "lots";
|
|
36
|
+
expect(ocrPageCap()).toBe(20);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a rendering's cap is read back off its note; an uncapped one has none", () => {
|
|
40
|
+
expect(ocrCapOf(["scanned: text recognised by OCR", "OCR stops at 20 pages: draw the rest with pdftoppm"])).toBe(20);
|
|
41
|
+
expect(ocrCapOf(["scanned: text recognised by OCR"])).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("fileq's document formats are the extensions iq and the preprocessor treat as documents", () => {
|
|
46
|
+
expect(documentExtensions()).toEqual([...SHADOWED_DOCUMENT_EXTENSIONS].toSorted());
|
|
47
|
+
});
|
package/src/lib/derive.ts
CHANGED
|
@@ -17,13 +17,26 @@ import { mediaDeriver } from "./derivers/media.js";
|
|
|
17
17
|
import { odpDeriver } from "./derivers/odp.js";
|
|
18
18
|
import { odsDeriver } from "./derivers/ods.js";
|
|
19
19
|
import { odtDeriver } from "./derivers/odt.js";
|
|
20
|
-
import { pdfDeriver } from "./derivers/pdf.js";
|
|
20
|
+
import { ocrPageCap, pdfDeriver } from "./derivers/pdf.js";
|
|
21
21
|
import { pptxDeriver } from "./derivers/pptx.js";
|
|
22
22
|
import { profileDeriver } from "./derivers/profile.js";
|
|
23
23
|
import { rtfDeriver } from "./derivers/rtf.js";
|
|
24
24
|
import { xlsxDeriver } from "./derivers/xlsx.js";
|
|
25
25
|
import { cacheRendering, readCachedRendering } from "./content-cache.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
isFresh,
|
|
28
|
+
ocrCapOf,
|
|
29
|
+
parseSidecarFront,
|
|
30
|
+
readSidecar,
|
|
31
|
+
readSourceStat,
|
|
32
|
+
removeSidecar,
|
|
33
|
+
sidecarBody,
|
|
34
|
+
sidecarPathFor,
|
|
35
|
+
sha256OfFile,
|
|
36
|
+
sourceUnchanged,
|
|
37
|
+
writeSidecar,
|
|
38
|
+
type SourceStat,
|
|
39
|
+
} from "./sidecar.js";
|
|
27
40
|
|
|
28
41
|
// Pipeline `read` and `derive` both run: place the file, recognize it, route it, keep its shadow honest.
|
|
29
42
|
// One module, so `read` and `derive` cannot disagree about a file's markdown.
|
|
@@ -90,6 +103,10 @@ interface Rendered {
|
|
|
90
103
|
readonly cachedPath: string | undefined;
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
// A rendering whose OCR stopped short of the page cap this run asks for (a 20-page interactive read, met by the shadow
|
|
107
|
+
// keeper's 500) is not good enough for it; one that went deeper serves a shallower ask as it is.
|
|
108
|
+
const deepEnough = (notes: readonly string[]): boolean => (ocrCapOf(notes) ?? Number.POSITIVE_INFINITY) >= ocrPageCap();
|
|
109
|
+
|
|
93
110
|
// These bytes as this deriver renders them: from the content cache when rendered before, else derived and stored there.
|
|
94
111
|
// Throws only what the deriver throws.
|
|
95
112
|
const renderCached = async (
|
|
@@ -101,7 +118,7 @@ const renderCached = async (
|
|
|
101
118
|
): Promise<Rendered> => {
|
|
102
119
|
const stamp = deriverStamp(deriver);
|
|
103
120
|
const cached = await readCachedRendering(workspaceRoot, sourceSha, stamp);
|
|
104
|
-
if (cached !== undefined) {
|
|
121
|
+
if (cached !== undefined && deepEnough(cached.doc.notes)) {
|
|
105
122
|
return { kind: "fresh", doc: cached.doc, derivedAt: cached.derivedAt, cachedPath: cached.path };
|
|
106
123
|
}
|
|
107
124
|
// Neutralized once here so every consumer gets folded text.
|
|
@@ -110,6 +127,40 @@ const renderCached = async (
|
|
|
110
127
|
return { kind: "derived", doc, derivedAt, cachedPath: await cacheRendering(workspaceRoot, { sourceSha, deriverStamp: stamp, doc, derivedAt }) };
|
|
111
128
|
};
|
|
112
129
|
|
|
130
|
+
interface ReuseAsk {
|
|
131
|
+
readonly relPath: string;
|
|
132
|
+
readonly sourceSha: string;
|
|
133
|
+
readonly stamp: string;
|
|
134
|
+
readonly sourceStat: SourceStat | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The sidecar's body when it is fresh for these bytes and deep enough for this run; undefined when it must be derived.
|
|
138
|
+
// Same bytes under a new stat (a touch, a checkout, a sidecar from before stats were kept) get the stat rewritten, so
|
|
139
|
+
// hash-free readers find the shadow current again.
|
|
140
|
+
const reuseFresh = async (workspaceRoot: string, ask: ReuseAsk, now: () => Date): Promise<string | undefined> => {
|
|
141
|
+
const existing = await readSidecar(sidecarPathFor(workspaceRoot, ask.relPath));
|
|
142
|
+
if (existing === undefined || !isFresh(existing, ask.sourceSha, ask.stamp)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const front = parseSidecarFront(existing);
|
|
146
|
+
if (!deepEnough(front.notes)) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const body = sidecarBody(existing);
|
|
150
|
+
if (!sourceUnchanged(front, ask.sourceStat)) {
|
|
151
|
+
const derivedAt = new Date(front.derivedAt ?? Number.NaN);
|
|
152
|
+
await writeSidecar(workspaceRoot, {
|
|
153
|
+
relPath: ask.relPath,
|
|
154
|
+
sourceSha: ask.sourceSha,
|
|
155
|
+
deriverStamp: ask.stamp,
|
|
156
|
+
sourceStat: ask.sourceStat,
|
|
157
|
+
doc: { markdown: body.endsWith("\n") ? body.slice(0, -1) : body, title: front.title, notes: [...front.notes] },
|
|
158
|
+
derivedAt: Number.isNaN(derivedAt.getTime()) ? now() : derivedAt,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return body;
|
|
162
|
+
};
|
|
163
|
+
|
|
113
164
|
/** Converges a workspace file's sidecar with its source: derive when stale, reuse fresh, remove when gone. */
|
|
114
165
|
export const ensureSidecar = async (workspaceRoot: string, absPath: string, now: () => Date = () => new Date()): Promise<Outcome> => {
|
|
115
166
|
const relPath = relPathIn(workspaceRoot, absPath);
|
|
@@ -142,11 +193,13 @@ export const ensureSidecar = async (workspaceRoot: string, absPath: string, now:
|
|
|
142
193
|
}
|
|
143
194
|
const deriver = DERIVERS[format];
|
|
144
195
|
const stamp = deriverStamp(deriver);
|
|
196
|
+
// Taken before the hash, so a write racing the read leaves a stat that no longer matches, never one that vouches
|
|
197
|
+
// for bytes it did not see.
|
|
198
|
+
const sourceStat = await readSourceStat(absPath);
|
|
145
199
|
const sourceSha = await sha256OfFile(absPath);
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
return { kind: "fresh", relPath, format, sidecarPath, body, tokens: estimateTokens(body) };
|
|
200
|
+
const fresh = await reuseFresh(workspaceRoot, { relPath, sourceSha, stamp, sourceStat }, now);
|
|
201
|
+
if (fresh !== undefined) {
|
|
202
|
+
return { kind: "fresh", relPath, format, sidecarPath, body: fresh, tokens: estimateTokens(fresh) };
|
|
150
203
|
}
|
|
151
204
|
// A corrupt file's failure is a loud skip, not fatal.
|
|
152
205
|
let rendered: Rendered;
|
|
@@ -156,7 +209,14 @@ export const ensureSidecar = async (workspaceRoot: string, absPath: string, now:
|
|
|
156
209
|
const detail = errorMessage(error).split("\n")[0];
|
|
157
210
|
return { kind: "skipped", relPath, reason: `derive-failed (${format}): ${detail}` };
|
|
158
211
|
}
|
|
159
|
-
const written = await writeSidecar(workspaceRoot, {
|
|
212
|
+
const written = await writeSidecar(workspaceRoot, {
|
|
213
|
+
relPath,
|
|
214
|
+
sourceSha,
|
|
215
|
+
deriverStamp: stamp,
|
|
216
|
+
sourceStat,
|
|
217
|
+
doc: rendered.doc,
|
|
218
|
+
derivedAt: rendered.derivedAt,
|
|
219
|
+
});
|
|
160
220
|
return rendered.kind === "fresh"
|
|
161
221
|
? { kind: "fresh", relPath, format, sidecarPath: written.path, body: written.body, tokens: written.tokens }
|
|
162
222
|
: { kind: "derived", relPath, format, sidecarPath: written.path, body: written.body, doc: rendered.doc, tokens: written.tokens };
|
package/src/lib/derivers/pdf.ts
CHANGED
|
@@ -18,11 +18,24 @@ import { stripRunningFurniture } from "./pdf-furniture.js";
|
|
|
18
18
|
// Below this many chars per page, the text layer is furniture, not content: a scan with a vestigial layer.
|
|
19
19
|
const SCAN_THRESHOLD_CHARS_PER_PAGE = 24;
|
|
20
20
|
|
|
21
|
-
// OCR costs seconds per page, so a long scan gets its first pages and a note.
|
|
22
|
-
|
|
21
|
+
// OCR costs seconds per page, so a long scan gets its first pages and a note. Someone reading now gets 20; the daemon's
|
|
22
|
+
// shadow keeper, working in the background with nobody waiting, asks for more (`fileq derive --ocr-pages 500`, which
|
|
23
|
+
// sets FILEQ_OCR_PAGES for the run).
|
|
24
|
+
const DEFAULT_OCR_PAGES = 20;
|
|
25
|
+
const MAX_OCR_PAGES = 5000;
|
|
23
26
|
const OCR_DPI = "200";
|
|
27
|
+
|
|
28
|
+
/** How many pages of a scan this run recognises: FILEQ_OCR_PAGES when it is a sane count, else 20. */
|
|
29
|
+
export const ocrPageCap = (): number => {
|
|
30
|
+
const asked = Number(process.env["FILEQ_OCR_PAGES"]);
|
|
31
|
+
return Number.isInteger(asked) && asked > 0 ? Math.min(asked, MAX_OCR_PAGES) : DEFAULT_OCR_PAGES;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The note a capped recognition leaves; ocrCapOf (sidecar.ts) reads the cap back off it.
|
|
35
|
+
const capNote = (cap: number): string => `OCR stops at ${cap} pages: draw the rest with pdftoppm and read them with \`ocr <page.png>...\` if they matter`;
|
|
36
|
+
|
|
24
37
|
// The reader loads its models once per run and then takes a second or few per page.
|
|
25
|
-
const
|
|
38
|
+
const ocrTimeoutMs = (pages: number): number => 60_000 + pages * 15_000;
|
|
26
39
|
|
|
27
40
|
// Asked once per PATH: the answer is a fact of the image, and a stamp is read on every derive.
|
|
28
41
|
const checked = new Map<string, boolean>();
|
|
@@ -46,18 +59,19 @@ export const ocrAvailable = (): boolean => {
|
|
|
46
59
|
// could not read leaves its page empty (exit 1) rather than shifting the ones after it. The pages are split on those
|
|
47
60
|
// form feeds, so the cap stays well past what twenty pages of text come to: a cut middle would shift every page after it.
|
|
48
61
|
const readPages = async (images: readonly string[]): Promise<string> => {
|
|
49
|
-
const result = await runBounded("ocr", images, { timeoutMs:
|
|
62
|
+
const result = await runBounded("ocr", images, { timeoutMs: ocrTimeoutMs(images.length), maxOutput: 64 * 1024 * 1024 });
|
|
50
63
|
if (!result.timedOut && (result.exitCode === 0 || result.exitCode === 1)) {
|
|
51
64
|
return result.stdout.text;
|
|
52
65
|
}
|
|
53
66
|
throw new ProcessError("ocr", images, result);
|
|
54
67
|
};
|
|
55
68
|
|
|
56
|
-
const recognisePages = async (absPath: string, totalPages: number): Promise<string[]> => {
|
|
69
|
+
const recognisePages = async (absPath: string, totalPages: number, cap: number): Promise<string[]> => {
|
|
57
70
|
const dir = await mkdtemp(join(tmpdir(), "fileq-ocr-"));
|
|
71
|
+
const last = Math.min(totalPages, cap);
|
|
58
72
|
try {
|
|
59
|
-
await execBounded("pdftoppm", ["-r", OCR_DPI, "-png", "-f", "1", "-l", String(
|
|
60
|
-
timeoutMs: 120_000,
|
|
73
|
+
await execBounded("pdftoppm", ["-r", OCR_DPI, "-png", "-f", "1", "-l", String(last), absPath, join(dir, "page")], {
|
|
74
|
+
timeoutMs: 120_000 + last * 2_000,
|
|
61
75
|
});
|
|
62
76
|
const images = (await readdir(dir)).filter((name) => name.endsWith(".png")).toSorted();
|
|
63
77
|
if (images.length === 0) {
|
|
@@ -114,12 +128,13 @@ export const pdfDeriver: Deriver = {
|
|
|
114
128
|
notes: [`no usable text layer across ${totalPages} page${plural} (a scan?): OCR is not part of this tier`],
|
|
115
129
|
};
|
|
116
130
|
}
|
|
117
|
-
const
|
|
131
|
+
const cap = ocrPageCap();
|
|
132
|
+
const recognised = await recognisePages(absPath, totalPages, cap);
|
|
118
133
|
const notes = [
|
|
119
134
|
`scanned: text recognised by OCR (PaddleOCR) on ${recognised.length} of ${totalPages} page${plural} — recognised, not exact; check figures against the page image`,
|
|
120
135
|
];
|
|
121
|
-
if (totalPages >
|
|
122
|
-
notes.push(
|
|
136
|
+
if (totalPages > cap) {
|
|
137
|
+
notes.push(capNote(cap));
|
|
123
138
|
}
|
|
124
139
|
const { markdown, notes: stripped } = withoutFurniture(recognised);
|
|
125
140
|
return { markdown, title, notes: [...notes, ...stripped] };
|
package/src/lib/formats.ts
CHANGED
|
@@ -129,6 +129,38 @@ export const detectFormat = async (absPath: string): Promise<Format | undefined>
|
|
|
129
129
|
*/
|
|
130
130
|
export const claimedFormat = async (absPath: string): Promise<Format | undefined> => (await detectFormat(absPath)) ?? EXTENSION_FORMAT[extname(absPath).toLowerCase()];
|
|
131
131
|
|
|
132
|
+
// Documents: formats whose text is what a reader searches for, and cheap to derive (no model is asked anything, unless a
|
|
133
|
+
// scanned pdf finds the OCR pack). The daemon's shadow keeper keeps a shadow of every one of these in the workspace, and
|
|
134
|
+
// ripgrep reads them through fileq's preprocessor; pictures, recordings, archives and notebooks are left to be read
|
|
135
|
+
// when someone asks.
|
|
136
|
+
export const DOCUMENT_FORMATS: ReadonlySet<Format> = new Set(["pdf", "docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub"]);
|
|
137
|
+
|
|
138
|
+
/** Every extension (lower case, with its dot) a document format is recognised by. */
|
|
139
|
+
export const documentExtensions = (): string[] =>
|
|
140
|
+
Object.entries(EXTENSION_FORMAT)
|
|
141
|
+
.filter(([, format]) => DOCUMENT_FORMATS.has(format))
|
|
142
|
+
.map(([extension]) => extension)
|
|
143
|
+
.toSorted();
|
|
144
|
+
|
|
145
|
+
/** The format a path's extension names when it is a document; undefined for anything else. */
|
|
146
|
+
export const documentFormatOf = (path: string): Format | undefined => {
|
|
147
|
+
const format = EXTENSION_FORMAT[extname(path).toLowerCase()];
|
|
148
|
+
return format !== undefined && DOCUMENT_FORMATS.has(format) ? format : undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Both spellings of each extension, since ripgrep's globs are case-sensitive and a scanner names files `SCAN.PDF`.
|
|
152
|
+
const caseGlob = (extension: string): string => `*.{${extension.slice(1)},${extension.slice(1).toUpperCase()}}`;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A ripgrep config (RIPGREP_CONFIG_PATH) routing every document through `pre`, fileq's preprocessor: one flag per
|
|
156
|
+
* line, as ripgrep reads it. Code and every other file are untouched, since only these globs reach the preprocessor.
|
|
157
|
+
*/
|
|
158
|
+
export const ripgrepConfigLines = (pre: string): string[] => [
|
|
159
|
+
"# fileq: ripgrep searches the text of documents, cited by page (`[p. 12] `); see `fileq ripgrep-config --help`.",
|
|
160
|
+
`--pre=${pre}`,
|
|
161
|
+
...documentExtensions().map((extension) => `--pre-glob=${caseGlob(extension)}`),
|
|
162
|
+
];
|
|
163
|
+
|
|
132
164
|
// Formats whose text diff is already the better reading: a textconv over them would replace a line diff with a
|
|
133
165
|
// rendering of the same text.
|
|
134
166
|
const TEXT_UNDERNEATH: ReadonlySet<Format> = new Set(["html"]);
|
package/src/lib/render/render.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { parseJsonOr } from "@intentic/base/json";
|
|
6
|
+
import { readTextIfExists } from "@intentic/base/fs";
|
|
5
7
|
import { toolOutDir } from "@intentic/agent-cli/env";
|
|
6
8
|
import { STATE_DIR } from "@intentic/constants";
|
|
7
9
|
import { getDocumentProxy } from "unpdf";
|
|
@@ -75,13 +77,11 @@ export const defaultOutDir = (absPath: string, workspaceRoot: string | undefined
|
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
const readStamp = async (dir: string): Promise<Stamp | undefined> => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
80
|
+
// No earlier render, or a stamp someone edited; either way the answer is to render afresh.
|
|
81
|
+
const text = await readTextIfExists(join(dir, STAMP_FILE));
|
|
82
|
+
// SAFETY: only this module writes the stamp, and each field is checked for presence before the stamp is believed.
|
|
83
|
+
const parsed = text === undefined ? undefined : (parseJsonOr(text, undefined) as Partial<Stamp> | null | undefined);
|
|
84
|
+
return parsed?.sha256 === undefined || parsed.size === undefined || parsed.total === undefined ? undefined : { sha256: parsed.sha256, size: parsed.size, total: parsed.total };
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
// Removes the images an earlier render of different bytes left, and nothing else in the folder.
|
package/src/lib/sidecar.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isFresh, parseSidecarFront, sidecarBody, sidecarPathFor } from "./sidecar.js";
|
|
1
|
+
import { isFresh, parseSidecarFront, sidecarBody, sidecarPathFor, sourceStatOf, sourceUnchanged } from "./sidecar.js";
|
|
2
2
|
|
|
3
3
|
const SIDECAR = `---
|
|
4
4
|
source: docs/spec.docx
|
|
@@ -24,6 +24,8 @@ describe("sidecar front matter", () => {
|
|
|
24
24
|
derivedAt: "2026-08-29T10:00:00.000Z",
|
|
25
25
|
// JSON-encoded on the way in, so a colon in a title cannot split the line it rides on.
|
|
26
26
|
title: "Quarterly: the plan",
|
|
27
|
+
sourceSize: undefined,
|
|
28
|
+
sourceMtime: undefined,
|
|
27
29
|
notes: ["sheet cut", "showing 200 of 4,000 rows"],
|
|
28
30
|
});
|
|
29
31
|
});
|
|
@@ -39,6 +41,8 @@ describe("sidecar front matter", () => {
|
|
|
39
41
|
deriver: undefined,
|
|
40
42
|
derivedAt: undefined,
|
|
41
43
|
title: undefined,
|
|
44
|
+
sourceSize: undefined,
|
|
45
|
+
sourceMtime: undefined,
|
|
42
46
|
notes: [],
|
|
43
47
|
});
|
|
44
48
|
expect(sidecarBody("just text")).toBe("just text");
|
|
@@ -58,3 +62,30 @@ describe("freshness", () => {
|
|
|
58
62
|
test("the shadow of a path is the path, mirrored under the derived tree", () => {
|
|
59
63
|
expect(sidecarPathFor("/work", "docs/spec.docx")).toBe("/work/.intentic/local/cache/derived/docs/spec.docx.md");
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
describe("source stat (the hash-free test)", () => {
|
|
67
|
+
const WITH_STAT = SIDECAR.replace("deriver: docx v1\n", "deriver: docx v1\nsource_size: 4096\nsource_mtime: 1791910037.034237706\n");
|
|
68
|
+
|
|
69
|
+
test("spelled as GNU stat -c '%s %.9Y' prints it, nanoseconds zero-padded", () => {
|
|
70
|
+
expect(sourceStatOf({ size: 4096n, mtimeNs: 1_791_910_037_034_237_706n })).toEqual({ size: "4096", mtime: "1791910037.034237706" });
|
|
71
|
+
expect(sourceStatOf({ size: 0n, mtimeNs: 5_000_000_007n })).toEqual({ size: "0", mtime: "5.000000007" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a sidecar carrying it matches only the same size and mtime", () => {
|
|
75
|
+
const front = parseSidecarFront(WITH_STAT);
|
|
76
|
+
expect(front.sourceSize).toBe("4096");
|
|
77
|
+
expect(sourceUnchanged(front, { size: "4096", mtime: "1791910037.034237706" })).toBe(true);
|
|
78
|
+
expect(sourceUnchanged(front, { size: "4096", mtime: "1791910038.034237706" })).toBe(false);
|
|
79
|
+
expect(sourceUnchanged(front, { size: "4097", mtime: "1791910037.034237706" })).toBe(false);
|
|
80
|
+
expect(sourceUnchanged(front, undefined)).toBe(false);
|
|
81
|
+
// The stat is a hint beside the hash, never part of freshness itself.
|
|
82
|
+
expect(isFresh(WITH_STAT, "abc123", "docx v1")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an older sidecar without it still reads, still counts as fresh by hash, and never as unchanged", () => {
|
|
86
|
+
const front = parseSidecarFront(SIDECAR);
|
|
87
|
+
expect(front.sha256).toBe("abc123");
|
|
88
|
+
expect(isFresh(SIDECAR, "abc123", "docx v1")).toBe(true);
|
|
89
|
+
expect(sourceUnchanged(front, { size: "4096", mtime: "1791910037.034237706" })).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/lib/sidecar.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { estimateTokens } from "@intentic/base/format";
|
|
6
6
|
import { writeFileAtomic } from "@intentic/base/fs";
|
|
7
7
|
import { neutralizeOutsideText } from "@intentic/base/outside-text";
|
|
8
|
-
import {
|
|
8
|
+
import { DERIVED_SHADOWS_DIR } from "@intentic/constants";
|
|
9
9
|
import type { DerivedDoc } from "./derivers/deriver.js";
|
|
10
10
|
|
|
11
11
|
// One markdown shadow per derivable file, at a predictable mirrored path under .intentic/local/cache/derived (portable,
|
|
@@ -14,8 +14,12 @@ import type { DerivedDoc } from "./derivers/deriver.js";
|
|
|
14
14
|
// deriver stamp both still match, never by mtime.
|
|
15
15
|
// Writes (sidecars and content-cache entries alike) go through exactly one function, since either is read back by a
|
|
16
16
|
// plain `Read` with no untrusted-content wrapper, so neutralization must happen at write time.
|
|
17
|
+
// A sidecar also records the source's size and mtime (`source_size`, `source_mtime`). They are not what freshness
|
|
18
|
+
// means here, and fileq never trusts them over the hash; they let a reader that cannot afford a hash (the ripgrep
|
|
19
|
+
// preprocessor, a POSIX shell script; the daemon's shadow keeper, over thousands of files) tell an untouched source
|
|
20
|
+
// with one `stat`. A sidecar written before they existed simply lacks them, and such a reader takes its slow path.
|
|
17
21
|
|
|
18
|
-
export const DERIVED_DIR =
|
|
22
|
+
export const DERIVED_DIR = DERIVED_SHADOWS_DIR;
|
|
19
23
|
|
|
20
24
|
export const sidecarPathFor = (workspaceRoot: string, relPath: string): string => join(workspaceRoot, DERIVED_DIR, `${relPath}.md`);
|
|
21
25
|
|
|
@@ -35,11 +39,65 @@ export interface SidecarFront {
|
|
|
35
39
|
readonly deriver: string | undefined;
|
|
36
40
|
readonly derivedAt: string | undefined;
|
|
37
41
|
readonly title: string | undefined;
|
|
42
|
+
/** The source's byte size and mtime when this was written (sourceStatOf's spelling); absent on older sidecars. */
|
|
43
|
+
readonly sourceSize: string | undefined;
|
|
44
|
+
readonly sourceMtime: string | undefined;
|
|
38
45
|
/** Every cap and degradation the derivation hit, in the order it wrote them. */
|
|
39
46
|
readonly notes: readonly string[];
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
const EMPTY_FRONT: SidecarFront = {
|
|
49
|
+
const EMPTY_FRONT: SidecarFront = {
|
|
50
|
+
source: undefined,
|
|
51
|
+
sha256: undefined,
|
|
52
|
+
deriver: undefined,
|
|
53
|
+
derivedAt: undefined,
|
|
54
|
+
title: undefined,
|
|
55
|
+
sourceSize: undefined,
|
|
56
|
+
sourceMtime: undefined,
|
|
57
|
+
notes: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A source's size and mtime as a sidecar records them: what GNU `stat -c '%s %.9Y'` prints, so a shell can compare. */
|
|
61
|
+
export interface SourceStat {
|
|
62
|
+
readonly size: string;
|
|
63
|
+
readonly mtime: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const sourceStatOf = (stats: { readonly size: bigint; readonly mtimeNs: bigint }): SourceStat => {
|
|
67
|
+
const seconds = stats.mtimeNs / 1_000_000_000n;
|
|
68
|
+
const nanos = stats.mtimeNs % 1_000_000_000n;
|
|
69
|
+
return { size: stats.size.toString(), mtime: `${seconds.toString()}.${nanos.toString().padStart(9, "0")}` };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The file's SourceStat now; undefined when it cannot be stat'd. */
|
|
73
|
+
export const readSourceStat = async (absPath: string): Promise<SourceStat | undefined> => {
|
|
74
|
+
try {
|
|
75
|
+
return sourceStatOf(await stat(absPath, { bigint: true }));
|
|
76
|
+
} catch {
|
|
77
|
+
// allow(silent-catch): no stat means no hash-free answer; every caller then takes the path that hashes.
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// pdf.ts's note for a scan whose OCR stopped at a page cap (interactive reads recognise 20 pages, the daemon's
|
|
83
|
+
// background keeper more). Read here, beside the front matter, so a caller that never loads the pdf reader (the
|
|
84
|
+
// daemon) can tell a shadow cut shorter than it wants.
|
|
85
|
+
const CAP_NOTE = /^OCR stops at (\d+) pages/;
|
|
86
|
+
|
|
87
|
+
/** The page cap a rendering's OCR stopped at, off its notes; undefined when it recognised every page (or none). */
|
|
88
|
+
export const ocrCapOf = (notes: readonly string[]): number | undefined => {
|
|
89
|
+
for (const note of notes) {
|
|
90
|
+
const match = CAP_NOTE.exec(note);
|
|
91
|
+
if (match !== null) {
|
|
92
|
+
return Number(match[1]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Whether a sidecar's recorded size and mtime are this source's: the hash-free test, false when either is missing. */
|
|
99
|
+
export const sourceUnchanged = (front: SidecarFront, now: SourceStat | undefined): boolean =>
|
|
100
|
+
now !== undefined && front.sourceSize === now.size && front.sourceMtime === now.mtime;
|
|
43
101
|
|
|
44
102
|
// A line scan rather than a YAML parse, so an edited-by-hand sidecar simply reads as stale instead of throwing.
|
|
45
103
|
// Title and notes are JSON-encoded on the way in (writeSidecar), so they decode back rather than splitting on a colon.
|
|
@@ -67,6 +125,8 @@ export const parseSidecarFront = (content: string): SidecarFront => {
|
|
|
67
125
|
deriver: field("deriver"),
|
|
68
126
|
derivedAt: field("derived_at"),
|
|
69
127
|
title: decode(field("title")),
|
|
128
|
+
sourceSize: field("source_size"),
|
|
129
|
+
sourceMtime: field("source_mtime"),
|
|
70
130
|
notes: [...head.matchAll(/^note: (.+)$/gm)].map((match) => decode(match[1]) ?? ""),
|
|
71
131
|
};
|
|
72
132
|
};
|
|
@@ -103,6 +163,8 @@ export interface DerivedEntry {
|
|
|
103
163
|
readonly deriverStamp: string;
|
|
104
164
|
readonly doc: DerivedDoc;
|
|
105
165
|
readonly derivedAt: Date;
|
|
166
|
+
/** The source's stat when it was read, for a sidecar only (see SourceStat). */
|
|
167
|
+
readonly sourceStat?: SourceStat | undefined;
|
|
106
168
|
}
|
|
107
169
|
|
|
108
170
|
export interface WriteSidecarInput extends Omit<DerivedEntry, "source"> {
|
|
@@ -123,6 +185,7 @@ export const writeDerived = async (path: string, entry: DerivedEntry): Promise<{
|
|
|
123
185
|
"---",
|
|
124
186
|
...(entry.source === undefined ? [] : [`source: ${entry.source}`]),
|
|
125
187
|
`sha256: ${entry.sourceSha}`,
|
|
188
|
+
...(entry.sourceStat === undefined ? [] : [`source_size: ${entry.sourceStat.size}`, `source_mtime: ${entry.sourceStat.mtime}`]),
|
|
126
189
|
`deriver: ${entry.deriverStamp}`,
|
|
127
190
|
`derived_at: ${entry.derivedAt.toISOString()}`,
|
|
128
191
|
...(title === undefined ? [] : [`title: ${JSON.stringify(title)}`]),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The ripgrep preprocessor (bin/fileq-rg-pre) against a temp workspace: a fresh shadow is printed with page marks
|
|
2
|
+
// and no fileq run, a stale or pre-stat shadow falls back to `fileq read --plain`, a non-document passes through, and
|
|
3
|
+
// plain `rg` under the generated config cites documents by page while code reads exactly as without it.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { markPages } from "@intentic/base/shadow-text";
|
|
10
|
+
import { captureCli } from "@intentic/agent-cli/testing";
|
|
11
|
+
import { app } from "./app.js";
|
|
12
|
+
import { documentExtensions, ripgrepConfigLines } from "./lib/formats.js";
|
|
13
|
+
import { DERIVED_DIR, sidecarBody } from "./lib/sidecar.js";
|
|
14
|
+
import { pdfPagesBytes } from "./testing.js";
|
|
15
|
+
|
|
16
|
+
const PRE = fileURLToPath(new URL("../bin/fileq-rg-pre", import.meta.url));
|
|
17
|
+
const hasRg = spawnSync("rg", ["--version"]).status === 0;
|
|
18
|
+
|
|
19
|
+
let root: string;
|
|
20
|
+
let bin: string;
|
|
21
|
+
let calls: string;
|
|
22
|
+
|
|
23
|
+
const page = (text: string) => ({ content: `BT /F1 12 Tf 72 700 Td (${text}) Tj ET` });
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
root = mkdtempSync(join(tmpdir(), "fileq-rgpre-"));
|
|
27
|
+
bin = mkdtempSync(join(tmpdir(), "fileq-rgpre-bin-"));
|
|
28
|
+
calls = join(bin, "calls");
|
|
29
|
+
// A stand-in fileq on PATH: records that the slow path ran, and answers as `read --plain` would.
|
|
30
|
+
writeFileSync(join(bin, "fileq"), `#!/bin/sh\necho "$@" >> '${calls}'\nprintf '## Page 1\\n\\nslow first\\n\\n## Page 2\\n\\nslow second\\n'\n`);
|
|
31
|
+
chmodSync(join(bin, "fileq"), 0o755);
|
|
32
|
+
process.env["WORKSPACE_ROOT"] = root;
|
|
33
|
+
mkdirSync(join(root, "docs"), { recursive: true });
|
|
34
|
+
writeFileSync(join(root, "docs/terms.pdf"), pdfPagesBytes([page("Introduction to the terms of service here"), page("Each wniosek must be filed in writing today")]));
|
|
35
|
+
});
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
delete process.env["WORKSPACE_ROOT"];
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
rmSync(bin, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const pre = (file: string, cwd = root) => {
|
|
43
|
+
rmSync(calls, { force: true });
|
|
44
|
+
const result = spawnSync("sh", [PRE, file], { cwd, encoding: "utf8", env: { PATH: `${bin}:${process.env["PATH"] ?? ""}`, WORKSPACE_ROOT: root } });
|
|
45
|
+
expect(result.status).toBe(0);
|
|
46
|
+
return { out: result.stdout, slow: existsSync(calls) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const shadowOf = (relPath: string): string => join(root, DERIVED_DIR, `${relPath}.md`);
|
|
50
|
+
|
|
51
|
+
describe("fast path: a shadow whose recorded stat is the file's", () => {
|
|
52
|
+
test("prints the body with page marks, without running fileq", async () => {
|
|
53
|
+
expect((await captureCli(app, ["derive", "docs/terms.pdf"])).exitCode).toBe(0);
|
|
54
|
+
const shadow = readFileSync(shadowOf("docs/terms.pdf"), "utf8");
|
|
55
|
+
expect(shadow).toMatch(/^source_size: \d+$/m);
|
|
56
|
+
expect(shadow).toMatch(/^source_mtime: \d+\.\d{9}$/m);
|
|
57
|
+
const { out, slow } = pre("./docs/terms.pdf");
|
|
58
|
+
expect(slow).toBe(false);
|
|
59
|
+
// The shell's marks are exactly the TypeScript ones iq indexes with.
|
|
60
|
+
expect(out).toBe(markPages(sidecarBody(shadow)));
|
|
61
|
+
expect(out).toContain("[p. 2] Each wniosek must be filed in writing today");
|
|
62
|
+
expect(out.split("\n")[0]).toBe("[p. 1] ## Page 1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an absolute path and a path from a subdirectory find the same shadow", () => {
|
|
66
|
+
expect(pre(join(root, "docs/terms.pdf")).slow).toBe(false);
|
|
67
|
+
expect(pre("terms.pdf", join(root, "docs")).slow).toBe(false);
|
|
68
|
+
expect(pre("../docs/terms.pdf", join(root, "docs")).slow).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("slow path: fileq read --plain, marked the same way", () => {
|
|
73
|
+
test("a file touched since its shadow was written", () => {
|
|
74
|
+
const later = new Date(Date.now() + 60_000);
|
|
75
|
+
utimesSync(join(root, "docs/terms.pdf"), later, later);
|
|
76
|
+
const { out, slow } = pre("docs/terms.pdf");
|
|
77
|
+
expect(slow).toBe(true);
|
|
78
|
+
expect(readFileSync(calls, "utf8")).toContain(`read --plain ${join(root, "docs/terms.pdf")}`);
|
|
79
|
+
expect(out).toBe("[p. 1] ## Page 1\n[p. 1] \n[p. 1] slow first\n[p. 1] \n[p. 2] ## Page 2\n[p. 2] \n[p. 2] slow second\n");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("fileq rewrites the stat of an unchanged file, and the next run is fast again", async () => {
|
|
83
|
+
expect((await captureCli(app, ["derive", "docs/terms.pdf"])).out).toContain("fresh docs/terms.pdf");
|
|
84
|
+
expect(pre("docs/terms.pdf").slow).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a sidecar written before stats were recorded still reads, through the slow path", () => {
|
|
88
|
+
const path = shadowOf("docs/terms.pdf");
|
|
89
|
+
const old = readFileSync(path, "utf8").replaceAll(/^source_(size|mtime): .*\n/gm, "");
|
|
90
|
+
writeFileSync(path, old);
|
|
91
|
+
expect(pre("docs/terms.pdf").slow).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a document with no shadow at all", () => {
|
|
95
|
+
writeFileSync(join(root, "docs/fresh.pdf"), pdfPagesBytes([page("No shadow yet for this one")]));
|
|
96
|
+
expect(pre("docs/fresh.pdf").slow).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a file that is not a document passes through untouched", () => {
|
|
101
|
+
writeFileSync(join(root, "notes.ts"), "export const wniosek = 1;\n");
|
|
102
|
+
const { out, slow } = pre("notes.ts");
|
|
103
|
+
expect(out).toBe("export const wniosek = 1;\n");
|
|
104
|
+
expect(slow).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("the script's extension list is fileq's document list", () => {
|
|
108
|
+
const script = readFileSync(PRE, "utf8");
|
|
109
|
+
for (const extension of documentExtensions()) {
|
|
110
|
+
const bracketed = Array.from(extension.slice(1), (char) => `[${char}${char.toUpperCase()}]`).join("");
|
|
111
|
+
expect(script, extension).toContain(`*.${bracketed}`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe.skipIf(!hasRg)("plain rg under the generated config", () => {
|
|
116
|
+
const rg = (args: string[], config: boolean): string => {
|
|
117
|
+
const configPath = join(bin, "ripgreprc");
|
|
118
|
+
// rg execs `--pre` directly, and git records bin/ scripts without the executable bit (the image's Dockerfile
|
|
119
|
+
// adds it), so a fresh checkout's copy cannot start: an executable copy stands in for the image's.
|
|
120
|
+
const executable = join(bin, "fileq-rg-pre");
|
|
121
|
+
copyFileSync(PRE, executable);
|
|
122
|
+
chmodSync(executable, 0o755);
|
|
123
|
+
writeFileSync(configPath, `${ripgrepConfigLines(executable).join("\n")}\n`);
|
|
124
|
+
// ripgrep reads an empty RIPGREP_CONFIG_PATH as unset.
|
|
125
|
+
const env = { PATH: process.env["PATH"] ?? "", WORKSPACE_ROOT: root, RIPGREP_CONFIG_PATH: config ? configPath : "" };
|
|
126
|
+
return spawnSync("rg", args, { cwd: root, encoding: "utf8", env }).stdout;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
test("a document hit is cited by path, line and page", async () => {
|
|
130
|
+
await captureCli(app, ["derive", "docs/terms.pdf"]);
|
|
131
|
+
expect(rg(["-n", "wniosek", "docs"], true)).toMatch(/^docs\/terms\.pdf:\d+:\[p\. 2\] Each wniosek must be filed/m);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("code reads exactly as it does without the config", () => {
|
|
135
|
+
expect(rg(["-n", "wniosek", "notes.ts"], true)).toBe(rg(["-n", "wniosek", "notes.ts"], false));
|
|
136
|
+
// Without it, rg sees the file's raw bytes: this fixture's stream happens to be uncompressed, and still no page.
|
|
137
|
+
expect(rg(["-n", "Each wniosek must", "docs"], false)).not.toContain("[p. ");
|
|
138
|
+
});
|
|
139
|
+
});
|