projectkeeper 0.0.0-stage → 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's own ignore rules, as git itself judges them (Spec §1.1; CKC-04 AC-17): what git leaves out
|
|
3
|
+
* (`git ls-files --others --ignored --exclude-standard --directory`) and the rule that does it — which ignore file,
|
|
4
|
+
* which line, which pattern (`git check-ignore -v`). A directory without version control has no ignore rules; a
|
|
5
|
+
* `.gitignore` there is just a file.
|
|
6
|
+
*
|
|
7
|
+
* Read-only: every call goes through `git()` (no optional locks, so not even the index's timestamps are refreshed).
|
|
8
|
+
*/
|
|
9
|
+
import { git } from '../util/git.ts';
|
|
10
|
+
import { nameForm } from '../util/paths.ts';
|
|
11
|
+
|
|
12
|
+
export interface IgnoreRule {
|
|
13
|
+
readonly file: string;
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly pattern: string;
|
|
16
|
+
}
|
|
17
|
+
export interface IgnoredEntry {
|
|
18
|
+
/** Relative to the root the listing ran in, forward slashes, no trailing slash. */
|
|
19
|
+
readonly rel: string;
|
|
20
|
+
readonly dir: boolean;
|
|
21
|
+
readonly rule: IgnoreRule | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LONG = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Paths git leaves out under `root`, whole directories collapsed; null when `root` is not in a git work tree. */
|
|
27
|
+
function listIgnored(root: string): string[] | null {
|
|
28
|
+
const r = git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], LONG);
|
|
29
|
+
if (!r.ok) return null;
|
|
30
|
+
return r.out.split('\0').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `"a\tb"` → a<TAB>b: git quotes a path with control characters, quotes or backslashes even with quotePath off. */
|
|
34
|
+
function unquote(path: string): string {
|
|
35
|
+
if (!(path.startsWith('"') && path.endsWith('"'))) return path;
|
|
36
|
+
const body = path.slice(1, -1);
|
|
37
|
+
const bytes: number[] = [];
|
|
38
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
39
|
+
const c = body[i]!;
|
|
40
|
+
if (c !== '\\') { bytes.push(...Buffer.from(c, 'utf8')); continue; }
|
|
41
|
+
const n = body[i + 1] ?? '';
|
|
42
|
+
if (/[0-7]/.test(n)) { bytes.push(parseInt(body.slice(i + 1, i + 4), 8)); i += 3; continue; }
|
|
43
|
+
bytes.push(({ t: 9, n: 10, r: 13, '"': 34, '\\': 92, a: 7, b: 8, f: 12, v: 11 } as Record<string, number>)[n] ?? n.charCodeAt(0));
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
return Buffer.from(bytes).toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The rule that leaves each path out, from `git check-ignore -v`. Paths are passed in batches to stay within the command line. */
|
|
50
|
+
export function ignoreRules(root: string, paths: readonly string[]): Map<string, IgnoreRule> {
|
|
51
|
+
const out = new Map<string, IgnoreRule>();
|
|
52
|
+
let batch: string[] = [];
|
|
53
|
+
let size = 0;
|
|
54
|
+
const flush = () => {
|
|
55
|
+
if (batch.length === 0) return;
|
|
56
|
+
const r = git(root, ['-c', 'core.quotePath=false', 'check-ignore', '-v', '--', ...batch], LONG);
|
|
57
|
+
for (const line of r.out.split(/\r?\n/)) {
|
|
58
|
+
const m = /^(.+?):(\d+):(.*)\t(.+)$/.exec(line);
|
|
59
|
+
if (!m || m[3]!.startsWith('!')) continue; // a negated pattern keeps the path in
|
|
60
|
+
out.set(unquote(m[4]!).replace(/\/$/, ''), { file: m[1]!, line: Number(m[2]), pattern: m[3]! });
|
|
61
|
+
}
|
|
62
|
+
batch = [];
|
|
63
|
+
size = 0;
|
|
64
|
+
};
|
|
65
|
+
for (const p of paths) {
|
|
66
|
+
batch.push(p);
|
|
67
|
+
size += p.length + 3;
|
|
68
|
+
if (batch.length >= 200 || size > 6000) flush();
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What the ignore rules leave out under `root`, each with its rule; null without version control. A directory git lists
|
|
76
|
+
* because every file in it is ignored (no pattern names the directory itself) takes the rule of its files.
|
|
77
|
+
*/
|
|
78
|
+
export function ignoredEntries(root: string): IgnoredEntry[] | null {
|
|
79
|
+
const listed = listIgnored(root);
|
|
80
|
+
if (listed === null) return null;
|
|
81
|
+
const dirs = listed.filter((p) => p.endsWith('/')).map((p) => p.slice(0, -1));
|
|
82
|
+
const inDir = (f: string) => dirs.some((d) => f.startsWith(`${d}/`));
|
|
83
|
+
const files = listed.filter((p) => !p.endsWith('/'));
|
|
84
|
+
const scattered = files.filter((f) => !inDir(f));
|
|
85
|
+
const rules = ignoreRules(root, [...dirs.map((d) => `${d}/`), ...scattered]);
|
|
86
|
+
const missing = dirs.filter((d) => !rules.has(d));
|
|
87
|
+
const byFile = missing.length ? ignoreRules(root, files.filter((f) => missing.some((d) => f.startsWith(`${d}/`)))) : new Map<string, IgnoreRule>();
|
|
88
|
+
return [
|
|
89
|
+
...dirs.map((d) => ({ rel: d, dir: true, rule: rules.get(d) ?? [...byFile].find(([f]) => f.startsWith(`${d}/`))?.[1] ?? null })),
|
|
90
|
+
...scattered.map((f) => ({ rel: f, dir: false, rule: rules.get(f) ?? null })),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A predicate for a walk under `root`: is this relative path (forward slashes) left out by the ignore rules? Null
|
|
96
|
+
* without version control.
|
|
97
|
+
*/
|
|
98
|
+
export function ignoredPredicate(root: string): ((rel: string) => boolean) | null {
|
|
99
|
+
const listed = listIgnored(root);
|
|
100
|
+
if (listed === null) return null;
|
|
101
|
+
return predicateOf(listed.filter((p) => p.endsWith('/')).map((p) => p.slice(0, -1)), listed.filter((p) => !p.endsWith('/')));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The same predicate from entries already listed. */
|
|
105
|
+
export function predicateFrom(entries: readonly IgnoredEntry[]): (rel: string) => boolean {
|
|
106
|
+
return predicateOf(entries.filter((e) => e.dir).map((e) => e.rel), entries.filter((e) => !e.dir).map((e) => e.rel));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function predicateOf(dirList: readonly string[], fileList: readonly string[]): (rel: string) => boolean {
|
|
110
|
+
// Both sides in the form names are kept in: what git lists and what a walk asks about are compared, nothing more.
|
|
111
|
+
const dirs = new Set(dirList.map((d) => nameForm(d)));
|
|
112
|
+
const files = new Set(fileList.map((f) => nameForm(f)));
|
|
113
|
+
return (rel: string) => {
|
|
114
|
+
const p = nameForm(rel).split('\\').join('/');
|
|
115
|
+
if (files.has(p) || dirs.has(p)) return true;
|
|
116
|
+
const segs = p.split('/');
|
|
117
|
+
for (let i = 1; i < segs.length; i += 1) if (dirs.has(segs.slice(0, i).join('/'))) return true;
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One path, asked of git directly (the watcher's check for a file it has not seen). */
|
|
123
|
+
export function isIgnored(root: string, rel: string): boolean {
|
|
124
|
+
const r = git(root, ['check-ignore', '-q', '--', rel.split('\\').join('/')]);
|
|
125
|
+
return r.ok;
|
|
126
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A location that lies in a place the project's own ignore rules leave out (Spec §1.1, D105; CKC-04 AC-17, CKC-07
|
|
3
|
+
* AC-10): the usual case is a worker's worktree under an ignored directory (`.worktrees/K-cq` in a repository whose
|
|
4
|
+
* `.gitignore` says `.worktrees/`). Its working files are not pending material — a change in them is not listed, starts
|
|
5
|
+
* no round and is in no Follow up — while its branch and commits still reach the ledger from version control, and its
|
|
6
|
+
* commits the trunk does not have still wait like any commit.
|
|
7
|
+
*
|
|
8
|
+
* The scan and the watcher already ask a location's own repository about each file (./ignore.ts); what they did not ask
|
|
9
|
+
* is whether the location itself sits where a repository around it ignores. That is asked here, of git, for every
|
|
10
|
+
* repository in scope that holds the location, nested ones included — outward until a repository of its own is reached:
|
|
11
|
+
* a nested repository belongs by its own history (§1.1), so the rules of the one around it stop at it, and a nested
|
|
12
|
+
* repository the outer one ignores (`/app/`) is still read. Not left out: what the owner took in, what the Keeper judged
|
|
13
|
+
* the project's material (`overridesIgnoreRules`), and a place kept for recovery only (read as history, D82).
|
|
14
|
+
*
|
|
15
|
+
* Read-only: `git check-ignore` through `git()`.
|
|
16
|
+
*/
|
|
17
|
+
import type { ScopeItem } from '../model/types.ts';
|
|
18
|
+
import { isWithin, pathKey, relativeDisplay } from '../util/paths.ts';
|
|
19
|
+
import { ignoreRules, type IgnoreRule } from './ignore.ts';
|
|
20
|
+
import { overridesIgnoreRules, treatmentOf } from './skip.ts';
|
|
21
|
+
|
|
22
|
+
export interface IgnoredPlace {
|
|
23
|
+
/** The rule that leaves the location out. */
|
|
24
|
+
readonly rule: IgnoreRule;
|
|
25
|
+
/** The scope item whose repository the rule belongs to. */
|
|
26
|
+
readonly by: ScopeItem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What git said about a location, kept a short while: the watcher asks for every file event, the coverage at every pass. */
|
|
30
|
+
const asked = new Map<string, { at: number; rule: IgnoreRule | null }>();
|
|
31
|
+
const KEEP_MS = 20_000;
|
|
32
|
+
|
|
33
|
+
/** Forget what git said (a test that changes an ignore file within the keep time). */
|
|
34
|
+
export function forgetIgnoredPlaces(): void { asked.clear(); }
|
|
35
|
+
|
|
36
|
+
function ruleFor(root: string, rel: string, now: number): IgnoreRule | null {
|
|
37
|
+
const key = `${pathKey(root)}\n${rel}`;
|
|
38
|
+
const had = asked.get(key);
|
|
39
|
+
if (had && now - had.at < KEEP_MS) return had.rule;
|
|
40
|
+
const rule = ignoreRules(root, [rel]).get(rel) ?? null;
|
|
41
|
+
asked.set(key, { at: now, rule });
|
|
42
|
+
return rule;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const locational = (scope: readonly ScopeItem[]) => scope.filter((i) => i.category !== 'Session source');
|
|
46
|
+
|
|
47
|
+
/** Its own choice to take in what the rules leave out, or a place read as history: the rules around it do not apply. */
|
|
48
|
+
const standsApart = (scope: readonly ScopeItem[], item: ScopeItem) => overridesIgnoreRules(scope, item) || treatmentOf(item) === 'history';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The ignore rule of a repository in scope that leaves this item's own location out, or null. A repository of its own is
|
|
52
|
+
* never left out this way; the walk outward stops at the first repository, and at an item the owner took in.
|
|
53
|
+
*/
|
|
54
|
+
export function ignoredPlaceOf(scope: readonly ScopeItem[], item: ScopeItem, now = Date.now()): IgnoredPlace | null {
|
|
55
|
+
if (item.category === 'Session source' || item.category === 'Repository' || standsApart(scope, item)) return null;
|
|
56
|
+
const around = locational(scope)
|
|
57
|
+
.filter((o) => o !== item && !o.missing && o.path.length < item.path.length && isWithin(o.path, item.path))
|
|
58
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
59
|
+
for (const o of around) {
|
|
60
|
+
if (standsApart(scope, o)) return null;
|
|
61
|
+
if (o.versionControl !== 'none') {
|
|
62
|
+
const rule = ruleFor(o.path, relativeDisplay(o.path, item.path), now);
|
|
63
|
+
if (rule) return { rule, by: o };
|
|
64
|
+
}
|
|
65
|
+
if (o.category === 'Repository') return null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* For many paths at once (the coverage's list of what waits): the ignored place a file lies in, or null. Each scope item
|
|
72
|
+
* is asked about once.
|
|
73
|
+
*/
|
|
74
|
+
export function ignoredPlaceLookup(scope: readonly ScopeItem[]): (path: string) => IgnoredPlace | null {
|
|
75
|
+
const deepestFirst = locational(scope).sort((a, b) => b.path.length - a.path.length);
|
|
76
|
+
// Only an item that is no repository of its own, inside another location, can lie in an ignored place.
|
|
77
|
+
const candidates = deepestFirst.filter((i) => i.category !== 'Repository' && deepestFirst.some((o) => o !== i && o.path.length < i.path.length && isWithin(o.path, i.path)));
|
|
78
|
+
if (candidates.length === 0) return () => null;
|
|
79
|
+
const byItem = new Map<string, IgnoredPlace | null>();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
return (path: string) => {
|
|
82
|
+
const item = deepestFirst.find((i) => isWithin(i.path, path));
|
|
83
|
+
if (!item || !candidates.includes(item)) return null;
|
|
84
|
+
let place = byItem.get(item.id);
|
|
85
|
+
if (place === undefined) { place = ignoredPlaceOf(scope, item, now); byItem.set(item.id, place); }
|
|
86
|
+
return place;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `.gitignore line 2: .worktrees/` — the rule as the scope list names it. */
|
|
91
|
+
export function ruleText(rule: IgnoreRule): string {
|
|
92
|
+
return `${rule.file} line ${rule.line}: ${rule.pattern}`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one skip list (Spec §1.1; CKC-04 AC-13, AC-17): which directories are never walked as project material, which
|
|
3
|
+
* names are build output, caches or installed dependencies, which directories projects keep vendored code in, which
|
|
4
|
+
* files are documents, and what a scope item's relation means for what is read from it. Scope discovery, the file
|
|
5
|
+
* scan and the watcher read all of it from here; the organizing code should too (takeover.ts and materials.ts still
|
|
6
|
+
* keep their own path patterns and move here when their owner next touches them).
|
|
7
|
+
*
|
|
8
|
+
* A name here is a candidate, not a verdict: discovery lists what it finds with the reason, the Keeper judges it
|
|
9
|
+
* (`pk_classify_scope`) and the owner can correct it. The project's own ignore rules are in ./ignore.ts; git decides
|
|
10
|
+
* those, not this list.
|
|
11
|
+
*/
|
|
12
|
+
import { extname } from 'node:path';
|
|
13
|
+
import type { ScopeItem } from '../model/types.ts';
|
|
14
|
+
import type { UsedAs } from '../model/vocab.ts';
|
|
15
|
+
import { isWithin } from '../util/paths.ts';
|
|
16
|
+
|
|
17
|
+
/** Version-control internals, and the usual home of worktrees (each registered worktree is measured on its own). */
|
|
18
|
+
export const NEVER_WALKED: ReadonlySet<string> = new Set(['.git', '.hg', '.svn', '.worktrees']);
|
|
19
|
+
|
|
20
|
+
/** Usual names of what a build or a tool writes, with the kind shown as the reason. Compared without regard to case. */
|
|
21
|
+
const GENERATED: Readonly<Record<string, string>> = {
|
|
22
|
+
build: 'build output', dist: 'build output', out: 'build output', target: 'build output', deriveddata: 'build output',
|
|
23
|
+
cmakefiles: 'build output', '.eggs': 'build output',
|
|
24
|
+
'.gradle': 'build cache', '.next': 'build cache', '.nuxt': 'build cache', '.svelte-kit': 'build cache',
|
|
25
|
+
'.angular': 'build cache', '.turbo': 'build cache', '.parcel-cache': 'build cache', '.cache': 'cache', '__pycache__': 'cache',
|
|
26
|
+
'.pytest_cache': 'cache', '.mypy_cache': 'cache', '.ruff_cache': 'cache', '.tox': 'cache', coverage: 'test coverage output',
|
|
27
|
+
'.nyc_output': 'test coverage output', '.playwright-cli': 'tool output',
|
|
28
|
+
};
|
|
29
|
+
/** Where package managers install other people's code. Not distributed with the project; nothing in it is read. */
|
|
30
|
+
const DEPENDENCIES: ReadonlySet<string> = new Set(['node_modules', 'bower_components', 'jspm_packages', '.venv', 'venv', 'site-packages']);
|
|
31
|
+
/** Usual names of directories a project keeps vendored third-party code in; their documents are read as Reference only. */
|
|
32
|
+
const VENDORED: ReadonlySet<string> = new Set(['third_party', 'third-party', 'thirdparty', 'vendor', 'vendors', 'external', 'externals', 'extern']);
|
|
33
|
+
|
|
34
|
+
/** Third-party material of these kinds is not read at all: installed dependencies, and links to places outside the project. */
|
|
35
|
+
export const NOT_READ_KINDS: ReadonlySet<string> = new Set(['installed dependencies', 'linked in']);
|
|
36
|
+
|
|
37
|
+
export interface NameClass {
|
|
38
|
+
readonly relation: 'Generated' | 'Third-party material';
|
|
39
|
+
readonly kind: string;
|
|
40
|
+
readonly evidence: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a directory's name alone suggests, or null. */
|
|
44
|
+
export function classifyName(name: string): NameClass | null {
|
|
45
|
+
const n = name.toLowerCase();
|
|
46
|
+
if (GENERATED[n]) return { relation: 'Generated', kind: GENERATED[n]!, evidence: `${name}/ is a usual name for ${GENERATED[n]}` };
|
|
47
|
+
if (DEPENDENCIES.has(n)) return { relation: 'Third-party material', kind: 'installed dependencies', evidence: `${name}/ is where a package manager installs dependencies` };
|
|
48
|
+
if (VENDORED.has(n)) return { relation: 'Third-party material', kind: 'vendored code', evidence: `${name}/ is a usual name for vendored third-party code` };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A walk that has no scope item telling it otherwise does not enter these: version control, build output, dependencies, vendored code. */
|
|
53
|
+
export function isSkippedName(name: string): boolean {
|
|
54
|
+
return NEVER_WALKED.has(name.toLowerCase()) || classifyName(name) !== null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A directory of vendored code holds libraries worth telling apart (each with its own upstream and license). */
|
|
58
|
+
export function isVendoredName(name: string): boolean {
|
|
59
|
+
return VENDORED.has(name.toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether an item takes in what the project's ignore rules leave out: the owner chose to include it (an answer to the
|
|
64
|
+
* scope question, a correction, or a location added by hand inside the project), or the Keeper judged it the project's
|
|
65
|
+
* material. The project's own locations are not such a choice: the rules apply inside them.
|
|
66
|
+
*/
|
|
67
|
+
export function overridesIgnoreRules(scope: readonly ScopeItem[], item: ScopeItem): boolean {
|
|
68
|
+
if (item.relation === 'Excluded') return false;
|
|
69
|
+
if (item.ignoredBy != null || (item.classification != null && item.classification.by !== 'program')) return true;
|
|
70
|
+
return item.addedBy === 'owner' && scope.some((o) => o !== item && o.category !== 'Session source' && o.path.length < item.path.length && isWithin(o.path, item.path));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The first segment of a relative path that a walk would not enter, or null. */
|
|
74
|
+
export function skippedSegment(relPath: string): string | null {
|
|
75
|
+
for (const seg of relPath.split(/[\\/]/)) if (seg && isSkippedName(seg)) return seg;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DOC_EXT: ReadonlySet<string> = new Set(['.md', '.markdown', '.mdx', '.txt', '.rst', '.adoc', '.asciidoc', '.org']);
|
|
80
|
+
const DOC_NAMES: ReadonlySet<string> = new Set(['readme', 'license', 'licence', 'copying', 'notice', 'changelog', 'authors', 'contributing']);
|
|
81
|
+
/** A license file at the top of a directory: the directory brings its own terms. */
|
|
82
|
+
export const LICENSE_FILE = /^(licen[cs]e|copying)(\.(md|txt|rst))?$/i;
|
|
83
|
+
|
|
84
|
+
/** Text a person wrote — notes, plans, READMEs, licenses — as opposed to code, data or binaries. */
|
|
85
|
+
export function isDocumentPath(path: string): boolean {
|
|
86
|
+
const base = path.split(/[\\/]/).pop() ?? '';
|
|
87
|
+
if (DOC_EXT.has(extname(base).toLowerCase())) return true;
|
|
88
|
+
return DOC_NAMES.has(base.toLowerCase().replace(/\.[^.]*$/, '')) && !/\.(js|ts|json|py|sh|html|css)$/i.test(base);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What is read from a scope item (Spec §1.1):
|
|
93
|
+
* - `read`: everything a walk does not skip;
|
|
94
|
+
* - `documents`: third-party material — only its documents, as `Reference only`;
|
|
95
|
+
* - `changes`: a worktree registered under a repository in scope — only what it adds to the trunk (E60);
|
|
96
|
+
* - `history`: a location the project keeps for recovery only — read so it can be cited, never current material
|
|
97
|
+
* (its `Used as` follows the project's rule). The rule changes how what is read counts, not what is read (D1): what
|
|
98
|
+
* the ignore rules leave out, or a judgement about the location (the Keeper's, the owner's, the program's reading of
|
|
99
|
+
* it as build output or someone else's code) left out, stays unread; the program's own guess from a folder's name
|
|
100
|
+
* that it holds deleted material (trash/ and the like) gives way to the project's rule, which says what it holds;
|
|
101
|
+
* - `none`: generated output, what the ignore rules leave out, what the owner excluded.
|
|
102
|
+
*/
|
|
103
|
+
export type Treatment = 'read' | 'documents' | 'changes' | 'history' | 'none';
|
|
104
|
+
|
|
105
|
+
const recoveryOnly = (item: ScopeItem) => (item.coveredBy ?? []).some((c) => c.category === 'Recovery only');
|
|
106
|
+
/** Left out by the project's ignore rules or by a judgement about the location, not by a Recovery only rule or a folder's name. */
|
|
107
|
+
const leftOutByDecision = (item: ScopeItem) => item.ignoredBy != null || item.classification != null;
|
|
108
|
+
const registeredWorktree = (item: ScopeItem) => item.category === 'Worktree' && (item.relation === 'Worktree of main repo' || item.worktree != null);
|
|
109
|
+
|
|
110
|
+
export function treatmentOf(item: ScopeItem): Treatment {
|
|
111
|
+
if (item.category === 'Session source' || item.missing) return 'none';
|
|
112
|
+
if (item.relation === 'Excluded') return item.addedBy === 'keeper' && recoveryOnly(item) && !leftOutByDecision(item) ? 'history' : 'none';
|
|
113
|
+
if (item.relation === 'Generated') return 'none';
|
|
114
|
+
if (item.relation === 'Third-party material') return NOT_READ_KINDS.has(item.classification?.kind ?? '') ? 'none' : 'documents';
|
|
115
|
+
if (registeredWorktree(item)) return 'changes';
|
|
116
|
+
return 'read';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Items that stand for a location on disk (not session sources), deepest first. */
|
|
120
|
+
const locational = (scope: readonly ScopeItem[]) => scope.filter((i) => i.category !== 'Session source').sort((a, b) => b.path.length - a.path.length);
|
|
121
|
+
|
|
122
|
+
/** The deepest scope item whose location holds `path`. */
|
|
123
|
+
export function innermostItem(scope: readonly ScopeItem[], path: string): ScopeItem | undefined {
|
|
124
|
+
return locational(scope).find((i) => isWithin(i.path, path));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a scope item is read at all, given the items above it: under a location that is left out, nothing is read,
|
|
129
|
+
* except what the owner added by hand and the worktrees registered under the repository (each measured on its own).
|
|
130
|
+
*/
|
|
131
|
+
export function isReadRoot(scope: readonly ScopeItem[], item: ScopeItem): boolean {
|
|
132
|
+
if (treatmentOf(item) === 'none') return false;
|
|
133
|
+
if (item.addedBy === 'owner' || registeredWorktree(item)) return true;
|
|
134
|
+
return !locational(scope).some((o) => o !== item && o.path.length < item.path.length && isWithin(o.path, item.path) && treatmentOf(o) === 'none');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How a file is read given the scope: which item it belongs to and the `Used as` its location sets, if any. */
|
|
138
|
+
export function readingOf(scope: readonly ScopeItem[], path: string): { item: ScopeItem | undefined; treatment: Treatment; usedAs: { value: UsedAs; by: 'keeper' | 'owner'; scopeItemId: string } | null } {
|
|
139
|
+
const item = innermostItem(scope, path);
|
|
140
|
+
if (!item) return { item, treatment: 'read', usedAs: null };
|
|
141
|
+
const treatment = treatmentOf(item);
|
|
142
|
+
const usedAs = treatment === 'documents' && isDocumentPath(path)
|
|
143
|
+
? { value: 'Reference only' as const, by: item.classification?.by === 'owner' ? 'owner' as const : 'keeper' as const, scopeItemId: item.id }
|
|
144
|
+
: null;
|
|
145
|
+
return { item, treatment, usedAs };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Commits of this item are taken as sources: the project's repositories and the worktrees that add something to the trunk. */
|
|
149
|
+
export function readsCommits(item: ScopeItem): boolean {
|
|
150
|
+
if (item.category !== 'Repository' && item.category !== 'Worktree') return false;
|
|
151
|
+
const t = treatmentOf(item);
|
|
152
|
+
if (t !== 'read' && t !== 'changes') return false;
|
|
153
|
+
const w = item.worktree;
|
|
154
|
+
// A merged worktree with nothing uncommitted contributes nothing: not its commits, not its status (E60).
|
|
155
|
+
return !(t === 'changes' && w && w.merged === true && w.uniqueCommits === 0 && w.taken.length === 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What is read from where, in one comparable line: a change in it means the scan has to run again. */
|
|
159
|
+
export function scanSignature(scope: readonly ScopeItem[]): string {
|
|
160
|
+
return scope.filter((i) => i.category !== 'Session source').map((i) => `${i.path.toLowerCase()}=${treatmentOf(i)}`).sort().join('\n');
|
|
161
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolchain discovery (Spec §3.1, §6.7; CKC-03 AC-23). The Keeper may read the toolchain the
|
|
3
|
+
* project's own configuration points at — an SDK, a compiler — to make sense of the build
|
|
4
|
+
* environment. Those locations are found by the program from the project's own config, using
|
|
5
|
+
* generic conventions, and become both an allowed read root (src/keeper/bounds) and a Project
|
|
6
|
+
* scope entry the owner can see and correct (§6.7).
|
|
7
|
+
*
|
|
8
|
+
* Only absolute paths a config file *states*, that exist on disk and lie *outside* the project's
|
|
9
|
+
* own locations, are returned; each carries the config file and the item it came from as its
|
|
10
|
+
* reason. Version-manager files that name a tool and version but not a path (a version pin, an
|
|
11
|
+
* install manifest) are intentionally not resolved to guessed install directories — nothing is
|
|
12
|
+
* added on speculation.
|
|
13
|
+
*
|
|
14
|
+
* A declared location that is too broad — a filesystem root, the home directory or anything
|
|
15
|
+
* containing it, the ProjectKeeper home or anything containing it or inside it, a directory that
|
|
16
|
+
* contains the project — is still listed so the owner can see and correct it, but flagged
|
|
17
|
+
* `used: false` with the reason, and never becomes a read root.
|
|
18
|
+
*/
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
22
|
+
import { canonicalPath, isWithin, normalizePath, pathKey } from '../util/paths.ts';
|
|
23
|
+
import { projectKeeperHome as defaultProjectKeeperHome } from '../store/paths.ts';
|
|
24
|
+
import { broadRootReason } from '../keeper/bounds/paths.ts';
|
|
25
|
+
|
|
26
|
+
export interface ToolchainRoot {
|
|
27
|
+
/** Normalised absolute directory the config names. */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** Which config file, and which item in it, points here (§6.7: reason shown to the owner). */
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
/** The config file the location was declared in. */
|
|
32
|
+
readonly configPath: string;
|
|
33
|
+
/** Whether it is an allowed read root; false when it is too broad (see notUsedReason). */
|
|
34
|
+
readonly used: boolean;
|
|
35
|
+
/** Why it is not used, e.g. "not used: too broad (the whole home directory)"; null when used. */
|
|
36
|
+
readonly notUsedReason: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The directories a toolchain location is compared against (passed in, so tests never use the real ones). */
|
|
40
|
+
export interface ToolchainOptions {
|
|
41
|
+
/** The user's home directory. Default: the real one. */
|
|
42
|
+
readonly home?: string;
|
|
43
|
+
/** The ProjectKeeper home in use. Default: the configured one. */
|
|
44
|
+
readonly projectKeeperHome?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A property/Gradle-style value: unescape the Java `\:` and `\\` escapes and trim. */
|
|
48
|
+
function unescapeProperty(value: string): string {
|
|
49
|
+
return value.replace(/\\(.)/g, '$1').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A concrete absolute filesystem path, or null for macros ($VAR$, $PROJECT_DIR$), URLs and relatives. */
|
|
53
|
+
function concreteAbsolute(raw: string): string | null {
|
|
54
|
+
let s = raw.trim().replace(/^["']|["']$/g, '');
|
|
55
|
+
if (!s || s.includes('$')) return null; // IDE/Gradle macros are not concrete
|
|
56
|
+
if (s.startsWith('file://')) s = s.replace(/^file:\/\//, '');
|
|
57
|
+
else if (s.startsWith('jar://')) s = s.replace(/^jar:\/\//, '').replace(/!.*$/, '');
|
|
58
|
+
// A leading slash before a Windows drive from a file:// URL: /C:/x → C:/x
|
|
59
|
+
if (/^\/[A-Za-z]:[\\/]/.test(s)) s = s.slice(1);
|
|
60
|
+
if (!isAbsolute(s) && !/^[A-Za-z]:[\\/]/.test(s)) return null;
|
|
61
|
+
return normalizePath(s);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The directory a candidate names: the directory itself, or the parent of a file (a compiler binary). */
|
|
65
|
+
function candidateDir(path: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
const st = statSync(path);
|
|
68
|
+
return st.isDirectory() ? path : normalizePath(dirname(path));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RawHit { readonly value: string; readonly where: string }
|
|
75
|
+
|
|
76
|
+
/** `key = value` lines whose key names a tool location (…dir/home/path/sdk/root). */
|
|
77
|
+
function fromProperties(text: string): RawHit[] {
|
|
78
|
+
const out: RawHit[] = [];
|
|
79
|
+
for (const line of text.split(/\r?\n/)) {
|
|
80
|
+
const m = /^\s*([\w.\-]*(?:dir|home|path|sdk|root))\s*[:=]\s*(.+?)\s*$/i.exec(line);
|
|
81
|
+
if (m && !line.trimStart().startsWith('#') && !line.trimStart().startsWith('!')) out.push({ value: unescapeProperty(m[2]!), where: m[1]! });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** JetBrains library / module roots: url="file://…" or url="jar://…!/…". */
|
|
87
|
+
function fromIdeaXml(text: string): RawHit[] {
|
|
88
|
+
const out: RawHit[] = [];
|
|
89
|
+
for (const m of text.matchAll(/url\s*=\s*"((?:file|jar):\/\/[^"]+)"/g)) out.push({ value: m[1]!, where: 'library root' });
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** CMake cache PATH/FILEPATH entries. */
|
|
94
|
+
function fromCMakeCache(text: string): RawHit[] {
|
|
95
|
+
const out: RawHit[] = [];
|
|
96
|
+
for (const line of text.split(/\r?\n/)) {
|
|
97
|
+
const m = /^([^#/][^:=]*):(PATH|FILEPATH)=(.+)$/.exec(line);
|
|
98
|
+
if (m) out.push({ value: m[3]!, where: `${m[1]!} (${m[2]!})` });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read a config file if it is present and not too large. */
|
|
104
|
+
function readIfPresent(file: string, maxBytes = 2_000_000): string | null {
|
|
105
|
+
try {
|
|
106
|
+
if (!existsSync(file) || statSync(file).size > maxBytes) return null;
|
|
107
|
+
return readFileSync(file, 'utf8');
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ideaLibraryFiles(location: string): string[] {
|
|
114
|
+
const dir = join(location, '.idea', 'libraries');
|
|
115
|
+
try {
|
|
116
|
+
return readdirSync(dir).filter((n) => n.toLowerCase().endsWith('.xml')).map((n) => join(dir, n));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function topLevelFilesBySuffix(location: string, suffix: string): string[] {
|
|
123
|
+
try {
|
|
124
|
+
return readdirSync(location).filter((n) => n.toLowerCase().endsWith(suffix)).map((n) => join(location, n));
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Toolchain roots declared by the project's own configuration. `locations` are the project's own
|
|
132
|
+
* directories (a path inside one of them is already in scope, so it is never reported as toolchain).
|
|
133
|
+
*/
|
|
134
|
+
export function discoverToolchain(locations: readonly string[], options: ToolchainOptions = {}): ToolchainRoot[] {
|
|
135
|
+
const roots = locations.map((l) => canonicalPath(l));
|
|
136
|
+
const breadth = { home: options.home ?? homedir(), projectKeeperHome: options.projectKeeperHome ?? defaultProjectKeeperHome(), projectLocations: roots };
|
|
137
|
+
const out: ToolchainRoot[] = [];
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
const add = (rawValue: string, configPath: string, where: string) => {
|
|
140
|
+
const abs = concreteAbsolute(rawValue);
|
|
141
|
+
if (!abs) return;
|
|
142
|
+
// A configuration file spells a directory its own way; it is kept in the file system's spelling, like the locations.
|
|
143
|
+
const dir = candidateDir(canonicalPath(abs));
|
|
144
|
+
if (!dir) return; // must exist on disk
|
|
145
|
+
if (roots.some((r) => isWithin(r, dir))) return; // already inside the project
|
|
146
|
+
const key = pathKey(dir);
|
|
147
|
+
if (seen.has(key)) return;
|
|
148
|
+
seen.add(key);
|
|
149
|
+
const notUsedReason = broadRootReason(dir, breadth);
|
|
150
|
+
out.push({ path: dir, reason: `${basename(configPath)} declares ${where} → ${dir}`, configPath, used: notUsedReason === null, notUsedReason });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const location of roots) {
|
|
154
|
+
if (!existsSync(location)) continue;
|
|
155
|
+
const props = readIfPresent(join(location, 'local.properties'));
|
|
156
|
+
if (props) for (const h of fromProperties(props)) add(h.value, join(location, 'local.properties'), h.where);
|
|
157
|
+
const cmake = readIfPresent(join(location, 'CMakeCache.txt'));
|
|
158
|
+
if (cmake) for (const h of fromCMakeCache(cmake)) add(h.value, join(location, 'CMakeCache.txt'), h.where);
|
|
159
|
+
for (const xml of [...ideaLibraryFiles(location), ...topLevelFilesBySuffix(location, '.iml')]) {
|
|
160
|
+
const text = readIfPresent(xml);
|
|
161
|
+
if (text) for (const h of fromIdeaXml(text)) add(h.value, xml, h.where);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A toolchain entry as stored on the project (records written before the used flag existed lack it). */
|
|
168
|
+
export interface StoredToolchainEntry {
|
|
169
|
+
readonly path: string;
|
|
170
|
+
readonly used?: boolean;
|
|
171
|
+
readonly notUsedReason?: string | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The toolchain entries the read boundary considers: the ones stored on the project, or discovered now when scope
|
|
176
|
+
* discovery has not stored any. The boundary re-checks each for breadth itself; the stored flag is for display.
|
|
177
|
+
*/
|
|
178
|
+
export function toolchainRoots(project: { readonly toolchain?: readonly StoredToolchainEntry[]; readonly locations: readonly string[] }, options: ToolchainOptions = {}): readonly StoredToolchainEntry[] {
|
|
179
|
+
return project.toolchain ? [...project.toolchain] : discoverToolchain(project.locations, options);
|
|
180
|
+
}
|