sdocs-dev 1.6.2 → 1.12.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.
@@ -0,0 +1,111 @@
1
+ // Hardcoded list of OS paths that auto-delete files. Two categories:
2
+ //
3
+ // ephemeralRoots() - paths a user might legitimately put work in
4
+ // (`/tmp`, `~/.Trash`). Files here get a *rescue copy* taken into
5
+ // ~/.sdocs/library/rescued/ at index time, because the original is
6
+ // going to vanish on the OS's schedule.
7
+ //
8
+ // throwawayRoots() - OS-managed scratch directories like
9
+ // `os.tmpdir()` on macOS (which resolves to a per-user folder under
10
+ // /var/folders/...). Nothing meaningful lives here long enough to
11
+ // index; it is where Playwright sandboxes, build tools, and other
12
+ // transient processes write their working files. Anything under
13
+ // these roots is skipped entirely at index time - no rescue, no
14
+ // library entry. This is what stops test-run pollution from
15
+ // accumulating in the user's library.
16
+ //
17
+ // Both checks realpath() the input so macOS's /private/var/folders/...
18
+ // alias does not slip through.
19
+
20
+ const os = require('os');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ function homedir() { return os.homedir(); }
25
+
26
+ // Resolve all known forms of a path so the `/private/...` macOS alias
27
+ // is matched the same as the bare form.
28
+ function expandAliases(p) {
29
+ if (!p) return [];
30
+ const out = new Set();
31
+ const resolved = path.resolve(p);
32
+ out.add(resolved);
33
+ try {
34
+ const real = fs.realpathSync(resolved);
35
+ if (real) out.add(real);
36
+ } catch (_) { /* path may not exist; resolved form is enough */ }
37
+ return [...out];
38
+ }
39
+
40
+ function ephemeralRoots() {
41
+ const roots = new Set();
42
+ for (const r of expandAliases(os.tmpdir())) roots.add(r);
43
+
44
+ if (process.platform === 'darwin') {
45
+ roots.add('/tmp');
46
+ roots.add('/private/tmp');
47
+ roots.add('/var/tmp');
48
+ roots.add('/private/var/tmp');
49
+ roots.add(path.join(homedir(), '.Trash'));
50
+ } else if (process.platform === 'linux') {
51
+ roots.add('/tmp');
52
+ roots.add('/var/tmp');
53
+ roots.add('/run');
54
+ roots.add('/dev/shm');
55
+ roots.add(path.join(homedir(), '.cache'));
56
+ } else if (process.platform === 'win32') {
57
+ if (process.env.TEMP) roots.add(path.resolve(process.env.TEMP));
58
+ if (process.env.TMP) roots.add(path.resolve(process.env.TMP));
59
+ roots.add('C:\\Windows\\Temp');
60
+ }
61
+
62
+ return [...roots].filter(Boolean);
63
+ }
64
+
65
+ // OS scratch directories - never index files from here, even with
66
+ // rescue. These are where test runners, build tools, and short-lived
67
+ // processes write their working files; the user does not put meaningful
68
+ // long-lived documents under them.
69
+ function throwawayRoots() {
70
+ const roots = new Set();
71
+ for (const r of expandAliases(os.tmpdir())) roots.add(r);
72
+ if (process.platform === 'darwin') {
73
+ // /var/folders/<u>/<gid>/T is per-user macOS tmp; the /private alias
74
+ // is the realpath, the un-prefixed form is the symlink. Cover both.
75
+ roots.add('/var/folders');
76
+ roots.add('/private/var/folders');
77
+ } else if (process.platform === 'linux') {
78
+ roots.add('/run');
79
+ roots.add('/dev/shm');
80
+ } else if (process.platform === 'win32') {
81
+ if (process.env.TEMP) roots.add(path.resolve(process.env.TEMP));
82
+ if (process.env.TMP) roots.add(path.resolve(process.env.TMP));
83
+ roots.add('C:\\Windows\\Temp');
84
+ }
85
+ return [...roots].filter(Boolean);
86
+ }
87
+
88
+ function pathUnder(absPath, roots) {
89
+ if (!absPath) return false;
90
+ for (const candidate of expandAliases(absPath)) {
91
+ for (const root of roots) {
92
+ if (candidate === root || candidate.startsWith(root + path.sep)) return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function isEphemeralPath(absPath) {
99
+ return pathUnder(absPath, ephemeralRoots());
100
+ }
101
+
102
+ function isThrowawayPath(absPath) {
103
+ return pathUnder(absPath, throwawayRoots());
104
+ }
105
+
106
+ module.exports = {
107
+ ephemeralRoots,
108
+ throwawayRoots,
109
+ isEphemeralPath,
110
+ isThrowawayPath,
111
+ };
@@ -0,0 +1,280 @@
1
+ // Top-level library operations. Brings the modules together:
2
+ // - read a markdown file
3
+ // - extract title, body excerpt, tags, agent metadata
4
+ // - if the file is in an ephemeral location, take a rescue copy
5
+ // - upsert into the index
6
+ //
7
+ // Also handles the YAML front-matter edit when the CLI passes tags.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+
13
+ const SDocYaml = require('../shared/sdocs-yaml.js');
14
+ const SDocLibTags = require('../shared/sdocs-library-tags.js');
15
+
16
+ const store = require('./library-store');
17
+ const scanner = require('./library-scan');
18
+ const ephemeral = require('./library-ephemeral');
19
+ const paths = require('./library-paths');
20
+
21
+ const MAX_EXCERPT = 400;
22
+
23
+ function deriveTitle(meta, body) {
24
+ if (meta && typeof meta.title === 'string' && meta.title.trim()) return meta.title.trim();
25
+ const m = (body || '').match(/^#\s+(.+?)\s*$/m);
26
+ if (m) return m[1];
27
+ return null;
28
+ }
29
+
30
+ function bodyExcerpt(body) {
31
+ if (!body) return '';
32
+ const cleaned = body
33
+ .replace(/```[\s\S]*?```/g, '')
34
+ .replace(/`[^`\n]+`/g, '')
35
+ .replace(/^#{1,6} .*$/gm, '')
36
+ .replace(/\s+/g, ' ')
37
+ .trim();
38
+ if (cleaned.length <= MAX_EXCERPT) return cleaned;
39
+ return cleaned.slice(0, MAX_EXCERPT) + '...';
40
+ }
41
+
42
+ function readFileSafe(absPath) {
43
+ try { return fs.readFileSync(absPath, 'utf8'); } catch (_) { return null; }
44
+ }
45
+
46
+ function detectGitProject(absPath) {
47
+ let dir = path.dirname(absPath);
48
+ for (let i = 0; i < 30; i++) {
49
+ if (fs.existsSync(path.join(dir, '.git'))) {
50
+ let branch = null;
51
+ try {
52
+ const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8').trim();
53
+ const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
54
+ if (m) branch = m[1];
55
+ } catch (_) {}
56
+ return { project: path.basename(dir), root: dir, branch };
57
+ }
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
61
+ }
62
+ return { project: null, root: null, branch: null };
63
+ }
64
+
65
+ function ensureRescueCopy(absPath, content) {
66
+ const dest = path.join(paths.rescuedDir(),
67
+ crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 12) + '-' +
68
+ path.basename(absPath));
69
+ store.ensureDir(path.dirname(dest));
70
+ fs.writeFileSync(dest, content);
71
+ return dest;
72
+ }
73
+
74
+ // Parse, merge tags, and produce a normalised entry from a file's content
75
+ // plus metadata. Pure (no fs writes), so tests can drive it directly.
76
+ function buildEntry({ absPath, content, addTags, stats, rescuedFrom = null }) {
77
+ const parsed = SDocYaml.parseFrontMatter(content || '');
78
+ const meta = parsed.meta || {};
79
+ const body = parsed.body || '';
80
+
81
+ const fmTags = Array.isArray(meta.tags) ? meta.tags : [];
82
+ const tags = SDocLibTags.mergeTags(fmTags, addTags || []);
83
+
84
+ const git = detectGitProject(absPath);
85
+ const sdocsMeta = meta['sdocs-library'] && typeof meta['sdocs-library'] === 'object'
86
+ ? meta['sdocs-library'] : {};
87
+
88
+ return {
89
+ id: store.idForPath(absPath),
90
+ path: absPath,
91
+ rescued: !!rescuedFrom,
92
+ rescuedFrom,
93
+ title: deriveTitle(meta, body) || path.basename(absPath, path.extname(absPath)),
94
+ bodyExcerpt: bodyExcerpt(body),
95
+ body: body,
96
+ tags: tags,
97
+ mtime: stats ? new Date(stats.mtimeMs).toISOString() : new Date().toISOString(),
98
+ size: stats ? stats.size : (content ? Buffer.byteLength(content) : 0),
99
+ gitProject: git.project,
100
+ gitBranch: git.branch,
101
+ agent: typeof meta.agent === 'string' ? meta.agent : sdocsMeta['agent'] || null,
102
+ sessionId: sdocsMeta['session-id'] || null,
103
+ resumeCmd: sdocsMeta['resume-cmd'] || null,
104
+ };
105
+ }
106
+
107
+ // Add tags to a file's `tags:` front matter, write the file back.
108
+ // Returns the new content. If no tags would change the file, returns
109
+ // null (caller can skip the write).
110
+ function injectTagsIntoFile(absPath, addTags) {
111
+ if (!addTags || !addTags.length) return null;
112
+ const raw = readFileSafe(absPath);
113
+ if (raw == null) return null;
114
+ const parsed = SDocYaml.parseFrontMatter(raw);
115
+ const meta = parsed.meta || {};
116
+ const existing = Array.isArray(meta.tags) ? meta.tags : [];
117
+ const merged = SDocLibTags.mergeTags(existing, addTags);
118
+ const same = existing.length === merged.length && existing.every((t, i) => merged[i] === t);
119
+ if (same) return null;
120
+ meta.tags = merged;
121
+ const out = SDocYaml.serializeFrontMatter(meta) + '\n' + (parsed.body || '');
122
+ fs.writeFileSync(absPath, out);
123
+ return out;
124
+ }
125
+
126
+ // Remove tags from a file's front matter. Returns the new tag list or
127
+ // null if the file was missing / had no tags to remove.
128
+ function removeTagsFromFile(absPath, removeTags) {
129
+ if (!removeTags || !removeTags.length) return null;
130
+ const raw = readFileSafe(absPath);
131
+ if (raw == null) return null;
132
+ const parsed = SDocYaml.parseFrontMatter(raw);
133
+ const meta = parsed.meta || {};
134
+ const existing = Array.isArray(meta.tags) ? meta.tags : [];
135
+ if (!existing.length) return null;
136
+ const drop = new Set(removeTags.map(t => String(t).toLowerCase()));
137
+ const next = existing.filter(t => !drop.has(String(t).toLowerCase()));
138
+ if (next.length === existing.length) return null;
139
+ if (next.length) meta.tags = next; else delete meta.tags;
140
+ const out = SDocYaml.serializeFrontMatter(meta) + '\n' + (parsed.body || '');
141
+ fs.writeFileSync(absPath, out);
142
+ return next;
143
+ }
144
+
145
+ // Per-file opt-out: front matter `sdocs-library: false` skips indexing.
146
+ function isOptedOut(content) {
147
+ if (!content) return false;
148
+ const parsed = SDocYaml.parseFrontMatter(content);
149
+ const meta = parsed.meta || {};
150
+ const v = meta['sdocs-library'];
151
+ return v === false || v === 'false';
152
+ }
153
+
154
+ // Add or update a single file. Returns the upserted entry, or null if
155
+ // the file was skipped.
156
+ function indexFile(absPath, { addTags } = {}) {
157
+ const resolved = path.resolve(absPath);
158
+ if (!fs.existsSync(resolved)) return null;
159
+
160
+ // OS scratch directories (macOS /var/folders, Linux /run, Windows
161
+ // %TEMP%) never make it into the library. Test runners and build
162
+ // tools write here constantly; rescue copies would just accumulate.
163
+ // The escape hatch is for the test suite, which legitimately uses
164
+ // mkdtemp under os.tmpdir() to isolate fixtures and needs indexing
165
+ // to work against those paths.
166
+ if (ephemeral.isThrowawayPath(resolved) && !process.env.SDOCS_ALLOW_THROWAWAY_INDEXING) {
167
+ return null;
168
+ }
169
+
170
+ if (addTags && addTags.length) {
171
+ injectTagsIntoFile(resolved, addTags);
172
+ }
173
+
174
+ let content = readFileSafe(resolved);
175
+ if (content == null) return null;
176
+ if (isOptedOut(content)) return null;
177
+
178
+ let rescuedFrom = null;
179
+ let entryPath = resolved;
180
+ if (ephemeral.isEphemeralPath(resolved)) {
181
+ const rescued = ensureRescueCopy(resolved, content);
182
+ rescuedFrom = resolved;
183
+ entryPath = rescued;
184
+ }
185
+
186
+ let stats;
187
+ try { stats = fs.statSync(entryPath); } catch (_) { stats = null; }
188
+
189
+ const entry = buildEntry({
190
+ absPath: entryPath, content, addTags, stats, rescuedFrom,
191
+ });
192
+ return store.upsertEntry(entry);
193
+ }
194
+
195
+ // Drop entries whose underlying file is gone. For rescued entries the
196
+ // rescue copy under ~/.sdocs/library/rescued/ is the source of truth -
197
+ // if that's missing the entry can't be opened, so it's pruned even if
198
+ // rescuedFrom happens to still exist. Returns the count removed.
199
+ function pruneMissing() {
200
+ const idx = store.loadIndex();
201
+ let removed = 0;
202
+ for (const e of idx.entries.slice()) {
203
+ if (!e.path || !fs.existsSync(e.path)) {
204
+ store.removeEntry(e.id);
205
+ removed++;
206
+ }
207
+ }
208
+ return removed;
209
+ }
210
+
211
+ // Bulk scan: walk roots, index each .md found, then prune entries
212
+ // whose files have disappeared since the last scan. Returns counts.
213
+ function scanAndIndex({ roots, excludes, maxFileSize } = {}) {
214
+ const found = scanner.scan({ roots, excludes, maxFileSize });
215
+ let added = 0, updated = 0;
216
+ for (const f of found) {
217
+ const before = store.getEntry(store.idForPath(f.path));
218
+ indexFile(f.path);
219
+ if (before) updated++; else added++;
220
+ }
221
+ const removed = pruneMissing();
222
+ const state = store.loadState();
223
+ state.lastScanAt = Date.now();
224
+ store.saveState(state);
225
+ return { scanned: found.length, added, updated, removed };
226
+ }
227
+
228
+ function rebuild() {
229
+ store.clearIndex();
230
+ return scanAndIndex();
231
+ }
232
+
233
+ // Tags used by entries whose path is under a given prefix (the project
234
+ // path, typically). For the file-info-card autocomplete and `sdoc
235
+ // library ls --tags`. Both the prefix and each entry path are tested
236
+ // against their realpaths too so a symlinked /var on macOS doesn't
237
+ // hide entries from the tag bag.
238
+ function tagsUnderPrefix(prefix) {
239
+ const root = path.resolve(prefix);
240
+ let rootReal = root;
241
+ try { rootReal = fs.realpathSync(root); } catch (_) {}
242
+ // Special case: the filesystem root ('/' on posix, 'C:\' on win) is
243
+ // already its own separator, so `root + path.sep` becomes '//' which
244
+ // matches nothing. Treat it as "everything".
245
+ const rootIsFsRoot = root === path.sep || /^[A-Za-z]:[\\/]$/.test(root);
246
+ const sep = path.sep;
247
+ function under(p) {
248
+ if (rootIsFsRoot) return true;
249
+ if (!p) return false;
250
+ if (p === root || p.startsWith(root + sep)) return true;
251
+ if (rootReal !== root && (p === rootReal || p.startsWith(rootReal + sep))) return true;
252
+ return false;
253
+ }
254
+ const counts = {};
255
+ for (const e of store.loadIndex().entries) {
256
+ const p = e.rescued && e.rescuedFrom ? e.rescuedFrom : e.path;
257
+ let pReal = p;
258
+ try { pReal = fs.realpathSync(p); } catch (_) {}
259
+ if (under(p) || under(pReal)) {
260
+ for (const t of e.tags || []) counts[t] = (counts[t] || 0) + 1;
261
+ }
262
+ }
263
+ return Object.entries(counts)
264
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
265
+ .map(([tag, count]) => ({ tag, count }));
266
+ }
267
+
268
+ module.exports = {
269
+ indexFile,
270
+ pruneMissing,
271
+ scanAndIndex,
272
+ rebuild,
273
+ buildEntry,
274
+ injectTagsIntoFile,
275
+ removeTagsFromFile,
276
+ tagsUnderPrefix,
277
+ isOptedOut,
278
+ bodyExcerpt,
279
+ deriveTitle,
280
+ };
@@ -0,0 +1,20 @@
1
+ // Canonical paths the library subsystem uses. One module so tests can
2
+ // rebind for sandboxing (set SDOCS_HOME to redirect everything into a
3
+ // temp directory).
4
+
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ function root() {
9
+ if (process.env.SDOCS_HOME) return process.env.SDOCS_HOME;
10
+ return path.join(os.homedir(), '.sdocs');
11
+ }
12
+
13
+ module.exports = {
14
+ root,
15
+ libraryDir: () => path.join(root(), 'library'),
16
+ rescuedDir: () => path.join(root(), 'library', 'rescued'),
17
+ indexFile: () => path.join(root(), 'library-index.json'),
18
+ stateFile: () => path.join(root(), 'library-state.json'),
19
+ configFile: () => path.join(root(), 'library.yaml'),
20
+ };
@@ -0,0 +1,258 @@
1
+ // Filesystem walker for the library. Walks one or more roots, returning
2
+ // the absolute paths of .md files that pass the size cap and ignore
3
+ // rules. Pure: takes config and returns files; doesn't touch the index.
4
+ //
5
+ // For v1 we walk everything. Mtime-based shortcuts can come once it
6
+ // proves slow in practice.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const ephemeral = require('./library-ephemeral');
13
+ const paths = require('./library-paths');
14
+
15
+ const DEFAULT_MAX_SIZE = 1 * 1024 * 1024;
16
+
17
+ const DIRNAME_BLOCKLIST = new Set([
18
+ 'node_modules',
19
+ '.git',
20
+ '.svn',
21
+ '.hg',
22
+ 'dist',
23
+ 'build',
24
+ 'vendor',
25
+ '.venv',
26
+ '.next',
27
+ '.cache',
28
+ '__pycache__',
29
+ 'target',
30
+ '.gradle',
31
+ '.idea',
32
+ '.vscode',
33
+ '.DS_Store',
34
+ '.Trash',
35
+ // Directories whose contents are sensitive even when the user
36
+ // happens to have them outside a hidden-dir ancestor. Belt-and-
37
+ // suspenders for the hidden-dir rule above.
38
+ '.ssh',
39
+ '.aws',
40
+ '.gnupg',
41
+ '.docker',
42
+ '.kube',
43
+ '.gcloud',
44
+ '.azure',
45
+ '.bitwarden',
46
+ '.password-store',
47
+ ]);
48
+
49
+ // File basenames that should never make it into the library, regardless
50
+ // of which directory they live in or what extension they carry. Most of
51
+ // these don't have a markdown extension and so the existing extension
52
+ // filter already drops them - but the deny list is the right place for
53
+ // the rule, and is in position for when more extensions get indexed.
54
+ //
55
+ // Crucially, the credentials/secrets patterns require a config-file
56
+ // extension (json/yaml/env/...) - they will NOT match `.md` because
57
+ // markdown is a notes format and "company-secrets.md" or
58
+ // "credentials-handling.md" are legitimate user notes about secrets,
59
+ // not the secrets themselves.
60
+ const DENY_BASENAME_PATTERNS = [
61
+ // SSH private/public keys
62
+ /^id_rsa(\.pub)?$/i,
63
+ /^id_ed25519(\.pub)?$/i,
64
+ /^id_ecdsa(\.pub)?$/i,
65
+ /^id_dsa(\.pub)?$/i,
66
+ /\.ppk$/i,
67
+ // Environment files (.env, .env.local, .env.production, ...)
68
+ /^\.env(\..+)?$/i,
69
+ // Cryptographic material
70
+ /\.(key|pem|p12|pfx|cer|crt|jks|keystore)$/i,
71
+ // PGP / GPG
72
+ /\.(gpg|pgp|asc)$/i,
73
+ // Password databases / wallets
74
+ /\.(kdbx|kdb|agilekeychain|1pif)$/i,
75
+ /^wallet\.dat$/i,
76
+ // Credential files: literal name (no extension - this is how
77
+ // git/aws/gh store them) or with a config / data extension.
78
+ /(^|[._-])credentials$/i,
79
+ /(^|[._-])credentials\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt|sh|properties)$/i,
80
+ // Files literally named secret(s).<config-ext>. Does NOT match
81
+ // `secret.md` or other markdown notes ABOUT secrets.
82
+ /^secrets?\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt|sh|properties)$/i,
83
+ // api_secret.json, api-secret.yaml, apisecret.txt, ...
84
+ /^api[_-]?secret\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt)$/i,
85
+ // Common single-file secrets in $HOME
86
+ /^\.netrc$/i,
87
+ /^\.pgpass$/i,
88
+ /^\.htpasswd$/i,
89
+ ];
90
+
91
+ function systemSkipPaths() {
92
+ const home = os.homedir();
93
+ const out = new Set();
94
+ if (process.platform === 'darwin') {
95
+ out.add('/Applications');
96
+ out.add('/System');
97
+ out.add('/Library');
98
+ out.add('/usr');
99
+ out.add('/private');
100
+ out.add(path.join(home, 'Library'));
101
+ } else if (process.platform === 'linux') {
102
+ out.add('/usr');
103
+ out.add('/var');
104
+ out.add('/etc');
105
+ out.add('/proc');
106
+ out.add('/sys');
107
+ out.add('/boot');
108
+ out.add(path.join(home, '.local'));
109
+ out.add(path.join(home, '.config'));
110
+ } else if (process.platform === 'win32') {
111
+ out.add('C:\\Program Files');
112
+ out.add('C:\\Program Files (x86)');
113
+ out.add('C:\\Windows');
114
+ out.add(path.join(home, 'AppData'));
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // Exported so the agent's file-read and bridge-spawn endpoints can
120
+ // apply the same deny rule on the path they're handed.
121
+ function deniedByPattern(absPath) {
122
+ const base = path.basename(absPath);
123
+ if (DENY_BASENAME_PATTERNS.some(re => re.test(base))) return true;
124
+ const segs = absPath.split(path.sep);
125
+ for (const s of segs) {
126
+ if (DIRNAME_BLOCKLIST.has(s)) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ function shouldSkipDir(absDir, base, skipSet, exemptRoots) {
132
+ if (base.startsWith('.') && base !== '.' && base !== '..') return true;
133
+ if (DIRNAME_BLOCKLIST.has(base)) return true;
134
+ if (skipSet.has(absDir)) return true;
135
+ // Skip ephemeral paths during descent unless we're inside a root that
136
+ // the caller explicitly named (in which case they want it scanned).
137
+ if (ephemeral.isEphemeralPath(absDir) && !insideAnyRoot(absDir, exemptRoots)) return true;
138
+ if (absDir === paths.root() || absDir.startsWith(paths.root() + path.sep)) return true;
139
+ return false;
140
+ }
141
+
142
+ function insideAnyRoot(absDir, roots) {
143
+ if (!roots || !roots.length) return false;
144
+ for (const r of roots) {
145
+ if (absDir === r || absDir.startsWith(r + path.sep)) return true;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ function defaultRoots() {
151
+ return [os.homedir()];
152
+ }
153
+
154
+ // .sdocsignore: per-directory dotfile that excludes files / directories
155
+ // from indexing. Subset of gitignore syntax:
156
+ // - blank lines and lines starting with # are ignored
157
+ // - trailing / means "directory only"
158
+ // - * matches any run of non-/ characters
159
+ // - ** matches across directory boundaries
160
+ // - patterns without a / match against basename only
161
+ // - patterns with a / match against the path relative to the .sdocsignore
162
+ // Negation (!) and other gitignore niceties are not supported in v1.
163
+ function parseSdocsignore(dir) {
164
+ let raw;
165
+ try { raw = fs.readFileSync(path.join(dir, '.sdocsignore'), 'utf8'); }
166
+ catch (_) { return null; }
167
+ const out = [];
168
+ for (let line of raw.split(/\r?\n/)) {
169
+ line = line.trim();
170
+ if (!line || line.startsWith('#')) continue;
171
+ let dirOnly = false;
172
+ if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1); }
173
+ const hasSlash = line.includes('/');
174
+ const re = new RegExp('^' + line
175
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
176
+ .replace(/\*\*/g, '__GLOBSTAR__')
177
+ .replace(/\*/g, '[^/]*')
178
+ .replace(/\?/g, '[^/]')
179
+ .replace(/__GLOBSTAR__/g, '.*') + '$');
180
+ out.push({ re, dirOnly, anchored: hasSlash, dir });
181
+ }
182
+ return out.length ? out : null;
183
+ }
184
+
185
+ function matchesIgnoreStack(stack, absPath, isDir) {
186
+ for (const layer of stack) {
187
+ const rel = path.relative(layer.dir, absPath);
188
+ if (!rel || rel.startsWith('..')) continue; // outside this .sdocsignore's scope
189
+ const base = path.basename(absPath);
190
+ for (const p of layer.patterns) {
191
+ if (p.dirOnly && !isDir) continue;
192
+ const target = p.anchored ? rel : base;
193
+ if (p.re.test(target)) return true;
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+
199
+ // Walk synchronously and accumulate matches. Cheap enough for personal
200
+ // libraries; we'll revisit if a user reports a slow scan.
201
+ function scan({ roots, excludes, maxFileSize } = {}) {
202
+ const rootsToWalk = (roots && roots.length ? roots : defaultRoots()).map(p => path.resolve(p));
203
+ const exSet = new Set((excludes || []).map(p => path.resolve(p)));
204
+ for (const sys of systemSkipPaths()) exSet.add(sys);
205
+ const limit = maxFileSize || DEFAULT_MAX_SIZE;
206
+
207
+ const found = [];
208
+
209
+ function walk(dir, ignoreStack) {
210
+ let entries;
211
+ try {
212
+ entries = fs.readdirSync(dir, { withFileTypes: true });
213
+ } catch (_) {
214
+ return;
215
+ }
216
+ // Pick up .sdocsignore in this directory (cumulative with ancestors).
217
+ const here = parseSdocsignore(dir);
218
+ const stack = here ? ignoreStack.concat([{ dir, patterns: here }]) : ignoreStack;
219
+
220
+ for (const ent of entries) {
221
+ const full = path.join(dir, ent.name);
222
+ if (ent.isSymbolicLink()) continue;
223
+ if (matchesIgnoreStack(stack, full, ent.isDirectory())) continue;
224
+ if (ent.isDirectory()) {
225
+ if (!shouldSkipDir(full, ent.name, exSet, rootsToWalk)) walk(full, stack);
226
+ continue;
227
+ }
228
+ if (!ent.isFile()) continue;
229
+ if (deniedByPattern(full)) continue;
230
+ if (!/\.(md|mdx|markdown)$/i.test(ent.name)) continue;
231
+ let st;
232
+ try { st = fs.statSync(full); } catch (_) { continue; }
233
+ if (st.size > limit) continue;
234
+ found.push({ path: full, mtime: st.mtimeMs, size: st.size });
235
+ }
236
+ }
237
+
238
+ for (const r of rootsToWalk) {
239
+ let st;
240
+ try { st = fs.statSync(r); } catch (_) { continue; }
241
+ if (!st.isDirectory()) continue;
242
+ walk(r, []);
243
+ }
244
+ return found;
245
+ }
246
+
247
+ module.exports = {
248
+ scan,
249
+ defaultRoots,
250
+ systemSkipPaths,
251
+ DIRNAME_BLOCKLIST,
252
+ DENY_BASENAME_PATTERNS,
253
+ DEFAULT_MAX_SIZE,
254
+ shouldSkipDir,
255
+ deniedByPattern,
256
+ parseSdocsignore,
257
+ matchesIgnoreStack,
258
+ };