dotmd-cli 0.82.0 → 0.83.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/bin/dotmd.mjs +11 -5
- package/package.json +1 -1
- package/src/extractors.mjs +2 -1
- package/src/index.mjs +42 -13
- package/src/markdown-code-spans.mjs +21 -10
- package/src/parse-cache.mjs +99 -0
- package/src/pickup.mjs +42 -1
package/bin/dotmd.mjs
CHANGED
|
@@ -1752,12 +1752,18 @@ async function main() {
|
|
|
1752
1752
|
process.stderr.write(`Repo root: ${config.repoRoot}\n`);
|
|
1753
1753
|
}
|
|
1754
1754
|
|
|
1755
|
+
// A printed list shows status, title, age and next step, none of which the
|
|
1756
|
+
// validating passes produce, so it reads the index the way `prompts` and
|
|
1757
|
+
// `hud` do. `--json` emits each document's warnings and errors, so it still
|
|
1758
|
+
// pays for the full pass.
|
|
1759
|
+
const listIndexOptions = listArgs => (listArgs.includes('--json') ? {} : { fast: true });
|
|
1760
|
+
|
|
1755
1761
|
// Preset aliases (user config can override built-in commands below)
|
|
1756
1762
|
if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
|
|
1757
1763
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1758
1764
|
const { runQuery } = await import('../src/query.mjs');
|
|
1759
1765
|
const { statusMetadataFor } = await import('../src/status-metadata.mjs');
|
|
1760
|
-
const index = buildIndex(config);
|
|
1766
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1761
1767
|
applyIndexFilters(index);
|
|
1762
1768
|
const docs = index.docs.filter(doc => {
|
|
1763
1769
|
const metadata = statusMetadataFor(config, doc.type, doc.status);
|
|
@@ -1774,7 +1780,7 @@ async function main() {
|
|
|
1774
1780
|
if (config.presets[command]) {
|
|
1775
1781
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1776
1782
|
const { runQuery } = await import('../src/query.mjs');
|
|
1777
|
-
const index = buildIndex(config);
|
|
1783
|
+
const index = buildIndex(config, listIndexOptions([...config.presets[command], ...restArgs]));
|
|
1778
1784
|
applyIndexFilters(index);
|
|
1779
1785
|
runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
|
|
1780
1786
|
return;
|
|
@@ -1787,7 +1793,7 @@ async function main() {
|
|
|
1787
1793
|
if (command === 'plans') {
|
|
1788
1794
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1789
1795
|
const { runQuery } = await import('../src/query.mjs');
|
|
1790
|
-
const index = buildIndex(config);
|
|
1796
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1791
1797
|
applyIndexFilters(index);
|
|
1792
1798
|
const sub = restArgs[0];
|
|
1793
1799
|
let defaults;
|
|
@@ -1807,7 +1813,7 @@ async function main() {
|
|
|
1807
1813
|
if (command === 'runlists') {
|
|
1808
1814
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1809
1815
|
const { runRunlists } = await import('../src/query.mjs');
|
|
1810
|
-
const index = buildIndex(config);
|
|
1816
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1811
1817
|
applyIndexFilters(index);
|
|
1812
1818
|
runRunlists(index, restArgs, config);
|
|
1813
1819
|
return;
|
|
@@ -1818,7 +1824,7 @@ async function main() {
|
|
|
1818
1824
|
if (command === 'roadmaps') {
|
|
1819
1825
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1820
1826
|
const { runRoadmaps } = await import('../src/roadmap.mjs');
|
|
1821
|
-
const index = buildIndex(config);
|
|
1827
|
+
const index = buildIndex(config, listIndexOptions(restArgs));
|
|
1822
1828
|
applyIndexFilters(index);
|
|
1823
1829
|
runRoadmaps(index, restArgs, config);
|
|
1824
1830
|
return;
|
package/package.json
CHANGED
package/src/extractors.mjs
CHANGED
|
@@ -42,7 +42,8 @@ export function extractNextStep(body) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function extractBodyLinks(body) {
|
|
45
|
-
|
|
45
|
+
// Every inline link contains `](`, and masking never creates one.
|
|
46
|
+
if (!body || !body.includes('](')) return [];
|
|
46
47
|
// Strip fenced code blocks, then MASK inline code rather than delete it.
|
|
47
48
|
// Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
|
|
48
49
|
// has its link TEXT as a code span, so removing the span left `[](plan.md)`,
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
6
6
|
import { findLexicalDocsRoot } from './managed-path.mjs';
|
|
7
|
+
import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
|
|
7
8
|
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
8
9
|
import { checkIndex } from './index-file.mjs';
|
|
9
10
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
@@ -30,7 +31,9 @@ export function buildIndex(config, opts = {}) {
|
|
|
30
31
|
const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
|
|
31
32
|
const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
|
|
32
33
|
const skipWarningOnlyChecks = fast || errorsOnly;
|
|
33
|
-
const
|
|
34
|
+
const cache = openParseCache(config);
|
|
35
|
+
const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast, cache }));
|
|
36
|
+
if (cache && !config._execution?.suppressSideEffects) cache.save();
|
|
34
37
|
if (!fast) {
|
|
35
38
|
// Per-file validation (validateDoc) ran during parse without sibling
|
|
36
39
|
// visibility. Now that the full index is materialized, enrich
|
|
@@ -271,16 +274,45 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
|
|
|
271
274
|
}
|
|
272
275
|
}
|
|
273
276
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
278
|
-
const { frontmatter, body } = extractFrontmatter(raw);
|
|
277
|
+
// Everything parseDocFile takes from the file's text alone, with no config and
|
|
278
|
+
// no clock, which is what the parse cache may keep.
|
|
279
|
+
function extractDocText(frontmatter, body) {
|
|
279
280
|
const fmWarnings = [];
|
|
280
281
|
const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
|
|
281
|
-
|
|
282
|
+
return {
|
|
283
|
+
parsedFrontmatter,
|
|
284
|
+
fmWarnings: fmWarnings.map(w => ({ message: w.message })),
|
|
285
|
+
headingTitle: extractFirstHeading(body),
|
|
286
|
+
bodySummary: extractSummary(body),
|
|
287
|
+
bodyStatusSnapshot: extractStatusSnapshot(body),
|
|
288
|
+
bodyNextStep: extractNextStep(body),
|
|
289
|
+
checklist: extractChecklistCounts(body),
|
|
290
|
+
bodyLinks: extractBodyLinks(body),
|
|
291
|
+
hasCloseout: /^##\s+Closeout/m.test(body),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function parseDocFile(filePath, config, opts = {}) {
|
|
296
|
+
const { fast = false, cache = null } = opts;
|
|
297
|
+
const relativePath = toRepoPath(filePath, config.repoRoot);
|
|
298
|
+
const stamp = cache ? fileStamp(filePath) : null;
|
|
299
|
+
let text = stamp ? cache.get(relativePath, stamp) : null;
|
|
300
|
+
// Validation reads the body itself, so only a fast build can skip the read.
|
|
301
|
+
let body = null;
|
|
302
|
+
if (!text || !fast) {
|
|
303
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
304
|
+
const extracted = extractFrontmatter(raw);
|
|
305
|
+
body = extracted.body;
|
|
306
|
+
// A file rewritten between the stat and the read no longer matches its stamp.
|
|
307
|
+
if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
|
|
308
|
+
if (!text) {
|
|
309
|
+
text = extractDocText(extracted.frontmatter, body);
|
|
310
|
+
if (stamp) cache.set(relativePath, stamp, text);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
|
|
282
314
|
const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
|
|
283
|
-
const summary = asString(parsedFrontmatter.summary) ??
|
|
315
|
+
const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
|
|
284
316
|
// For terminal-status docs (archived / reference / deprecated by default),
|
|
285
317
|
// skip the body-scrape and the "No current_state set" fallback when the user
|
|
286
318
|
// didn't set `current_state:` in frontmatter explicitly. Body text on a
|
|
@@ -305,7 +337,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
305
337
|
} else if (isTerminalDoc) {
|
|
306
338
|
currentState = null;
|
|
307
339
|
} else {
|
|
308
|
-
const scraped =
|
|
340
|
+
const scraped = text.bodyStatusSnapshot;
|
|
309
341
|
if (scraped) {
|
|
310
342
|
currentState = scraped;
|
|
311
343
|
currentStateOrigin = 'body';
|
|
@@ -313,7 +345,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
313
345
|
currentState = 'No current_state set';
|
|
314
346
|
}
|
|
315
347
|
}
|
|
316
|
-
const nextStep = asString(parsedFrontmatter.next_step) ??
|
|
348
|
+
const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
|
|
317
349
|
// `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
|
|
318
350
|
// filing tickets naturally reach for the JIRA/Linear name. If both are set,
|
|
319
351
|
// they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
|
|
@@ -328,9 +360,6 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
328
360
|
const domain = asString(parsedFrontmatter.domain) ?? null;
|
|
329
361
|
const audience = asString(parsedFrontmatter.audience) ?? null;
|
|
330
362
|
const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
|
|
331
|
-
const checklist = extractChecklistCounts(body);
|
|
332
|
-
const bodyLinks = extractBodyLinks(body);
|
|
333
|
-
const hasCloseout = /^##\s+Closeout/m.test(body);
|
|
334
363
|
|
|
335
364
|
// Dynamic reference field extraction. A leading `>` on a value (e.g.
|
|
336
365
|
// `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
function
|
|
2
|
-
return
|
|
3
|
-
+ value.slice(start, end).replace(/[^\n]/g, 'x')
|
|
4
|
-
+ value.slice(end);
|
|
1
|
+
function mask(text) {
|
|
2
|
+
return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
|
|
5
3
|
}
|
|
6
4
|
|
|
7
5
|
// Markdown code spans close only on a backtick run of the same length as their
|
|
@@ -11,13 +9,19 @@ function maskRange(value, start, end) {
|
|
|
11
9
|
// rewriter survive: after an unmatched opener, nothing is treated as prose
|
|
12
10
|
// until a compatible closer appears.
|
|
13
11
|
export function maskInlineCodeLine(line, state = { run: null }) {
|
|
14
|
-
|
|
12
|
+
if (!line.includes('`')) return state.run === null ? line : mask(line);
|
|
13
|
+
|
|
15
14
|
const runs = [...line.matchAll(/`+/g)];
|
|
15
|
+
const findCloser = (from, length) => {
|
|
16
|
+
for (let i = from; i < runs.length; i++) if (runs[i][0].length === length) return i;
|
|
17
|
+
return -1;
|
|
18
|
+
};
|
|
19
|
+
const ranges = [];
|
|
16
20
|
let index = 0;
|
|
17
21
|
|
|
18
22
|
if (state.run !== null) {
|
|
19
|
-
const closingIndex =
|
|
20
|
-
if (closingIndex === -1) return
|
|
23
|
+
const closingIndex = findCloser(0, state.run);
|
|
24
|
+
if (closingIndex === -1) return mask(line);
|
|
21
25
|
const closing = runs[closingIndex];
|
|
22
26
|
ranges.push([0, closing.index + closing[0].length]);
|
|
23
27
|
index = closingIndex + 1;
|
|
@@ -26,8 +30,7 @@ export function maskInlineCodeLine(line, state = { run: null }) {
|
|
|
26
30
|
|
|
27
31
|
for (; index < runs.length; index++) {
|
|
28
32
|
const opening = runs[index];
|
|
29
|
-
const closingIndex =
|
|
30
|
-
candidateIndex > index && candidate[0].length === opening[0].length);
|
|
33
|
+
const closingIndex = findCloser(index + 1, opening[0].length);
|
|
31
34
|
if (closingIndex === -1) {
|
|
32
35
|
ranges.push([opening.index, line.length]);
|
|
33
36
|
state.run = opening[0].length;
|
|
@@ -38,7 +41,15 @@ export function maskInlineCodeLine(line, state = { run: null }) {
|
|
|
38
41
|
index = closingIndex;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
// Ranges are ascending and disjoint, so one left-to-right pass builds the
|
|
45
|
+
// result instead of re-copying the whole line once per span.
|
|
46
|
+
let out = '';
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
for (const [start, end] of ranges) {
|
|
49
|
+
out += line.slice(cursor, start) + mask(line.slice(start, end));
|
|
50
|
+
cursor = end;
|
|
51
|
+
}
|
|
52
|
+
return out + line.slice(cursor);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export function maskInlineCodeSpans(value) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { commitRename } from './durable-rename.mjs';
|
|
5
|
+
import { readEnv, stateDir } from './naming.mjs';
|
|
6
|
+
|
|
7
|
+
// What every build of the index used to redo from scratch: read each document
|
|
8
|
+
// and pull its frontmatter, links, checklist and summary out of the body. None
|
|
9
|
+
// of that depends on the config or the clock, so it is kept per file under the
|
|
10
|
+
// state directory and reused while the file's size, times and inode are
|
|
11
|
+
// unchanged. Everything that does depend on them (terminal statuses, staleness,
|
|
12
|
+
// reference fields, validation) is still computed on every run.
|
|
13
|
+
//
|
|
14
|
+
// One line per document, `path \t stamp \t json`, so a hit parses only its own
|
|
15
|
+
// line and hands back fresh objects: nothing a caller does to a document can
|
|
16
|
+
// leak into what is saved. The cache is an optimisation and never an input:
|
|
17
|
+
// a missing, unreadable or foreign-version file is treated as empty, a failed
|
|
18
|
+
// write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
|
|
19
|
+
|
|
20
|
+
const SCHEMA = 1;
|
|
21
|
+
const FILE_NAME = 'parse-cache';
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const VERSION = (() => {
|
|
24
|
+
try { return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
|
|
25
|
+
catch { return 'unknown'; }
|
|
26
|
+
})();
|
|
27
|
+
const HEADER = JSON.stringify({ schema: SCHEMA, version: VERSION });
|
|
28
|
+
|
|
29
|
+
export function fileStamp(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const s = statSync(filePath, { bigint: true });
|
|
32
|
+
return `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function stampSize(stamp) {
|
|
39
|
+
return Number(stamp.slice(0, stamp.indexOf(':')));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openParseCache(config) {
|
|
43
|
+
if (readEnv('NO_PARSE_CACHE') === '1') return null;
|
|
44
|
+
const dir = stateDir(config.repoRoot);
|
|
45
|
+
// The state directory is created, and ignored by git, by `init` and the
|
|
46
|
+
// commands that own it; a repo without one gets no cache rather than a new
|
|
47
|
+
// untracked folder from a read-only command.
|
|
48
|
+
if (!existsSync(dir)) return null;
|
|
49
|
+
const cachePath = path.join(dir, FILE_NAME);
|
|
50
|
+
const lines = new Map();
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync(cachePath, 'utf8');
|
|
53
|
+
const rows = text.split('\n');
|
|
54
|
+
if (rows[0] === HEADER) {
|
|
55
|
+
for (let i = 1; i < rows.length; i++) {
|
|
56
|
+
const row = rows[i];
|
|
57
|
+
const a = row.indexOf('\t');
|
|
58
|
+
const b = row.indexOf('\t', a + 1);
|
|
59
|
+
if (a < 0 || b < 0) continue;
|
|
60
|
+
lines.set(row.slice(0, a), { stamp: row.slice(a + 1, b), row });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* no cache yet */ }
|
|
64
|
+
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
let dirty = false;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
get(key, stamp) {
|
|
70
|
+
seen.add(key);
|
|
71
|
+
const entry = lines.get(key);
|
|
72
|
+
if (!entry || entry.stamp !== stamp) return null;
|
|
73
|
+
try { return JSON.parse(entry.row.slice(entry.row.indexOf('\t', entry.row.indexOf('\t') + 1) + 1)); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
},
|
|
76
|
+
set(key, stamp, value) {
|
|
77
|
+
seen.add(key);
|
|
78
|
+
if (/[\t\n]/.test(key)) return;
|
|
79
|
+
lines.set(key, { stamp, row: `${key}\t${stamp}\t${JSON.stringify(value)}` });
|
|
80
|
+
dirty = true;
|
|
81
|
+
},
|
|
82
|
+
save({ complete = true } = {}) {
|
|
83
|
+
if (complete) {
|
|
84
|
+
for (const key of lines.keys()) {
|
|
85
|
+
if (!seen.has(key)) { lines.delete(key); dirty = true; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!dirty) return;
|
|
89
|
+
const temp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(temp, [HEADER, ...[...lines.values()].map(entry => entry.row)].join('\n'), 'utf8');
|
|
92
|
+
commitRename(temp, cachePath);
|
|
93
|
+
dirty = false;
|
|
94
|
+
} catch {
|
|
95
|
+
try { unlinkSync(temp); } catch { /* already gone */ }
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/pickup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -233,7 +233,48 @@ function validateBinding(record, identity, config) {
|
|
|
233
233
|
return record;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Working out a plan's canonical identity lists every directory on its path,
|
|
237
|
+
// three times over, and listing plans asks it of every plan in the repo. A
|
|
238
|
+
// record can only belong to a plan whose file name it carries, so the names in
|
|
239
|
+
// the ownership folder rule most plans out first. The summary is rebuilt
|
|
240
|
+
// whenever the folder changes, and any record it cannot read turns the check
|
|
241
|
+
// off, so the full identity check still decides every case it could get wrong.
|
|
242
|
+
let ownershipNamesCache = null;
|
|
243
|
+
|
|
244
|
+
function ownershipNames(config) {
|
|
245
|
+
const dir = ownershipRoot(config);
|
|
246
|
+
let stamp;
|
|
247
|
+
try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
|
|
248
|
+
catch { return new Set(); }
|
|
249
|
+
if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
|
|
250
|
+
let names = new Set();
|
|
251
|
+
try {
|
|
252
|
+
for (const entry of readdirSync(dir)) {
|
|
253
|
+
if (!entry.endsWith('.json')) continue;
|
|
254
|
+
const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
|
|
255
|
+
if (record.corrupt) { names = null; break; }
|
|
256
|
+
names.add(path.basename(record.canonicalPath).toLowerCase());
|
|
257
|
+
names.add(path.basename(record.plan).toLowerCase());
|
|
258
|
+
}
|
|
259
|
+
} catch { names = null; }
|
|
260
|
+
ownershipNamesCache = { stamp, names };
|
|
261
|
+
return names;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mayHaveOwnershipRecord(absolutePath, config) {
|
|
265
|
+
const names = ownershipNames(config);
|
|
266
|
+
if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
|
|
267
|
+
try {
|
|
268
|
+
if (lstatSync(absolutePath).isSymbolicLink()) return true;
|
|
269
|
+
// A record whose fields were rewritten still sits at its identity's file
|
|
270
|
+
// name, and has to be found to be reported corrupt.
|
|
271
|
+
const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
|
|
272
|
+
return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
|
|
273
|
+
} catch { return true; }
|
|
274
|
+
}
|
|
275
|
+
|
|
236
276
|
export function readPlanOwnership(repoPath, config) {
|
|
277
|
+
if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
|
|
237
278
|
let identity;
|
|
238
279
|
try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
|
|
239
280
|
catch { return null; }
|