simt-dev 0.3.0 → 0.4.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 +172 -0
- package/README.md +138 -74
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -2
- package/rules/core.md +2 -2
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +34 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// A marked line is enforced by a checker, so the projection keeps its name and not
|
|
2
|
+
// its prose (plan 0003 R19, ADR-088).
|
|
3
|
+
//
|
|
4
|
+
// The unit is one list item, including the wrapped lines under it. A blank-line
|
|
5
|
+
// block would be the whole section: the core rules are consecutive bullets, and
|
|
6
|
+
// marking one of them must not take its neighbours with it.
|
|
7
|
+
//
|
|
8
|
+
// `import-direction` records nothing about which way an import may point. The
|
|
9
|
+
// index has the edges (R21). A direction still has to be named before the prose
|
|
10
|
+
// can leave, or the line would say Enforced and nothing would be enforcing it.
|
|
11
|
+
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
|
|
17
|
+
const exec = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export const CHECK_MARKER = 'simt-check';
|
|
20
|
+
|
|
21
|
+
/** Checkers this tool can run. `import-direction` is recognised and waits on a named direction. */
|
|
22
|
+
const RUNNABLE = new Set(['max-file-lines', 'forbid-regex', 'forbidden-path-edit', 'required-file']);
|
|
23
|
+
|
|
24
|
+
const MARKER = /<!--\s*simt-check:\s*([^>]*?)\s*-->/g;
|
|
25
|
+
const MARKER_AT = /\s*<!--\s*simt-check:\s*[^>]*?-->/g;
|
|
26
|
+
const HAS_MARKER = /<!--\s*simt-check:/;
|
|
27
|
+
const ITEM_START = /^(\s*)(?:[-*]|\d+\.)\s+/;
|
|
28
|
+
|
|
29
|
+
/** ADR-005's two tiers, used when a repository has not named its own. */
|
|
30
|
+
const DEFAULT_WARN = 400;
|
|
31
|
+
const DEFAULT_ERROR = 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What is wrong with the markers in a body, or null. The same field as a
|
|
35
|
+
* frontmatter defect, so `check` needs no second way to see a broken rule.
|
|
36
|
+
*/
|
|
37
|
+
export function checkDefect(body) {
|
|
38
|
+
for (const unit of units(body)) {
|
|
39
|
+
if (fenced(unit)) continue;
|
|
40
|
+
const found = markersIn(unit);
|
|
41
|
+
if (found.length === 0) continue;
|
|
42
|
+
if (found.length > 1) {
|
|
43
|
+
return `carries more than one ${CHECK_MARKER} marker on one line; one line is one checker`;
|
|
44
|
+
}
|
|
45
|
+
const parsed = parseCheck(found[0]);
|
|
46
|
+
if (parsed.error) return parsed.error;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replace each runnable check with one `Enforced:` line. A body with no marker
|
|
53
|
+
* is returned unchanged, so a rule nobody marked does not move a byte.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{body: string, enforced: {name: string}[], changed: boolean}}
|
|
56
|
+
*/
|
|
57
|
+
export function foldChecks(body) {
|
|
58
|
+
if (checkDefect(body) !== null || !HAS_MARKER.test(body)) {
|
|
59
|
+
return { body, enforced: [], changed: false };
|
|
60
|
+
}
|
|
61
|
+
const enforced = [];
|
|
62
|
+
const kept = [];
|
|
63
|
+
for (const unit of units(body)) {
|
|
64
|
+
const found = fenced(unit) ? [] : markersIn(unit);
|
|
65
|
+
if (found.length === 0) {
|
|
66
|
+
kept.push(unit);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const parsed = parseCheck(found[0]);
|
|
70
|
+
if (!parsed.runnable) {
|
|
71
|
+
kept.push(stripMarkers(unit));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const indent = ITEM_START.exec(unit)?.[1] ?? '';
|
|
75
|
+
const bullet = ITEM_START.test(unit) ? '- ' : '';
|
|
76
|
+
kept.push(`${indent}${bullet}Enforced: ${parsed.name}`);
|
|
77
|
+
enforced.push({ name: parsed.name });
|
|
78
|
+
}
|
|
79
|
+
const next = kept.join('\n');
|
|
80
|
+
return { body: next, enforced, changed: next !== body };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run the checkers the selected rules declare. A directory that is not a git
|
|
85
|
+
* repository has no tracked tree and no diff, so the checkers that need one
|
|
86
|
+
* report nothing rather than failing a repository they cannot see.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root
|
|
89
|
+
* @param {{id: string, source: string, body: string}[]} rules
|
|
90
|
+
* @param {object|null} manifest
|
|
91
|
+
*/
|
|
92
|
+
export async function runDeclaredChecks(root, rules, manifest) {
|
|
93
|
+
const declared = [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (const rule of rules) {
|
|
96
|
+
if (checkDefect(rule.body) !== null) continue;
|
|
97
|
+
for (const unit of units(rule.body)) {
|
|
98
|
+
if (fenced(unit)) continue;
|
|
99
|
+
for (const expr of markersIn(unit)) {
|
|
100
|
+
const parsed = parseCheck(expr);
|
|
101
|
+
if (parsed.error) continue;
|
|
102
|
+
const key = `${parsed.name}\0${parsed.arg}`;
|
|
103
|
+
if (seen.has(key)) continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
declared.push({ ...parsed, source: rule.source });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const findings = [];
|
|
110
|
+
for (const check of declared) {
|
|
111
|
+
findings.push(...(await runOne(root, check, manifest)));
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runOne(root, check, manifest) {
|
|
117
|
+
if (check.name === 'import-direction') {
|
|
118
|
+
return [{
|
|
119
|
+
file: check.source,
|
|
120
|
+
status: 'check-unbuilt',
|
|
121
|
+
detail: 'import-direction has no direction to enforce; the index records imports and no rule names which way they may point',
|
|
122
|
+
remedy: 'leave the instruction in the rule until a direction is named',
|
|
123
|
+
}];
|
|
124
|
+
}
|
|
125
|
+
if (check.name === 'max-file-lines') return fileLines(root, check, manifest);
|
|
126
|
+
if (check.name === 'required-file') return requiredFile(root, check);
|
|
127
|
+
const diff = await workDiff(root);
|
|
128
|
+
if (diff === null) return [];
|
|
129
|
+
if (check.name === 'forbid-regex') return forbidRegex(check, diff);
|
|
130
|
+
return forbiddenPath(check, diff);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function fileLines(root, check, manifest) {
|
|
134
|
+
const limits = manifest?.limits?.fileLines ?? {};
|
|
135
|
+
const generated = limits.generated;
|
|
136
|
+
if (generated !== undefined && !Array.isArray(generated)) {
|
|
137
|
+
return [{
|
|
138
|
+
file: 'simt.json',
|
|
139
|
+
status: 'invalid',
|
|
140
|
+
detail: 'limits.fileLines.generated must be a list of paths',
|
|
141
|
+
remedy: 'name each generated path, or remove the key',
|
|
142
|
+
}];
|
|
143
|
+
}
|
|
144
|
+
const warnAt = Number.isInteger(limits.warn) ? limits.warn : DEFAULT_WARN;
|
|
145
|
+
const errorAt = Number.isInteger(limits.error) ? limits.error : DEFAULT_ERROR;
|
|
146
|
+
const files = await tracked(root);
|
|
147
|
+
if (files === null) return [];
|
|
148
|
+
const findings = [];
|
|
149
|
+
const prefixes = Array.isArray(generated) ? generated.filter((g) => typeof g === 'string' && g !== '') : [];
|
|
150
|
+
for (const rel of files) {
|
|
151
|
+
// A committed dependency is not a file this repository wrote. Counting it
|
|
152
|
+
// fails every install of this package, because the package's own sources
|
|
153
|
+
// are longer than the ceiling the consumer declared.
|
|
154
|
+
if (rel.split('/').includes('node_modules')) continue;
|
|
155
|
+
if (exempt(rel, prefixes)) continue;
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = await readFile(resolve(root, rel));
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (text.includes(0)) continue;
|
|
163
|
+
const source = text.toString('utf8');
|
|
164
|
+
// The region marker is this tool naming itself as the generator. A path the
|
|
165
|
+
// repository names is the other exemption; a filename that looks generated is not.
|
|
166
|
+
if (source.includes('simt:generated')) continue;
|
|
167
|
+
const lines = lineCount(source);
|
|
168
|
+
if (lines > errorAt) {
|
|
169
|
+
findings.push({
|
|
170
|
+
file: rel,
|
|
171
|
+
status: 'file-lines',
|
|
172
|
+
detail: `${lines} lines, over the ceiling of ${errorAt}`,
|
|
173
|
+
remedy: 'split the file, or name its generator in limits.fileLines.generated',
|
|
174
|
+
});
|
|
175
|
+
} else if (lines > warnAt) {
|
|
176
|
+
findings.push({
|
|
177
|
+
file: rel,
|
|
178
|
+
status: 'file-lines-warn',
|
|
179
|
+
detail: `${lines} lines, over the review trigger of ${warnAt}`,
|
|
180
|
+
remedy: 'say why the file stays whole, or split it',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return findings;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function requiredFile(root, check) {
|
|
188
|
+
const full = resolve(root, check.arg);
|
|
189
|
+
const rel = relative(root, full);
|
|
190
|
+
if (rel.startsWith('..') || rel.includes(`..${sep}`)) {
|
|
191
|
+
return [{
|
|
192
|
+
file: check.source,
|
|
193
|
+
status: 'required-file',
|
|
194
|
+
detail: `${check.arg} is outside the repository`,
|
|
195
|
+
remedy: 'name a path inside the repository',
|
|
196
|
+
}];
|
|
197
|
+
}
|
|
198
|
+
const info = await stat(full).catch(() => null);
|
|
199
|
+
if (info?.isFile()) return [];
|
|
200
|
+
return [{
|
|
201
|
+
file: check.arg,
|
|
202
|
+
status: 'required-file',
|
|
203
|
+
detail: 'a rule requires this file and it is absent',
|
|
204
|
+
remedy: `add ${check.arg}`,
|
|
205
|
+
}];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function forbidRegex(check, diff) {
|
|
209
|
+
let pattern;
|
|
210
|
+
try {
|
|
211
|
+
pattern = new RegExp(check.arg);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const findings = [];
|
|
216
|
+
for (const file of diff.files) {
|
|
217
|
+
if (file.added.some((line) => pattern.test(line))) {
|
|
218
|
+
findings.push({
|
|
219
|
+
file: file.path,
|
|
220
|
+
status: 'forbid-regex',
|
|
221
|
+
detail: `an added line matches ${check.arg}`,
|
|
222
|
+
remedy: 'take the match out of the change',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function forbiddenPath(check, diff) {
|
|
230
|
+
const hit = diff.files.find((file) => file.path === check.arg || file.path.startsWith(`${check.arg}/`));
|
|
231
|
+
if (!hit) return [];
|
|
232
|
+
return [{
|
|
233
|
+
file: hit.path,
|
|
234
|
+
status: 'forbidden-path',
|
|
235
|
+
detail: `${check.arg} is a path a rule says not to edit`,
|
|
236
|
+
remedy: 'edit the source the rule names, and leave this path unchanged',
|
|
237
|
+
}];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseCheck(expr) {
|
|
241
|
+
const parts = String(expr).trim().split(/\s+/);
|
|
242
|
+
const name = parts[0] ?? '';
|
|
243
|
+
const arg = parts.slice(1).join(' ');
|
|
244
|
+
if (name === 'import-direction') {
|
|
245
|
+
if (arg !== '') return { error: `${CHECK_MARKER} import-direction takes no argument` };
|
|
246
|
+
return { name, arg: '', runnable: false };
|
|
247
|
+
}
|
|
248
|
+
if (!RUNNABLE.has(name)) {
|
|
249
|
+
return { error: `${CHECK_MARKER} names "${name}", which is not a checker; the checkers are ${[...RUNNABLE, 'import-direction'].sort().join(', ')}` };
|
|
250
|
+
}
|
|
251
|
+
if (name !== 'max-file-lines' && arg === '') {
|
|
252
|
+
return { error: `${CHECK_MARKER} ${name} needs its argument` };
|
|
253
|
+
}
|
|
254
|
+
if (name === 'max-file-lines' && arg !== '') {
|
|
255
|
+
return { error: `${CHECK_MARKER} max-file-lines takes the repository's limits.fileLines, not an argument` };
|
|
256
|
+
}
|
|
257
|
+
if (name === 'forbid-regex') {
|
|
258
|
+
try {
|
|
259
|
+
RegExp(arg);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { error: `${CHECK_MARKER} forbid-regex is not a regular expression (${error.message})` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if ((name === 'forbidden-path-edit' || name === 'required-file') && (arg.startsWith('/') || arg.split('/').includes('..'))) {
|
|
265
|
+
return { error: `${CHECK_MARKER} ${name} needs a path inside the repository` };
|
|
266
|
+
}
|
|
267
|
+
return { name, arg, runnable: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fenced(unit) {
|
|
271
|
+
return /^\s*(```|~~~)/.test(unit);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function markersIn(unit) {
|
|
275
|
+
return [...unit.matchAll(MARKER)].map((m) => m[1]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stripMarkers(unit) {
|
|
279
|
+
return unit.replace(MARKER_AT, '').replace(/[ \t]+$/gm, '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Lines of a body, fences kept whole, list items split from their neighbours. */
|
|
283
|
+
function units(body) {
|
|
284
|
+
const lines = String(body ?? '').split('\n');
|
|
285
|
+
const out = [];
|
|
286
|
+
let fenced = false;
|
|
287
|
+
let current = [];
|
|
288
|
+
const flush = () => {
|
|
289
|
+
if (current.length > 0) out.push(current.join('\n'));
|
|
290
|
+
current = [];
|
|
291
|
+
};
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
294
|
+
if (!fenced) flush();
|
|
295
|
+
current.push(line);
|
|
296
|
+
fenced = !fenced;
|
|
297
|
+
if (!fenced) flush();
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (fenced) {
|
|
301
|
+
current.push(line);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (line.trim() === '') {
|
|
305
|
+
flush();
|
|
306
|
+
out.push(line);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ITEM_START.test(line) && current.length > 0) flush();
|
|
310
|
+
current.push(line);
|
|
311
|
+
}
|
|
312
|
+
flush();
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function lineCount(text) {
|
|
317
|
+
if (text.length === 0) return 0;
|
|
318
|
+
const n = text.split('\n').length;
|
|
319
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function exempt(rel, prefixes) {
|
|
323
|
+
return prefixes.some((prefix) => rel === prefix || rel.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function tracked(root) {
|
|
327
|
+
try {
|
|
328
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root });
|
|
329
|
+
return stdout.split('\0').filter((p) => p !== '');
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function workDiff(root) {
|
|
336
|
+
let text = '';
|
|
337
|
+
try {
|
|
338
|
+
const { stdout } = await exec('git', ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], { cwd: root, maxBuffer: 8 * 1024 * 1024 });
|
|
339
|
+
text = stdout;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error.code === 'ENOENT') return null;
|
|
342
|
+
text = typeof error.stdout === 'string' ? error.stdout : '';
|
|
343
|
+
}
|
|
344
|
+
const files = parseDiff(text);
|
|
345
|
+
let untracked = [];
|
|
346
|
+
try {
|
|
347
|
+
const { stdout } = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root });
|
|
348
|
+
untracked = stdout.split('\0').filter((p) => p !== '');
|
|
349
|
+
} catch {
|
|
350
|
+
return { files };
|
|
351
|
+
}
|
|
352
|
+
for (const path of untracked) {
|
|
353
|
+
let raw;
|
|
354
|
+
try {
|
|
355
|
+
raw = await readFile(resolve(root, path));
|
|
356
|
+
} catch {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (raw.includes(0)) continue;
|
|
360
|
+
files.push({ path, added: raw.toString('utf8').split('\n') });
|
|
361
|
+
}
|
|
362
|
+
return { files };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseDiff(text) {
|
|
366
|
+
const files = [];
|
|
367
|
+
let current = null;
|
|
368
|
+
for (const line of text.split('\n')) {
|
|
369
|
+
const header = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
370
|
+
if (header) {
|
|
371
|
+
current = { path: header[2], added: [] };
|
|
372
|
+
files.push(current);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (current && line.startsWith('+') && !line.startsWith('+++')) current.added.push(line.slice(1));
|
|
376
|
+
}
|
|
377
|
+
return files;
|
|
378
|
+
}
|