@kitkat-vn/kitcode 0.2.0-staging.3
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/LICENSE +21 -0
- package/README.md +328 -0
- package/bin/kitcode.mjs +1097 -0
- package/package.json +41 -0
- package/src/api-access.mjs +119 -0
- package/src/api.mjs +165 -0
- package/src/atomic-file.mjs +85 -0
- package/src/break-progress.mjs +87 -0
- package/src/claim-bundle.mjs +37 -0
- package/src/companion-controls.mjs +40 -0
- package/src/companion-electron.mjs +87 -0
- package/src/companion-preload.cjs +10 -0
- package/src/companion-window.mjs +57 -0
- package/src/cors.mjs +51 -0
- package/src/equals-ledger.mjs +166 -0
- package/src/focus-clock.mjs +86 -0
- package/src/fonts/fira-code/OFL.txt +93 -0
- package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
- package/src/fonts/vt323/OFL.txt +93 -0
- package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
- package/src/git.mjs +448 -0
- package/src/hook-installers.mjs +397 -0
- package/src/hook-prompt.mjs +201 -0
- package/src/install-id.mjs +94 -0
- package/src/integration-installers.mjs +28 -0
- package/src/integration-spec.mjs +398 -0
- package/src/log-file.mjs +60 -0
- package/src/milestones-reached.mjs +130 -0
- package/src/next-break-reminder.mjs +78 -0
- package/src/onboarding-electron.mjs +240 -0
- package/src/onboarding-platform.mjs +50 -0
- package/src/onboarding-preload.cjs +19 -0
- package/src/onboarding-window.mjs +874 -0
- package/src/open-dashboard.mjs +30 -0
- package/src/reward.mjs +311 -0
- package/src/runner-installer.mjs +153 -0
- package/src/runtime.mjs +1323 -0
- package/src/scan-worker.mjs +23 -0
- package/src/scanner.mjs +84 -0
- package/src/seen-lines.mjs +148 -0
- package/src/skill-installer.mjs +76 -0
- package/src/source-snapshot.mjs +725 -0
- package/src/store.mjs +595 -0
- package/src/tracker-instance.mjs +166 -0
- package/src/tracker-problems.mjs +30 -0
- package/src/tracker-stop.mjs +73 -0
- package/src/uninstall.mjs +80 -0
- package/src/vibe.mjs +4 -0
- package/src/window-guards.mjs +7 -0
- package/src/window-page.mjs +49 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {gitArrival, ignoredPaths, listRepositoryFiles} from './git.mjs';
|
|
5
|
+
|
|
6
|
+
// Folders never read, at any depth (docs/reward-rules.md §5.4): version control, dependencies and
|
|
7
|
+
// build output, and tool caches. Unity writes Library, Temp, Logs and obj; the rest are PHP and Go
|
|
8
|
+
// vendoring, Python environments and bytecode, Rust and Maven output, Gradle, CocoaPods, Turborepo
|
|
9
|
+
// and generic caches.
|
|
10
|
+
const IGNORED_DIRS = new Set([
|
|
11
|
+
'.git',
|
|
12
|
+
'node_modules',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.next',
|
|
16
|
+
'out',
|
|
17
|
+
'coverage',
|
|
18
|
+
'Library',
|
|
19
|
+
'Temp',
|
|
20
|
+
'Logs',
|
|
21
|
+
'obj',
|
|
22
|
+
'vendor',
|
|
23
|
+
'.venv',
|
|
24
|
+
'venv',
|
|
25
|
+
'target',
|
|
26
|
+
'__pycache__',
|
|
27
|
+
'.gradle',
|
|
28
|
+
'Pods',
|
|
29
|
+
'.turbo',
|
|
30
|
+
'.cache',
|
|
31
|
+
]);
|
|
32
|
+
// SSH key files, whatever follows the name: .pub, -cert.pub, _sk, a backup's .old (operator decision,
|
|
33
|
+
// 2026-09-24). Until then every file named id_* was left out, source such as id_utils.js included.
|
|
34
|
+
const SSH_KEY_NAMES = ['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'];
|
|
35
|
+
// Files a tool writes, whose lines would otherwise pass the real-code-line test: lockfiles (a base64
|
|
36
|
+
// integrity value ends in ==, so installing a dependency earned =), minified files and source maps
|
|
37
|
+
// (operator decision, 2026-09-24; docs/reward-rules.md §5.4). Names in lower case.
|
|
38
|
+
const LOCKFILE_NAMES = new Set([
|
|
39
|
+
// JavaScript
|
|
40
|
+
'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
41
|
+
// PHP, Rust, Ruby, Python, Go, Elixir, Dart, CocoaPods, NuGet, Gradle, Nix
|
|
42
|
+
'composer.lock', 'cargo.lock', 'gemfile.lock', 'poetry.lock', 'pipfile.lock', 'go.sum', 'mix.lock',
|
|
43
|
+
'pubspec.lock', 'podfile.lock', 'packages.lock.json', 'gradle.lockfile', 'flake.lock',
|
|
44
|
+
]);
|
|
45
|
+
const GENERATED_SUFFIXES = ['.min.js', '.min.css', '.map'];
|
|
46
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
// A snapshot's format (docs/reward-rules.md §5.2). Format 1, which KitCode wrote until 2026-09-25,
|
|
49
|
+
// keyed each line by its whole sha256, 64 hex characters. Format 2 keys it by the first 16, and
|
|
50
|
+
// records the counting rules the snapshot was taken under.
|
|
51
|
+
export const SNAPSHOT_FORMAT = 2;
|
|
52
|
+
const LINE_KEY_LENGTH = 16;
|
|
53
|
+
const FULL_KEY = /^[0-9a-f]{64}$/;
|
|
54
|
+
const LINE_KEY = /^[0-9a-f]{16}$/;
|
|
55
|
+
|
|
56
|
+
function hashValue(value) {
|
|
57
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isObject(value) {
|
|
61
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// At least 4 word characters ([A-Za-z0-9_]), and more than 40 % of the line.
|
|
65
|
+
function isRealCodeLine(line) {
|
|
66
|
+
let wordChars = 0;
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
69
|
+
const code = line.charCodeAt(index);
|
|
70
|
+
|
|
71
|
+
if ((code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95) {
|
|
72
|
+
wordChars += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return wordChars >= 4 && wordChars > line.length * 0.4;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function countEquals(line) {
|
|
80
|
+
return line.match(/=/g)?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The most = one line earns, since rules version 2 (operator decision Q3, 2026-09-25).
|
|
84
|
+
const MAX_EQUALS_PER_LINE = 3;
|
|
85
|
+
|
|
86
|
+
// A line as rules version 2 compares it (operator decision Q3, 2026-09-25; docs/reward-rules.md §5.1):
|
|
87
|
+
// every whitespace character removed, the quotes " and ` written as ', and one trailing ; or ,
|
|
88
|
+
// dropped. Re-indenting, re-spacing and re-quoting a line, and the ; or , a formatter adds or takes
|
|
89
|
+
// away, leave it the same line.
|
|
90
|
+
function normalizeLine(line) {
|
|
91
|
+
return line.replace(/\s+/g, '').replace(/["`]/g, '\'').replace(/[;,]$/, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The counting rules, by version (docs/reward-rules.md §5). A snapshot records the version it was
|
|
95
|
+
// taken under, and a scan is compared only with a snapshot taken under the same rules: a change of
|
|
96
|
+
// rules goes through the rules switch (runtime.mjs switchProjectRules, §5.6). Each set says how a
|
|
97
|
+
// line is normalised before anything else looks at it (none: its exact text), its key, whether it
|
|
98
|
+
// counts, the = it earns, and whether it earns them once per user, ever (`oncePerUser`), or once per
|
|
99
|
+
// copy added.
|
|
100
|
+
export const RULES = Object.freeze({
|
|
101
|
+
// Version 1, the rules of 0.1.39, and of this code until hardening phase 2: a line is its exact
|
|
102
|
+
// text, it counts when it passes the real-code-line test, and every = on it counts, for every copy.
|
|
103
|
+
1: Object.freeze({
|
|
104
|
+
version: 1,
|
|
105
|
+
lineKey: (line) => hashValue(line).slice(0, LINE_KEY_LENGTH),
|
|
106
|
+
isCountedLine: isRealCodeLine,
|
|
107
|
+
equalsOn: countEquals,
|
|
108
|
+
}),
|
|
109
|
+
// Version 2 (operator decision Q3, 2026-09-25): a line is its normalised text, it counts when that
|
|
110
|
+
// passes the real-code-line test, at most 3 of its = count, and they count once per user, ever,
|
|
111
|
+
// across every project: against ~/.kitcode/seen-lines (applySourceScan).
|
|
112
|
+
2: Object.freeze({
|
|
113
|
+
version: 2,
|
|
114
|
+
normalize: normalizeLine,
|
|
115
|
+
lineKey: (line) => hashValue(line).slice(0, LINE_KEY_LENGTH),
|
|
116
|
+
isCountedLine: isRealCodeLine,
|
|
117
|
+
equalsOn: (line) => Math.min(MAX_EQUALS_PER_LINE, countEquals(line)),
|
|
118
|
+
oncePerUser: true,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
export const RULES_VERSION = 2;
|
|
122
|
+
const CURRENT_RULES = RULES[RULES_VERSION];
|
|
123
|
+
|
|
124
|
+
export function emptySnapshot(rulesVersion = RULES_VERSION) {
|
|
125
|
+
return {version: SNAPSHOT_FORMAT, rulesVersion, files: {}};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A snapshot that cannot be counted against: damaged, or written by a newer KitCode (`newer`).
|
|
129
|
+
export class SnapshotError extends Error {
|
|
130
|
+
constructor(message, {newer = false} = {}) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = 'SnapshotError';
|
|
133
|
+
this.newer = newer;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function snapshotFormat(snapshot) {
|
|
138
|
+
return snapshot.version === undefined ? 1 : snapshot.version;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The version of the rules `snapshot` was taken under, from its marks alone: a format 1 snapshot was
|
|
142
|
+
// taken under version 1. Throws a SnapshotError when the marks, or its files, are not what KitCode
|
|
143
|
+
// writes, or when they are newer than `newest`.
|
|
144
|
+
export function snapshotRules(snapshot, {newest = RULES_VERSION} = {}) {
|
|
145
|
+
if (!isObject(snapshot)) {
|
|
146
|
+
throw new SnapshotError('it is not an object');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const format = snapshotFormat(snapshot);
|
|
150
|
+
|
|
151
|
+
if (format !== 1 && format !== SNAPSHOT_FORMAT) {
|
|
152
|
+
throw Number.isSafeInteger(format) && format > SNAPSHOT_FORMAT
|
|
153
|
+
? new SnapshotError(`its format is ${format}, and this KitCode knows formats up to ${SNAPSHOT_FORMAT}`, {newer: true})
|
|
154
|
+
: new SnapshotError(`its format, ${JSON.stringify(format)}, is not one KitCode writes`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!isObject(snapshot.files)) {
|
|
158
|
+
throw new SnapshotError('its files are not an object');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (format === 1) {
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const rules = snapshot.rulesVersion;
|
|
166
|
+
|
|
167
|
+
if (!Number.isSafeInteger(rules) || rules < 1) {
|
|
168
|
+
throw new SnapshotError(`its rules version, ${JSON.stringify(rules)}, is not one KitCode writes`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (rules > newest) {
|
|
172
|
+
throw new SnapshotError(`it was taken under counting rules ${rules}, and this KitCode knows rules up to ${newest}`, {newer: true});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rules;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `snapshot` in this version's format, checked line by line. One an older KitCode wrote, format 1, is
|
|
179
|
+
// converted by cutting each line key to its first 16 characters: every line keeps its identity, so
|
|
180
|
+
// the conversion counts nothing and loses nothing. Two keys of one file that then coincide become one,
|
|
181
|
+
// with their counts added (`collisions` says how many). A format 2 snapshot comes back as it is.
|
|
182
|
+
// Fields this version does not know are kept. Throws a SnapshotError when a key or a count is not what
|
|
183
|
+
// KitCode writes.
|
|
184
|
+
export function convertSnapshot(snapshot, {newest = RULES_VERSION} = {}) {
|
|
185
|
+
snapshotRules(snapshot, {newest});
|
|
186
|
+
const format = snapshotFormat(snapshot);
|
|
187
|
+
const keyPattern = format === 1 ? FULL_KEY : LINE_KEY;
|
|
188
|
+
const files = {};
|
|
189
|
+
let collisions = 0;
|
|
190
|
+
|
|
191
|
+
for (const [fileKey, entry] of Object.entries(snapshot.files)) {
|
|
192
|
+
if (!isObject(entry) || !isObject(entry.lineCounts)) {
|
|
193
|
+
throw new SnapshotError(`the line map of file ${fileKey.slice(0, LINE_KEY_LENGTH)}… is not an object`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const lineCounts = {};
|
|
197
|
+
|
|
198
|
+
for (const [key, count] of Object.entries(entry.lineCounts)) {
|
|
199
|
+
if (!keyPattern.test(key)) {
|
|
200
|
+
throw new SnapshotError(`its line key ${JSON.stringify(key)} is not ${format === 1 ? 'a 64-character sha256' : 'the 16 hexadecimal characters of a line key'}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!Number.isSafeInteger(count) || count < 1) {
|
|
204
|
+
throw new SnapshotError(`the count of its line ${key.slice(0, LINE_KEY_LENGTH)} is ${JSON.stringify(count)}, not a whole number above 0`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const lineKey = key.slice(0, LINE_KEY_LENGTH);
|
|
208
|
+
collisions += Object.hasOwn(lineCounts, lineKey) ? 1 : 0;
|
|
209
|
+
lineCounts[lineKey] = (lineCounts[lineKey] ?? 0) + count;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
files[fileKey] = {...entry, lineCounts};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (format === SNAPSHOT_FORMAT) {
|
|
216
|
+
return {snapshot, converted: false, collisions: 0};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const {version, rulesVersion, files: formerFiles, ...rest} = snapshot;
|
|
220
|
+
return {snapshot: {version: SNAPSHOT_FORMAT, rulesVersion: 1, files, ...rest}, converted: true, collisions};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Every line key in `snapshot`, of every file, or of the files `fileKeys` names.
|
|
224
|
+
export function snapshotLineKeys(snapshot, fileKeys = null) {
|
|
225
|
+
return Object.entries(snapshot.files)
|
|
226
|
+
.filter(([fileKey]) => !fileKeys || fileKeys.has(fileKey))
|
|
227
|
+
.flatMap(([, entry]) => Object.keys(entry.lineCounts));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The line maps state.json writes on one line each: those of snapshots in this version's format
|
|
231
|
+
// (store.mjs). An older snapshot is written as it always was, two spaces a level, until it is converted.
|
|
232
|
+
export function lineMapsWrittenOnOneLine(state) {
|
|
233
|
+
const maps = new Set();
|
|
234
|
+
|
|
235
|
+
for (const project of Object.values(isObject(state?.projects) ? state.projects : {})) {
|
|
236
|
+
const snapshot = project?.sourceSnapshot;
|
|
237
|
+
|
|
238
|
+
if (isObject(snapshot) && snapshot.version === SNAPSHOT_FORMAT && isObject(snapshot.files)) {
|
|
239
|
+
for (const entry of Object.values(snapshot.files)) {
|
|
240
|
+
if (isObject(entry?.lineCounts)) {
|
|
241
|
+
maps.add(entry.lineCounts);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return maps;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isBinary(buffer) {
|
|
251
|
+
return buffer.includes(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function safeRelative(root, filePath) {
|
|
255
|
+
return path.relative(root, filePath).split(path.sep).join('/');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// True when a folder on the way from `root` to `relativePath` is a symbolic link. The OS follows a
|
|
259
|
+
// link in a non-final path component during resolution, so a listing such as git's index can name a
|
|
260
|
+
// file under a linked folder that resolves outside the project. Only components below `root` are
|
|
261
|
+
// checked, so a symlink standing above the project root (a /tmp that points at /private/tmp) is not
|
|
262
|
+
// mistaken for one inside it. A linked file itself is caught by lstat in readSourceEntry.
|
|
263
|
+
function pathCrossesSymlink(root, relativePath) {
|
|
264
|
+
const parts = String(relativePath).split('/').filter(Boolean);
|
|
265
|
+
let current = root;
|
|
266
|
+
|
|
267
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
268
|
+
current = path.join(current, parts[index]);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fileKeyForRelative(relativePath) {
|
|
283
|
+
return hashValue(relativePath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Files whose contents are never read or hashed, and whose paths are never stored: .env files,
|
|
287
|
+
// certificates and keys, and SSH keys (docs/security.md, "The state file"). Matched on the file's
|
|
288
|
+
// own name, in any case.
|
|
289
|
+
export function isSecretFileName(name) {
|
|
290
|
+
const lower = String(name).toLowerCase();
|
|
291
|
+
|
|
292
|
+
return lower.startsWith('.env') || lower.endsWith('.pem') || lower.endsWith('.key') || SSH_KEY_NAMES.some((key) => lower.startsWith(key));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Lockfiles, minified files and source maps: never counted, and a change to one is not activity.
|
|
296
|
+
// Matched on the file's own name, in any case.
|
|
297
|
+
export function isGeneratedFileName(name) {
|
|
298
|
+
const lower = String(name).toLowerCase();
|
|
299
|
+
|
|
300
|
+
return LOCKFILE_NAMES.has(lower) || GENERATED_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function shouldIgnoreRelativePath(relativePath) {
|
|
304
|
+
const parts = String(relativePath).split(/[\\/]+/).filter(Boolean);
|
|
305
|
+
|
|
306
|
+
return parts.some((part) => IGNORED_DIRS.has(part))
|
|
307
|
+
|| (parts.length > 0 && (isSecretFileName(parts.at(-1)) || isGeneratedFileName(parts.at(-1))));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Every file under `root` a scan may read, without git: ignored folders are not entered, and
|
|
311
|
+
// symbolic links, to files or to folders, are skipped. A folder that cannot be listed is left out,
|
|
312
|
+
// unless the failure may be passing (too many open files, an I/O error): then the walk throws, so
|
|
313
|
+
// that a scan never mistakes a failed listing for deleted files.
|
|
314
|
+
function walkFiles(root, current = root, files = []) {
|
|
315
|
+
let entries;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
entries = fs.readdirSync(current, {withFileTypes: true});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (current !== root && ['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) {
|
|
321
|
+
return files;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const entry of entries) {
|
|
328
|
+
const fullPath = path.join(current, entry.name);
|
|
329
|
+
|
|
330
|
+
if (entry.isDirectory()) {
|
|
331
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
332
|
+
walkFiles(root, fullPath, files);
|
|
333
|
+
}
|
|
334
|
+
} else if (entry.isFile() && !isSecretFileName(entry.name)) {
|
|
335
|
+
files.push(safeRelative(root, fullPath));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return files;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The files of a project, relative to its root. In a Git project these are the files git lists:
|
|
343
|
+
// tracked, or untracked and not ignored, so .gitignore is honoured. Throws when they cannot be
|
|
344
|
+
// listed; a scan must then leave the snapshot as it is.
|
|
345
|
+
export function listProjectFiles(root, {sourceType = 'vibe'} = {}) {
|
|
346
|
+
if (!fs.statSync(root).isDirectory()) {
|
|
347
|
+
throw Object.assign(new Error(`${root} is not a folder`), {code: 'ENOTDIR'});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const files = sourceType === 'git' ? listRepositoryFiles(root, [...IGNORED_DIRS]) : walkFiles(root);
|
|
351
|
+
return files.filter((relativePath) => !shouldIgnoreRelativePath(relativePath));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The file keys of what a full scan of `root` would list now, or null when it cannot be listed.
|
|
355
|
+
export function listedFileKeys(root, {sourceType = 'vibe'} = {}) {
|
|
356
|
+
try {
|
|
357
|
+
return new Set(listProjectFiles(root, {sourceType}).map(fileKeyForRelative));
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Each counted line of `source`, normalised and keyed as `rules` normalise and key it, with how often
|
|
364
|
+
// it occurs, and the = on it. Two lines with one key (the same text, or text that differs only in what
|
|
365
|
+
// the rules ignore) are one line: their counts are added, and the = are those of the first.
|
|
366
|
+
function lineCountsForSource(source, rules) {
|
|
367
|
+
const counts = new Map();
|
|
368
|
+
|
|
369
|
+
for (const text of source.split(/\r?\n/)) {
|
|
370
|
+
const line = rules.normalize ? rules.normalize(text) : text;
|
|
371
|
+
|
|
372
|
+
if (rules.isCountedLine(line)) {
|
|
373
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const lineCounts = {};
|
|
378
|
+
const equalsByHash = {};
|
|
379
|
+
|
|
380
|
+
for (const [line, count] of counts) {
|
|
381
|
+
const lineKey = rules.lineKey(line);
|
|
382
|
+
lineCounts[lineKey] = (lineCounts[lineKey] ?? 0) + count;
|
|
383
|
+
equalsByHash[lineKey] ??= rules.equalsOn(line);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {equalsByHash, lineCounts};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// One file's text, read for a scan. A symbolic link is never followed, in any scan: not the file
|
|
390
|
+
// itself (lstat rejects it, since a link is not a regular file), and, when `checkPath` is set, not a
|
|
391
|
+
// folder on its path either (pathCrossesSymlink), since a listing may name a file under a linked
|
|
392
|
+
// folder that resolves outside the project. Files over 1 MiB, binary files and files that are gone
|
|
393
|
+
// come back `missing`. `checkPath` is by component, not by comparing resolved paths, so a case-only
|
|
394
|
+
// difference on a case-insensitive filesystem (git tracks Foo.js, the disk holds foo.js) is still read.
|
|
395
|
+
function readSourceText(root, relativePath, {checkPath = false} = {}) {
|
|
396
|
+
const fileKey = fileKeyForRelative(relativePath);
|
|
397
|
+
const missing = {relativePath, fileKey, missing: true};
|
|
398
|
+
|
|
399
|
+
if (checkPath && pathCrossesSymlink(root, relativePath)) {
|
|
400
|
+
return missing;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const fullPath = path.join(root, relativePath);
|
|
404
|
+
let stat;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
stat = fs.lstatSync(fullPath);
|
|
408
|
+
} catch {
|
|
409
|
+
return missing;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
|
|
413
|
+
return missing;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let buffer;
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
buffer = fs.readFileSync(fullPath);
|
|
420
|
+
} catch {
|
|
421
|
+
return missing;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (isBinary(buffer)) {
|
|
425
|
+
return missing;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {relativePath, fileKey, text: buffer.toString('utf8')};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// One file, read for a scan and counted under `rules`: see readSourceText for what is never read.
|
|
432
|
+
export function readSourceEntry(root, relativePath, {checkPath = false, rules = CURRENT_RULES} = {}) {
|
|
433
|
+
const source = readSourceText(root, relativePath, {checkPath});
|
|
434
|
+
|
|
435
|
+
return source.missing ? source : {relativePath, fileKey: source.fileKey, ...lineCountsForSource(source.text, rules)};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function normalizeChangedPath(root, changedPath) {
|
|
439
|
+
const relativePath = safeRelative(root, path.resolve(root, String(changedPath)));
|
|
440
|
+
|
|
441
|
+
return relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath) ? relativePath : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// What a read made under `rules` says about itself: the version, and, under rules that credit a line
|
|
445
|
+
// once per user, `oncePerUser`, so that the scan is credited as those rules say (applySourceScan).
|
|
446
|
+
function readUnder(rules, complete) {
|
|
447
|
+
return {complete, rulesVersion: rules.version, ...(rules.oncePerUser ? {oncePerUser: true} : {}), files: []};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Reads a project for a scan, counted under `rules`, without touching any snapshot. With
|
|
451
|
+
// `changedPaths`, only those files: a path an ignore rule now matches comes back `missing` and
|
|
452
|
+
// `ignored`, so its old entry is dropped. Without, every file: `complete`, and files it did not list
|
|
453
|
+
// are gone from the snapshot. The read carries its `rulesVersion`. Throws when the project cannot be
|
|
454
|
+
// listed.
|
|
455
|
+
export function readProjectSources(root, {sourceType = 'vibe', changedPaths = null, rules = CURRENT_RULES} = {}) {
|
|
456
|
+
if (!Array.isArray(changedPaths)) {
|
|
457
|
+
return readProjectSourcesUnder(root, {sourceType, rules: [rules]})[0];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const candidates = [...new Set(changedPaths.map((changedPath) => normalizeChangedPath(root, changedPath)).filter(Boolean))]
|
|
461
|
+
.filter((relativePath) => !shouldIgnoreRelativePath(relativePath));
|
|
462
|
+
const ignored = new Set(sourceType === 'git' ? ignoredPaths(root, candidates) : []);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
...readUnder(rules, false),
|
|
466
|
+
files: candidates.map((relativePath) => (
|
|
467
|
+
ignored.has(relativePath)
|
|
468
|
+
? {relativePath, fileKey: fileKeyForRelative(relativePath), missing: true, ignored: true}
|
|
469
|
+
: readSourceEntry(root, relativePath, {checkPath: true, rules})
|
|
470
|
+
)),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Every file of a project, listed once and read once, and counted under each of `rules`: one complete
|
|
475
|
+
// read per set of rules, all of the same content. The rules switch needs a read under the old rules
|
|
476
|
+
// and one under the new that no edit can fall between.
|
|
477
|
+
export function readProjectSourcesUnder(root, {sourceType = 'vibe', rules}) {
|
|
478
|
+
const reads = rules.map((ruleSet) => readUnder(ruleSet, true));
|
|
479
|
+
|
|
480
|
+
// A Git project's listing (git ls-files) is trusted for which files exist, but not for where they
|
|
481
|
+
// resolve: checkPath drops any path that crosses a symlinked folder. A vibe walk already skips
|
|
482
|
+
// links, so checkPath only confirms it there.
|
|
483
|
+
for (const relativePath of listProjectFiles(root, {sourceType})) {
|
|
484
|
+
const source = readSourceText(root, relativePath, {checkPath: true});
|
|
485
|
+
|
|
486
|
+
if (!source.missing) {
|
|
487
|
+
rules.forEach((ruleSet, index) => {
|
|
488
|
+
reads[index].files.push({relativePath, fileKey: source.fileKey, ...lineCountsForSource(source.text, ruleSet)});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return reads;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// `read` made, then checked against git in a Git project (docs/reward-rules.md §5.8; operator decision
|
|
497
|
+
// Q2, 2026-09-25): git is asked what can have arrived through it since `since`, the position the last
|
|
498
|
+
// scan recorded (git.mjs gitArrival). When anything can have, the read is a baseline, and `baseline`
|
|
499
|
+
// says why. When HEAD moved some other way than by the user's own commit, or the reflog cannot say
|
|
500
|
+
// how it got where it is, the whole project is read again with `readAll`, after git was asked: a file
|
|
501
|
+
// git wrote after the first read of it, or whose change this scan was not told of, is in it then. Each
|
|
502
|
+
// read carries `git`, the position now, for the snapshot to record. A folder project's read is left as
|
|
503
|
+
// it is.
|
|
504
|
+
//
|
|
505
|
+
// A read in which git did anything but the user's own commits carries `gitOperation`: what it found
|
|
506
|
+
// changed is git's, and not activity (operator decision Q4, 2026-09-25). A baseline always does; so does
|
|
507
|
+
// a read after git acted without moving HEAD, as a stash does, which is read whole too, like a move, so
|
|
508
|
+
// that none of what git changed is left for a later scan to take for the user's.
|
|
509
|
+
function checkedAgainstGit(root, sourceType, since, read, readAll) {
|
|
510
|
+
const first = read();
|
|
511
|
+
|
|
512
|
+
if (sourceType !== 'git') {
|
|
513
|
+
return first;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const {arrival, operation, position} = gitArrival(root, since);
|
|
517
|
+
const reads = arrival === 'moved' || arrival === 'unexplained' || (arrival === null && operation) ? readAll() : first;
|
|
518
|
+
const mark = (entry) => ({...entry, git: position, ...(arrival ? {baseline: arrival} : {}), ...(operation ? {gitOperation: true} : {})});
|
|
519
|
+
|
|
520
|
+
return Array.isArray(reads) ? reads.map(mark) : mark(reads);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// readProjectSources, checked against git (checkedAgainstGit): what a scan reads.
|
|
524
|
+
export function readProjectScan(root, {sourceType = 'vibe', changedPaths = null, rules = CURRENT_RULES, since} = {}) {
|
|
525
|
+
return checkedAgainstGit(
|
|
526
|
+
root,
|
|
527
|
+
sourceType,
|
|
528
|
+
since,
|
|
529
|
+
() => readProjectSources(root, {sourceType, changedPaths, rules}),
|
|
530
|
+
() => readProjectSources(root, {sourceType, rules}),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// readProjectSourcesUnder, checked against git: what the rules switch reads. When one read is a
|
|
535
|
+
// baseline, every one is.
|
|
536
|
+
export function readProjectScanUnder(root, {sourceType = 'vibe', rules, since}) {
|
|
537
|
+
const readAll = () => readProjectSourcesUnder(root, {sourceType, rules});
|
|
538
|
+
return checkedAgainstGit(root, sourceType, since, readAll, readAll);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// A snapshot in this version's format, for a scan to be folded into: one in format 1 is converted
|
|
542
|
+
// (convertSnapshot). A format 2 snapshot is taken as it is, and one with no files as empty.
|
|
543
|
+
function currentSnapshot(snapshot, rulesVersion) {
|
|
544
|
+
if (isObject(snapshot) && snapshotFormat(snapshot) === 1 && isObject(snapshot.files)) {
|
|
545
|
+
return convertSnapshot(snapshot).snapshot;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
...(isObject(snapshot) ? snapshot : {}),
|
|
550
|
+
version: SNAPSHOT_FORMAT,
|
|
551
|
+
rulesVersion: isObject(snapshot) && snapshot.rulesVersion !== undefined ? snapshot.rulesVersion : rulesVersion,
|
|
552
|
+
files: isObject(snapshot?.files) ? snapshot.files : {},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function sameCounts(left = {}, right = {}) {
|
|
557
|
+
const leftKeys = Object.keys(left);
|
|
558
|
+
|
|
559
|
+
return leftKeys.length === Object.keys(right).length && leftKeys.every((key) => left[key] === right[key]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function lineCountsChanged(previousCounts = {}, nextCounts = {}) {
|
|
563
|
+
return !sameCounts(previousCounts, nextCounts);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Whether a file's snapshot entry holds any counted line.
|
|
567
|
+
function hasLines(entry) {
|
|
568
|
+
return isObject(entry?.lineCounts) && Object.keys(entry.lineCounts).length > 0;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Whether two snapshots' files hold the same lines, as often.
|
|
572
|
+
export function sameSnapshotFiles(left, right) {
|
|
573
|
+
const fileKeys = Object.keys(left);
|
|
574
|
+
|
|
575
|
+
return fileKeys.length === Object.keys(right).length
|
|
576
|
+
&& fileKeys.every((fileKey) => Object.hasOwn(right, fileKey) && sameCounts(left[fileKey]?.lineCounts, right[fileKey]?.lineCounts));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// The = on the lines whose count went up, and those lines' keys. Each copy added earns the line's =,
|
|
580
|
+
// unless `once` is given, for rules that credit a line once per user, ever: then a line earns once,
|
|
581
|
+
// however many copies were added, and only if it is new to this file, not credited earlier in this
|
|
582
|
+
// scan (`once.credited`), and not `once.seen` before, in any file of any project.
|
|
583
|
+
function countAddedEquals(previousCounts, equalsByHash, lineCounts, addedKeys, once) {
|
|
584
|
+
let equalsAdded = 0;
|
|
585
|
+
|
|
586
|
+
for (const [lineHash, currentCount] of Object.entries(lineCounts)) {
|
|
587
|
+
const before = previousCounts[lineHash] ?? 0;
|
|
588
|
+
|
|
589
|
+
if (currentCount <= before) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
addedKeys.add(lineHash);
|
|
594
|
+
|
|
595
|
+
if (!once) {
|
|
596
|
+
equalsAdded += (currentCount - before) * (equalsByHash[lineHash] ?? 0);
|
|
597
|
+
} else if (before === 0 && !once.credited.has(lineHash) && !once.seen(lineHash)) {
|
|
598
|
+
once.credited.add(lineHash);
|
|
599
|
+
equalsAdded += equalsByHash[lineHash] ?? 0;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return equalsAdded;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Whether two recorded git positions are the same (gitArrival).
|
|
607
|
+
function sameGitPosition(left, right) {
|
|
608
|
+
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Folds a read into `stored`, the snapshot as it is now. It returns the next snapshot, the = on
|
|
612
|
+
// lines added since `stored`, the keys of those lines (`addedKeys`: every line of a new baseline), how
|
|
613
|
+
// many files changed, and `linesChanged`: the keys of the files whose normalised lines changed, lines
|
|
614
|
+
// added or removed, which is what activity is made of (operator decision Q4, 2026-09-25). A file whose
|
|
615
|
+
// lines are all the same after the read, however its text changed, is not among them, and neither is an
|
|
616
|
+
// empty file made or deleted. Only the scanner of record calls it: under the state lock, against the
|
|
617
|
+
// snapshot on disk, so that no two scans count the same line. The read must be under the rules the
|
|
618
|
+
// snapshot was taken under.
|
|
619
|
+
//
|
|
620
|
+
// A read made under rules that credit a line once per user asks `seen` whether a line has been seen
|
|
621
|
+
// before: the store's ~/.kitcode/seen-lines. Without it, the lines of `stored` are the lines seen. A
|
|
622
|
+
// read that is a `baseline` (checkedAgainstGit: code can have arrived through git) credits nothing,
|
|
623
|
+
// and its lines join the snapshot and seen-lines all the same. The read's git position, when it has
|
|
624
|
+
// one, is recorded on the snapshot (`gitMoved` says it changed).
|
|
625
|
+
//
|
|
626
|
+
// `base` is the snapshot the read started from. A file whose entry in `stored` differs from `base`
|
|
627
|
+
// was changed by another writer meanwhile, so the read may be older than that change: it is read
|
|
628
|
+
// again with `reread` rather than trusted, and in a full scan an entry it did not list is kept.
|
|
629
|
+
// Fields this version does not know, on the snapshot and on each file, are kept.
|
|
630
|
+
export function applySourceScan(stored, read, {base = stored, reread = null, seen = null} = {}) {
|
|
631
|
+
const previous = currentSnapshot(stored, read.rulesVersion);
|
|
632
|
+
|
|
633
|
+
if (previous.rulesVersion !== read.rulesVersion) {
|
|
634
|
+
throw new Error(`A snapshot taken under counting rules ${previous.rulesVersion} cannot be compared with a scan under rules ${read.rulesVersion}.`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const before = currentSnapshot(base, read.rulesVersion).files;
|
|
638
|
+
const files = read.complete ? {} : {...previous.files};
|
|
639
|
+
const listed = new Set();
|
|
640
|
+
const addedKeys = new Set();
|
|
641
|
+
let known = null;
|
|
642
|
+
const once = read.oncePerUser
|
|
643
|
+
? {credited: new Set(), seen: seen ?? ((key) => (known ??= new Set(snapshotLineKeys(previous))).has(key))}
|
|
644
|
+
: null;
|
|
645
|
+
let equalsAdded = 0;
|
|
646
|
+
let changedFiles = 0;
|
|
647
|
+
const linesChanged = [];
|
|
648
|
+
|
|
649
|
+
for (const readEntry of read.files) {
|
|
650
|
+
const current = previous.files[readEntry.fileKey];
|
|
651
|
+
const entry = reread && !readEntry.ignored && !sameCounts(current?.lineCounts, before[readEntry.fileKey]?.lineCounts)
|
|
652
|
+
? reread(readEntry.relativePath)
|
|
653
|
+
: readEntry;
|
|
654
|
+
listed.add(entry.fileKey);
|
|
655
|
+
|
|
656
|
+
if (entry.missing) {
|
|
657
|
+
if (current) {
|
|
658
|
+
delete files[entry.fileKey];
|
|
659
|
+
changedFiles += 1;
|
|
660
|
+
|
|
661
|
+
// A file an ignore rule now matches leaves the snapshot, but its content did not change.
|
|
662
|
+
if (hasLines(current) && !entry.ignored) {
|
|
663
|
+
linesChanged.push(entry.fileKey);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
files[entry.fileKey] = {...(current && typeof current === 'object' ? current : {}), lineCounts: entry.lineCounts};
|
|
671
|
+
equalsAdded += countAddedEquals(current?.lineCounts ?? {}, entry.equalsByHash, entry.lineCounts, addedKeys, once);
|
|
672
|
+
|
|
673
|
+
if (lineCountsChanged(current?.lineCounts, entry.lineCounts)) {
|
|
674
|
+
changedFiles += 1;
|
|
675
|
+
linesChanged.push(entry.fileKey);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (read.complete) {
|
|
680
|
+
for (const [fileKey, entry] of Object.entries(previous.files)) {
|
|
681
|
+
if (listed.has(fileKey)) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (reread && !sameCounts(entry?.lineCounts, before[fileKey]?.lineCounts)) {
|
|
686
|
+
files[fileKey] = entry;
|
|
687
|
+
} else {
|
|
688
|
+
changedFiles += 1;
|
|
689
|
+
|
|
690
|
+
if (hasLines(entry)) {
|
|
691
|
+
linesChanged.push(fileKey);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const git = read.git === undefined ? {} : {git: read.git};
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
changedFiles,
|
|
701
|
+
linesChanged,
|
|
702
|
+
equalsAdded: read.baseline ? 0 : equalsAdded,
|
|
703
|
+
addedKeys: [...addedKeys],
|
|
704
|
+
gitMoved: read.git !== undefined && !sameGitPosition(previous.git, read.git),
|
|
705
|
+
snapshot: {...previous, files, ...git},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function createSourceSnapshot(root, {sourceType = 'vibe', rules = CURRENT_RULES} = {}) {
|
|
710
|
+
return applySourceScan(emptySnapshot(rules.version), readProjectScan(root, {sourceType, rules})).snapshot;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// A scan of `root` against `previousSnapshot`, checked against git in a Git project. `options.seen`
|
|
714
|
+
// says whether a line was seen before, as the store's seen-lines does; without it, the lines of
|
|
715
|
+
// `previousSnapshot` are the lines seen.
|
|
716
|
+
export function scanSourceChanges(root, previousSnapshot = emptySnapshot(), options = {}) {
|
|
717
|
+
const read = readProjectScan(root, {
|
|
718
|
+
sourceType: options.sourceType ?? 'vibe',
|
|
719
|
+
changedPaths: Array.isArray(options.changedPaths) ? options.changedPaths : null,
|
|
720
|
+
rules: options.rules ?? CURRENT_RULES,
|
|
721
|
+
since: previousSnapshot?.git,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
return applySourceScan(previousSnapshot, read, {seen: options.seen ?? null});
|
|
725
|
+
}
|