crewforth 3.2.0-rc.2 → 3.2.0-rc.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/CHANGELOG.md +100 -3
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +10 -1
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +3 -3
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +13 -2
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +62 -1
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-json.awk +4 -0
- package/kit/eval/lib/statusline.sh +1 -1
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +142 -20
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +32 -23
- package/kit/hooks/guard-bash.sh +36 -21
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/settings.json +12 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +74 -22
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +69 -7
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +32 -7
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +34 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// stacks.mjs — modules, their declared dependencies, their imports and their references to each other, for the
|
|
2
|
-
//
|
|
2
|
+
// six stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
|
|
3
3
|
//
|
|
4
4
|
// A module is { id, name, stack, dir, manifest, tech[], evidence[], deps[], refs[], imports[], entry, entryEv,
|
|
5
5
|
// aliases[], selfNames[], files[] }.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (a namespace prefix, a Gradle line read by pattern).
|
|
11
11
|
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { parseJsonc, parseToml, parseXml, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
|
|
13
|
+
import { parseJsonc, parseToml, parseXml, parseYaml, yGet, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
|
|
14
14
|
import { ev, evFirst, lineOf, isTestPath } from './project.mjs';
|
|
15
15
|
|
|
16
16
|
const P = path.posix;
|
|
@@ -20,7 +20,7 @@ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
20
20
|
const isComment = (l) => /^\s*(\/\/|#|\*|\/\*|<!--)/.test(l);
|
|
21
21
|
|
|
22
22
|
export const SOURCE_EXT = {
|
|
23
|
-
node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/,
|
|
23
|
+
node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/, dart: /\.dart$/,
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
function mod(stack, name, manifest, dir) {
|
|
@@ -28,6 +28,22 @@ function mod(stack, name, manifest, dir) {
|
|
|
28
28
|
imports: [], entry: false, entryEv: null, aliases: [], selfNames: [name], files: [] };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* What each module is built with, from rules/tech.json: a package its manifest declares, or a file at its root.
|
|
33
|
+
* Labels are words a newcomer reads (Hono, Cloudflare Workers, Flutter), not versions; frameworks before tools, in
|
|
34
|
+
* the order of the rules. Before, only nine npm frameworks were named and a Node module read as "node".
|
|
35
|
+
*/
|
|
36
|
+
export function applyTech(st, pj, rules) {
|
|
37
|
+
const has = new Set(pj.files);
|
|
38
|
+
for (const m of st.modules) {
|
|
39
|
+
const deps = new Set(m.deps.map((d) => `${d.eco}\t${d.name}`));
|
|
40
|
+
for (const [eco, list] of Object.entries(rules?.packages ?? {})) for (const [name, label] of list) if (deps.has(`${eco}\t${name}`)) m.tech.add(label);
|
|
41
|
+
const base = m.dir === '.' ? '' : `${m.dir}/`;
|
|
42
|
+
for (const [file, label] of rules?.files ?? []) if (has.has(`${base}${file}`)) m.tech.add(label);
|
|
43
|
+
if (m.stack === 'node' && !m.tech.has('TypeScript') && m.files.some((f) => /\.(ts|tsx|mts|cts)$/.test(f))) m.tech.add('TypeScript');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
// A version the map may show. A dependency can be declared by URL (git+https://token@host/repo.git#v1), and a URL
|
|
32
48
|
// is never copied out: it reads as 'url', which no detector compares.
|
|
33
49
|
export function cleanVersion(v) {
|
|
@@ -48,7 +64,7 @@ function runnable(m) {
|
|
|
48
64
|
|
|
49
65
|
// Hand each source file to the deepest module of its stack whose directory holds it.
|
|
50
66
|
// A file under a sample project's own manifest (left out of discovery) belongs to that sample, so to nobody here.
|
|
51
|
-
const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)
|
|
67
|
+
const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|pubspec\.yaml)$/;
|
|
52
68
|
function own(modules, files, stack) {
|
|
53
69
|
const mine = modules.filter((m) => m.stack === stack).sort((a, b) => b.dir.length - a.dir.length);
|
|
54
70
|
const samples = files.filter((f) => MANIFEST.test(f) && isTestPath(f)).map(dirOf);
|
|
@@ -76,9 +92,6 @@ function nodeStack(pj, out) {
|
|
|
76
92
|
if (!v[sect] || typeof v[sect] !== 'object') continue;
|
|
77
93
|
for (const [dn, ver] of Object.entries(v[sect])) m.deps.push({ eco: 'npm', name: dn, version: cleanVersion(ver), dev, peer: sect === 'peerDependencies', ev: ev(f, L([sect, dn]), dn) });
|
|
78
94
|
}
|
|
79
|
-
for (const fw of ['next', 'react', 'vue', 'svelte', '@angular/core', 'express', 'fastify', '@nestjs/core', 'vite']) {
|
|
80
|
-
const d = m.deps.find((x) => x.name === fw && x.version !== 'url'); if (d) m.tech.add(`${fw}@${d.version.replace(/^[\^~>=<\s]+/, '')}`);
|
|
81
|
-
}
|
|
82
95
|
if (v.bin) { m.entry = true; m.entryEv = ev(f, L(['bin']), 'bin'); }
|
|
83
96
|
else if (v.scripts && (v.scripts.start || v.scripts.dev)) { const k = v.scripts.start ? 'start' : 'dev'; m.entry = true; m.entryEv = ev(f, L(['scripts', k]), k); }
|
|
84
97
|
else if (v.workspaces) { m.entry = true; m.entryEv = ev(f, L(['workspaces']), 'workspaces'); }
|
|
@@ -265,6 +278,98 @@ function goStack(pj, out) {
|
|
|
265
278
|
}
|
|
266
279
|
}
|
|
267
280
|
|
|
281
|
+
// ---- Dart / Flutter -------------------------------------------------------------------------------------------
|
|
282
|
+
// One module per pubspec.yaml. A `path:` dependency names another module of this repository (a reference); a
|
|
283
|
+
// `sdk: flutter` dependency makes it a Flutter module. Its .dart files import `package:<name>/…`: another module's
|
|
284
|
+
// name is a reference, anything else a package it uses; `dart:` is the SDK. lib/main.dart or bin/ is an entry, and
|
|
285
|
+
// so is a tool/ script with a main() (`dart run tool/x.dart` is how a package's own command is run).
|
|
286
|
+
// Before this reader, a Flutter repository's app and its packages were not on the map at all (measured on a real
|
|
287
|
+
// project: 5 Dart modules, 949 .dart files, none drawn).
|
|
288
|
+
function dartStack(pj, out) {
|
|
289
|
+
const made = [];
|
|
290
|
+
for (const f of pj.manifests.filter((x) => P.basename(x) === 'pubspec.yaml')) {
|
|
291
|
+
const src = pj.read(f); if (!src) continue;
|
|
292
|
+
let y; try { y = parseYaml(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `pubspec.yaml outside the subset this scanner reads: ${e.message}` }); continue; }
|
|
293
|
+
const nm = yGet(y, 'name');
|
|
294
|
+
if (!nm || nm.v.t !== 'str' || !nm.v.v) { out.notMeasured.push({ what: f, reason: 'pubspec.yaml has no name' }); continue; }
|
|
295
|
+
const m = mod('dart', nm.v.v, f, dirOf(f));
|
|
296
|
+
m.evidence.push(ev(f, nm.line, nm.v.v));
|
|
297
|
+
const paths = [];
|
|
298
|
+
for (const [key, dev] of [['dependencies', false], ['dev_dependencies', true]]) {
|
|
299
|
+
const block = yGet(y, key); if (!block || block.v.t !== 'map') continue;
|
|
300
|
+
for (const d of block.v.e) {
|
|
301
|
+
const e = ev(f, d.line, d.k);
|
|
302
|
+
if (d.v.t === 'map') {
|
|
303
|
+
const sdk = yGet(d.v, 'sdk'); const pth = yGet(d.v, 'path');
|
|
304
|
+
if (sdk && sdk.v.t === 'str') { if (sdk.v.v === 'flutter' && d.k === 'flutter') m.tech.add('Flutter'); continue; }
|
|
305
|
+
if (pth && pth.v.t === 'str') { paths.push({ to: P.normalize(P.join(m.dir === '.' ? '' : m.dir, pth.v.v)), name: d.k, ev: e }); continue; }
|
|
306
|
+
const v = yGet(d.v, 'version'); m.deps.push({ eco: 'pub', name: d.k, version: v && v.v.t === 'str' ? cleanVersion(v.v.v) : '', dev, ev: e });
|
|
307
|
+
} else {
|
|
308
|
+
m.deps.push({ eco: 'pub', name: d.k, version: d.v.t === 'str' ? cleanVersion(d.v.v) : '', dev, ev: e });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (yGet(y, 'flutter')) m.tech.add('Flutter');
|
|
313
|
+
m._paths = paths;
|
|
314
|
+
out.modules.push(m); made.push(m);
|
|
315
|
+
}
|
|
316
|
+
if (!made.length) return;
|
|
317
|
+
own(out.modules, pj.files, 'dart');
|
|
318
|
+
const byName = new Map(made.map((m) => [m.name, m]));
|
|
319
|
+
for (const m of made) {
|
|
320
|
+
for (const p of m._paths) {
|
|
321
|
+
const t = made.find((o) => o.dir === p.to) ?? byName.get(p.name);
|
|
322
|
+
if (t && t !== m) m.refs.push({ to: t.id, toMod: t, ev: p.ev, confidence: 'evidence' });
|
|
323
|
+
}
|
|
324
|
+
delete m._paths;
|
|
325
|
+
for (const f of m.files) {
|
|
326
|
+
if (/(^|\/)(lib\/main\.dart|bin\/[^/]+\.dart)$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1)) && !m.entry) {
|
|
327
|
+
m.entry = true; m.entryEv = evFirst(f, pj.read(f)?.lines ?? []);
|
|
328
|
+
}
|
|
329
|
+
if (!m.entry && /^tool\/[^/]+\.dart$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1))) {
|
|
330
|
+
const i = (pj.read(f)?.lines ?? []).findIndex((l) => /^\s*(?:Future<void>|void)\s+main\s*\(/.test(l));
|
|
331
|
+
if (i >= 0) { m.entry = true; m.entryEv = ev(f, i + 1, 'main'); }
|
|
332
|
+
}
|
|
333
|
+
const src = pj.read(f); if (!src) continue;
|
|
334
|
+
src.lines.forEach((l, i) => {
|
|
335
|
+
const x = /^\s*(?:import|export)\s+['"]package:([A-Za-z0-9_]+)\/[^'"]*['"]/.exec(l);
|
|
336
|
+
if (!x) return;
|
|
337
|
+
const t = byName.get(x[1]);
|
|
338
|
+
if (t) { if (t !== m && !m.refs.some((r) => r.toMod === t)) m.refs.push({ to: t.id, toMod: t, ev: ev(f, i + 1, x[1]), confidence: 'evidence' }); return; }
|
|
339
|
+
if (x[1] === m.name) return;
|
|
340
|
+
m.imports.push({ spec: x[1], root: x[1], ev: ev(f, i + 1, x[1]), file: f });
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Flutter declares packages nothing imports (cupertino_icons is a font, flutter_lints a lint set, build_runner a
|
|
345
|
+
// command), so an unused-package rule would mostly be wrong here: it is not run, and that is said.
|
|
346
|
+
if (out.modules.some((m) => m.stack === 'dart')) out.notMeasured.push({ what: 'Dart package usage (dep-unused, dep-undeclared)', reason: 'Flutter declares packages that no file imports (fonts, lint sets, code generators), so a declared package is not proven unused' });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A Flutter app holds its platforms as folders of their own: android/ is a Gradle project, ios/ an Xcode one. They
|
|
350
|
+
// are how the app is built for each platform, not modules of the repository, so they are folded into the app: its
|
|
351
|
+
// tech says Android / iOS, and the Gradle modules under it are not drawn on their own (measured on a real project:
|
|
352
|
+
// two "unplaced" Gradle modules beside a Flutter app the map did not show).
|
|
353
|
+
function foldPlatforms(pj, out) {
|
|
354
|
+
const apps = out.modules.filter((m) => m.stack === 'dart' && m.tech.has('Flutter'));
|
|
355
|
+
if (!apps.length) return;
|
|
356
|
+
const drop = new Set();
|
|
357
|
+
for (const a of apps) {
|
|
358
|
+
const base = a.dir === '.' ? '' : `${a.dir}/`;
|
|
359
|
+
for (const m of out.modules) {
|
|
360
|
+
if (m === a || m.stack !== 'java') continue;
|
|
361
|
+
if (m.dir === `${base}android` || m.dir.startsWith(`${base}android/`)) { drop.add(m); a.tech.add('Android'); }
|
|
362
|
+
}
|
|
363
|
+
if (pj.files.some((f) => f.startsWith(`${base}ios/`))) a.tech.add('iOS');
|
|
364
|
+
}
|
|
365
|
+
if (!drop.size) return;
|
|
366
|
+
out.modules = out.modules.filter((m) => !drop.has(m));
|
|
367
|
+
for (const m of out.modules) m.refs = m.refs.filter((r) => !drop.has(r.toMod));
|
|
368
|
+
// The Gradle notes were about those folders; with no Java module left on the map they speak of nothing.
|
|
369
|
+
if (!out.modules.some((m) => m.stack === 'java')) out.notMeasured = out.notMeasured.filter((x) => !JAVA_NOTES.has(x.what));
|
|
370
|
+
}
|
|
371
|
+
const JAVA_NOTES = new Set(['Gradle build logic', 'Java/Kotlin package usage (dep-unused, dep-undeclared)']);
|
|
372
|
+
|
|
268
373
|
// ---- .NET ---------------------------------------------------------------------------------------------------
|
|
269
374
|
function dotnetStack(pj, out) {
|
|
270
375
|
const central = new Map();
|
|
@@ -425,12 +530,13 @@ export function scanStacks(pj0) {
|
|
|
425
530
|
// Its source files still belong to the module that holds them (a test imports that module's dependencies).
|
|
426
531
|
const pj = { ...pj0, manifests: pj0.files.filter((f) => !isTestPath(f)) };
|
|
427
532
|
const out = { modules: [], notMeasured: [], globalAliases: [] };
|
|
428
|
-
for (const fn of [nodeStack, pythonStack, goStack, dotnetStack, javaStack]) {
|
|
533
|
+
for (const fn of [nodeStack, pythonStack, goStack, dartStack, dotnetStack, javaStack]) {
|
|
429
534
|
try { fn(pj, out); } catch (e) {
|
|
430
535
|
if (!(e instanceof Unsupported)) throw e;
|
|
431
536
|
out.notMeasured.push({ what: fn.name, reason: e.message });
|
|
432
537
|
}
|
|
433
538
|
}
|
|
539
|
+
foldPlatforms(pj, out);
|
|
434
540
|
// Two manifests with one name (a copy, a fork in a subdirectory) would share an id: the directory tells them apart.
|
|
435
541
|
const count = new Map();
|
|
436
542
|
for (const m of out.modules) count.set(m.id, (count.get(m.id) || 0) + 1);
|
|
@@ -117,7 +117,7 @@ export function configuration(pj, st, rules, hostOf, ex, out) {
|
|
|
117
117
|
src.lines.forEach((l, i) => {
|
|
118
118
|
const m = /^\s*(ENV|ARG)\s+([A-Za-z_][A-Za-z0-9_]*)/i.exec(l); if (m) def(m[2], f, i + 1, 'dockerfile');
|
|
119
119
|
const from = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(l);
|
|
120
|
-
if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add(
|
|
120
|
+
if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add('Docker'); } // the image itself is the line's evidence
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -186,6 +186,52 @@ export function codeUrls(pj, st, hostOf, ex, out) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// ---- one module calling another at the address it is served on -----------------------------------------------
|
|
190
|
+
// A module says where it is served in its own deployment file: wrangler routes and custom domains (Cloudflare
|
|
191
|
+
// Workers), the app of fly.toml (<app>.fly.dev). Another module that writes that host, quoted on its own or as the
|
|
192
|
+
// host of a URL, calls it: an app that builds Uri(host: 'api.x.app') never spells a URL, and before this a call
|
|
193
|
+
// to the project's own server was drawn as an external API, or not at all.
|
|
194
|
+
const SERVED_FILES = /(^|\/)(wrangler\.toml|wrangler\.jsonc?|fly\.toml)$/;
|
|
195
|
+
export function servedCalls(pj, st, ex, out) {
|
|
196
|
+
const served = new Map(); // host -> module
|
|
197
|
+
for (const m of st.modules) {
|
|
198
|
+
for (const f of pj.files.filter((x) => SERVED_FILES.test(x) && dirOf(x) === m.dir)) {
|
|
199
|
+
const src = pj.read(f); if (!src) continue;
|
|
200
|
+
src.lines.forEach((l, i) => {
|
|
201
|
+
if (isComment(l)) return;
|
|
202
|
+
const hosts = [];
|
|
203
|
+
for (const x of l.matchAll(/["']?(?:pattern|route)["']?\s*[=:]\s*["']([^"']+)["']/g)) hosts.push(x[1]);
|
|
204
|
+
if (/fly\.toml$/.test(f)) { const a = /^\s*app\s*=\s*["']([a-z0-9-]+)["']/.exec(l); if (a) hosts.push(`${a[1]}.fly.dev`); }
|
|
205
|
+
for (const raw of hosts) {
|
|
206
|
+
const h = raw.replace(/^https?:\/\//, '').replace(/^\*\.?/, '').split('/')[0].toLowerCase();
|
|
207
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(h) || isLocalHost(h) || served.has(h)) continue;
|
|
208
|
+
served.set(h, m); m.evidence.push(ev(f, i + 1, raw));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
out.served = served;
|
|
214
|
+
if (!served.size) return;
|
|
215
|
+
for (const [h, to] of served) {
|
|
216
|
+
const q = h.replace(/[.]/g, '\\.');
|
|
217
|
+
const re = new RegExp(`(["'\`])${q}\\1|://${q}(?=[/:"'\`]|$)`);
|
|
218
|
+
for (const m of st.modules) {
|
|
219
|
+
if (m === to) continue;
|
|
220
|
+
for (const f of m.files) {
|
|
221
|
+
if (isTestPath(f)) continue;
|
|
222
|
+
const src = pj.read(f); if (!src) continue;
|
|
223
|
+
src.lines.forEach((l, i) => { if (!isComment(l) && re.test(l)) out.edges.push({ from: m.id, to: to.id, kind: 'calls', confidence: 'evidence', via: 'code', evidence: [ev(f, i + 1, h)] }); });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// The host was read as an external API: it is the project's own module, so its edges go there and the node goes.
|
|
227
|
+
const id = `ext:http-api:${h}`;
|
|
228
|
+
if (!ex.ext.has(id)) continue;
|
|
229
|
+
for (const e of out.edges) if (e.to === id) { e.to = to.id; e.kind = 'calls'; }
|
|
230
|
+
out.edges = out.edges.filter((e) => e.from !== e.to);
|
|
231
|
+
ex.ext.delete(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
189
235
|
// ---- endpoints ----------------------------------------------------------------------------------------------
|
|
190
236
|
// A path is compared in one spelling: lower case, one leading slash, no trailing slash, every parameter ({id},
|
|
191
237
|
// :id, ${id}, <int:id>, {id:int}) as {}. A path whose start was built at run time (a variable before the first
|
|
@@ -322,6 +368,21 @@ export function endpoints(pj, st, out) {
|
|
|
322
368
|
if (axa && axa.s && !/^https?:/.test(axa.s)) add(consumes, m, ax[2], axa.s, f, axa.at, axa.s);
|
|
323
369
|
});
|
|
324
370
|
}
|
|
371
|
+
// Dart consumers: a Dio client's relative path, and a full URL (http.get(Uri.parse(…)), Uri.https(host, path))
|
|
372
|
+
// only when its host is one this project serves; any other host is someone else's API. `$id` is a parameter.
|
|
373
|
+
if (/\.dart$/.test(f)) {
|
|
374
|
+
const ours = (h) => out.served?.has(h.toLowerCase());
|
|
375
|
+
const dp = (s) => s.replace(/\$\{[^}]*\}|\$\w+/g, '{x}');
|
|
376
|
+
L.forEach((l, i) => {
|
|
377
|
+
if (isComment(l)) return;
|
|
378
|
+
const dio = /\b(\w*[dD]io\w*|_?client|_?api)\.(get|post|put|patch|delete)(?:<[^()]*?>)?\(\s*(['"])(\/[^'"]*)\3/.exec(l);
|
|
379
|
+
if (dio) add(consumes, m, dio[2], dp(dio[4]), f, i, dio[4]);
|
|
380
|
+
const hp = /\.(get|post|put|patch|delete)\(\s*Uri\.parse\(\s*(['"])https?:\/\/([^/'"$]+)(\/[^'"]*)\2/.exec(l);
|
|
381
|
+
if (hp && ours(hp[3])) add(consumes, m, hp[1], dp(hp[4]), f, i, hp[4]);
|
|
382
|
+
const uh = /\bUri\.https?\(\s*(['"])([^'"$]+)\1\s*,\s*(['"])(\/[^'"]*)\3/.exec(l);
|
|
383
|
+
if (uh && ours(uh[2])) add(consumes, m, 'GET', dp(uh[4]), f, i, uh[4]);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
325
386
|
// Python: FastAPI / Flask / Django providers; requests / httpx consumers
|
|
326
387
|
if (/\.py$/.test(f)) {
|
|
327
388
|
const pre = new Map();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Sourced, not run. The ONE rule for where the gate log is, for the gates that write it and everything that reads it
|
|
2
|
+
# (the status line, gate-report, doctor). Studio's reader (server/lib/kit-telemetry.js) follows the same order.
|
|
3
|
+
#
|
|
4
|
+
# 1. CREW_GATE_LOG, when set: used as given (/dev/null turns recording off).
|
|
5
|
+
# 2. .claude/gate-log.tsv, outside a git repository or where git ignores it (a full install ignores .claude/).
|
|
6
|
+
# 3. Otherwise <git dir>/crewforth-gate-log.tsv: inside the repository's own .git, which is never committed.
|
|
7
|
+
# Before 3.2.0 the log was simply not written there, so a project that tracks .claude/ had no record of a
|
|
8
|
+
# refusal, no 🛡 on its status line and no note of a model the gate lowered (seen on a real project).
|
|
9
|
+
#
|
|
10
|
+
# Builtins only on the reading side, which the status line runs every few seconds. The writing side asks git
|
|
11
|
+
# (one or two processes), and only when a decision is being logged.
|
|
12
|
+
|
|
13
|
+
# The repository's git directory, from the project root up, without a process: _CREW_GD, or rc 1.
|
|
14
|
+
crew_git_dir(){
|
|
15
|
+
local d="${1:-.}" n=0 l
|
|
16
|
+
_CREW_GD=""
|
|
17
|
+
while [ "$n" -lt 12 ]; do
|
|
18
|
+
if [ -d "$d/.git" ]; then _CREW_GD="$d/.git"; return 0; fi
|
|
19
|
+
if [ -f "$d/.git" ]; then
|
|
20
|
+
IFS= read -r l < "$d/.git" 2>/dev/null || l=""
|
|
21
|
+
case "$l" in "gitdir: "*) l="${l#gitdir: }"; l="${l%$'\r'}"
|
|
22
|
+
case "$l" in /*|[A-Za-z]:*) ;; *) l="$d/$l" ;; esac
|
|
23
|
+
[ -d "$l" ] && { _CREW_GD="$l"; return 0; } ;; esac
|
|
24
|
+
return 1
|
|
25
|
+
fi
|
|
26
|
+
[ "$d" = / ] && return 1
|
|
27
|
+
d="$d/.."; n=$((n+1))
|
|
28
|
+
done
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Where a gate writes: _CREW_GL ("" = nowhere). Run from the project root.
|
|
33
|
+
crew_gate_log_write(){
|
|
34
|
+
_CREW_GL=""
|
|
35
|
+
if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
|
|
36
|
+
[ -d .claude ] || return 0
|
|
37
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q .claude/gate-log.tsv 2>/dev/null; then
|
|
38
|
+
_CREW_GL=.claude/gate-log.tsv; return 0
|
|
39
|
+
fi
|
|
40
|
+
local g; g="$(git rev-parse --absolute-git-dir 2>/dev/null)" || g=""
|
|
41
|
+
[ -n "$g" ] && [ -d "$g" ] && _CREW_GL="$g/crewforth-gate-log.tsv"
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Where a reader finds it: _CREW_GL ("" = none yet). No process.
|
|
46
|
+
crew_gate_log_read(){
|
|
47
|
+
_CREW_GL=""
|
|
48
|
+
if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
|
|
49
|
+
if [ -f .claude/gate-log.tsv ]; then _CREW_GL=.claude/gate-log.tsv; return 0; fi
|
|
50
|
+
crew_git_dir . && [ -f "$_CREW_GD/crewforth-gate-log.tsv" ] && _CREW_GL="$_CREW_GD/crewforth-gate-log.tsv"
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# json-string.awk — the text on stdin as the inside of a JSON string, printed with no quotes around it and no newline
|
|
2
|
+
# after it. One escaper for every hook that puts dynamic text into its JSON (board-sync.sh, session-rehydrate.sh).
|
|
3
|
+
#
|
|
4
|
+
# printf '%s\n' "$MSG" | LC_ALL=C awk -f json-string.awk
|
|
5
|
+
#
|
|
6
|
+
# jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL, UTF-8, leading/trailing
|
|
7
|
+
# newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only the quote, the backslash
|
|
8
|
+
# and the newline, so a tab in a board item produced JSON the CLI cannot parse (jq rc=5) and the board silently
|
|
9
|
+
# vanished from the session. The trailing newline the caller prints closes the last record, so a value ending in a
|
|
10
|
+
# newline keeps it. A CR that ENDS a line is dropped on purpose: it is a CRLF line ending, not content, and MSYS gawk
|
|
11
|
+
# already drops it on read (text mode) while BSD awk keeps it, measured on Windows; stripping it here makes every OS
|
|
12
|
+
# emit the same bytes. A CR inside a line is still escaped as \r.
|
|
13
|
+
BEGIN { ORS = ""
|
|
14
|
+
for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
|
|
15
|
+
nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
|
|
16
|
+
NR > 1 { print "\\n" }
|
|
17
|
+
{ s = $0; sub(/\r$/, "", s)
|
|
18
|
+
gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
|
|
19
|
+
for (i = 1; i < 32; i++) {
|
|
20
|
+
if (i == 10 || !index(s, ctl[i])) continue
|
|
21
|
+
r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
|
|
22
|
+
out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
|
|
23
|
+
s = out s
|
|
24
|
+
}
|
|
25
|
+
d = sprintf("%c", 127)
|
|
26
|
+
if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
|
|
27
|
+
print s }
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# preset.sh — an organisation's settings for Crewforth in one file, applied to a project.
|
|
3
|
+
#
|
|
4
|
+
# bash preset.sh --apply <name|file.json> from the project root; a name is one of eval/lib/presets/
|
|
5
|
+
# bash preset.sh --check <name|file.json> is it a valid preset? Nothing is written (the installers ask first)
|
|
6
|
+
# bash preset.sh --show the preset in force here, or "none"
|
|
7
|
+
# bash preset.sh --list the presets that ship
|
|
8
|
+
#
|
|
9
|
+
# A preset is plain JSON (see presets/default.json for every key):
|
|
10
|
+
# critical.add / critical.remove paths made critical, or not (globs, as `+ glob` / `- glob`)
|
|
11
|
+
# floors [agent, change, risk, model]: a model a class of work runs on at least
|
|
12
|
+
# verify commands the gate may run as a verify command
|
|
13
|
+
# lang en | tr, the language of what Crewforth says (only when the project has none)
|
|
14
|
+
#
|
|
15
|
+
# Where it goes: .claude/crew-model-rules, the file every reader of these rules already reads (the model gate,
|
|
16
|
+
# agent-outcome's scan, doctor, Studio), as one marked block at the top. The project's own lines stay below it, so
|
|
17
|
+
# the rule is the file's own: for a critical path the last line that matches wins, so a project's `- glob` undoes a
|
|
18
|
+
# preset's `+ glob`; a floor only ever raises, so a preset's floor is a minimum a project can raise and not lower.
|
|
19
|
+
# Applying again replaces the block and nothing else; the same preset twice gives the same bytes.
|
|
20
|
+
#
|
|
21
|
+
# rc 0 = applied (or shown) · 1 = the preset is not valid, or names nothing; nothing was written · 2 = usage
|
|
22
|
+
# Every JSON read is Crewforth's own reader (settings-json.awk): no jq, no python.
|
|
23
|
+
set -u
|
|
24
|
+
_here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
|
|
25
|
+
SJ="$_here/settings-json.awk"; DIR="$_here/presets"
|
|
26
|
+
RULES=.claude/crew-model-rules; CONF=.claude/kit.conf
|
|
27
|
+
BEGIN_MARK='# ---- preset:'; END_MARK='# ---- end of preset (lines below are this project'"'"'s own and win over it, see preset.sh) ----'
|
|
28
|
+
|
|
29
|
+
die(){ echo "preset: $*" >&2; exit 1; }
|
|
30
|
+
jget(){ awk -v op=get -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
|
|
31
|
+
jstrings(){ awk -v op=strings -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
|
|
32
|
+
jlen(){ local n; n="$(awk -v op=len -v path="$1" -f "$SJ" "$2" 2>/dev/null)"; printf '%s' "${n:-0}"; }
|
|
33
|
+
|
|
34
|
+
case "${1:-}" in
|
|
35
|
+
--list) for f in "$DIR"/*.json; do [ -f "$f" ] || continue; n="${f##*/}"; printf '%s\n' "${n%.json}"; done; exit 0 ;;
|
|
36
|
+
--show)
|
|
37
|
+
[ -f "$RULES" ] || { echo none; exit 0; }
|
|
38
|
+
while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$BEGIN_MARK "*) l="${l#"$BEGIN_MARK "}"; echo "${l%% *}"; exit 0 ;; esac; done < "$RULES"
|
|
39
|
+
echo none; exit 0 ;;
|
|
40
|
+
--apply|--check) [ -n "${2:-}" ] || { echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2; } ;;
|
|
41
|
+
*) echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2 ;;
|
|
42
|
+
esac
|
|
43
|
+
MODE="$1"
|
|
44
|
+
[ "$MODE" = --check ] || [ -d .claude ] || die "no .claude/ here: run it from the root of a project Crewforth is installed in"
|
|
45
|
+
|
|
46
|
+
# ---- which file ----
|
|
47
|
+
P="$2"
|
|
48
|
+
case "$P" in *.json|*/*) ;; *) P="$DIR/$P.json" ;; esac
|
|
49
|
+
[ -f "$P" ] || die "no preset at $P (the ones that ship: $(bash "$0" --list 2>/dev/null | tr '\n' ' '))"
|
|
50
|
+
awk -v op=validate -f "$SJ" "$P" 2>/dev/null || die "$P is not a JSON object"
|
|
51
|
+
NAME="$(jget name "$P")"; NAME="${NAME#\"}"; NAME="${NAME%\"}"
|
|
52
|
+
case "$NAME" in ''|*[!a-z0-9-]*) die "$P: name must be lower case letters, digits and -" ;; esac
|
|
53
|
+
|
|
54
|
+
# ---- what it says, checked before anything is written ----
|
|
55
|
+
LINES=()
|
|
56
|
+
for k in add remove; do
|
|
57
|
+
sign='+'; [ "$k" = remove ] && sign='-'
|
|
58
|
+
while IFS= read -r g; do
|
|
59
|
+
[ -n "$g" ] || continue
|
|
60
|
+
case "$g" in *[[:space:]\#]*) die "$P: critical.$k holds '$g', which has a space or a #" ;; esac
|
|
61
|
+
LINES+=("$sign $g")
|
|
62
|
+
done < <(jstrings "critical.$k" "$P")
|
|
63
|
+
done
|
|
64
|
+
nf="$(jlen floors "$P")"; i=0
|
|
65
|
+
while [ "$i" -lt "$nf" ]; do
|
|
66
|
+
f=(); while IFS= read -r w; do f+=("$w"); done < <(jstrings "floors.$i" "$P")
|
|
67
|
+
[ "${#f[@]}" = 4 ] || die "$P: floors[$i] is not [agent, change, risk, model]"
|
|
68
|
+
case "${f[3]}" in haiku|sonnet|opus) ;; *) die "$P: floors[$i] names the model '${f[3]}', not haiku, sonnet or opus" ;; esac
|
|
69
|
+
for w in "${f[@]}"; do case "$w" in ''|*[[:space:]\#]*) die "$P: floors[$i] has an empty word, a space or a #" ;; esac; done
|
|
70
|
+
LINES+=("floor ${f[0]} ${f[1]} ${f[2]} ${f[3]}"); i=$((i+1))
|
|
71
|
+
done
|
|
72
|
+
while IFS= read -r c; do
|
|
73
|
+
[ -n "$c" ] || continue
|
|
74
|
+
case "$c" in *[\;\&\|\<\>\`\$\#]*) die "$P: verify '$c' has a shell operator or a #; the gate runs a verify command as one command" ;; esac
|
|
75
|
+
LINES+=("verify $c")
|
|
76
|
+
done < <(jstrings verify "$P")
|
|
77
|
+
LANGV="$(jget lang "$P")"; LANGV="${LANGV#\"}"; LANGV="${LANGV%\"}"
|
|
78
|
+
case "$LANGV" in ''|null|en|tr) ;; *) die "$P: lang is '$LANGV', not en or tr" ;; esac
|
|
79
|
+
for k in $(awk -v op=keys -f "$SJ" "$P" 2>/dev/null); do
|
|
80
|
+
case "$k" in about|name|critical|floors|verify|lang) ;; *) die "$P: unknown key '$k' (about, name, critical, floors, verify, lang)" ;; esac
|
|
81
|
+
done
|
|
82
|
+
|
|
83
|
+
[ "$MODE" = --check ] && { echo "preset: $NAME is valid — ${#LINES[@]} line(s)"; exit 0; }
|
|
84
|
+
|
|
85
|
+
# ---- the block, at the top of the rules file; the project's own lines kept below it ----
|
|
86
|
+
OWN=""
|
|
87
|
+
if [ -f "$RULES" ]; then
|
|
88
|
+
skip=0
|
|
89
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
90
|
+
case "$l" in "$BEGIN_MARK "*) skip=1; continue ;; "$END_MARK") skip=0; continue ;; esac
|
|
91
|
+
[ "$skip" = 1 ] || OWN+="$l"$'\n'
|
|
92
|
+
done < "$RULES"
|
|
93
|
+
fi
|
|
94
|
+
T="$RULES.preset.$$"
|
|
95
|
+
{
|
|
96
|
+
printf '%s %s (written by preset.sh; apply again to change it, edit your own lines below) ----\n' "$BEGIN_MARK" "$NAME"
|
|
97
|
+
for l in ${LINES[@]+"${LINES[@]}"}; do printf '%s\n' "$l"; done
|
|
98
|
+
printf '%s\n' "$END_MARK"
|
|
99
|
+
printf '%s' "$OWN"
|
|
100
|
+
} > "$T" && mv -f "$T" "$RULES" || { rm -f "$T"; die "could not write $RULES"; }
|
|
101
|
+
|
|
102
|
+
# ---- kit.conf: the preset's language, only where the project has none. Which preset is in force is said once, by
|
|
103
|
+
# the block's own first line (--show reads it): kit.conf is rewritten by every update, the rules file is not.
|
|
104
|
+
if [ -f "$CONF" ] && case "$LANGV" in en|tr) true ;; *) false ;; esac; then
|
|
105
|
+
hadlang=0; while IFS= read -r l || [ -n "$l" ]; do case "$l" in lang=*) hadlang=1 ;; esac; done < "$CONF"
|
|
106
|
+
[ "$hadlang" = 0 ] && printf 'lang=%s\n' "$LANGV" >> "$CONF"
|
|
107
|
+
fi
|
|
108
|
+
echo "preset: $NAME applied — ${#LINES[@]} line(s) in $RULES; your own lines below the block were kept"
|
|
109
|
+
exit 0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Crewforth as it ships: no line of its own. Every key a preset may hold is here, empty. critical.add and critical.remove are globs made critical or not (a critical path runs on opus and only an agent on opus writes there); floors are [agent, change, risk, model] with * for any, a model a class of work runs on at least; verify are commands the model gate may run as a card's verify command; lang (en or tr) is used only where the project has no language yet.",
|
|
3
|
+
"name": "default",
|
|
4
|
+
"critical": { "add": [], "remove": [] },
|
|
5
|
+
"floors": [],
|
|
6
|
+
"verify": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "For one person on their own project: the common test commands may run as a card's verify command, so cheap work can be checked and run on haiku; no path is added.",
|
|
3
|
+
"name": "solo",
|
|
4
|
+
"critical": { "add": [], "remove": [] },
|
|
5
|
+
"floors": [],
|
|
6
|
+
"verify": ["npm test", "pnpm test", "yarn test", "pytest", "go test ./...", "cargo test", "dotnet test", "flutter test"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "For a team that ships to production from this repository: what deploys it is critical too, and the referees of the work run on opus whatever the risk.",
|
|
3
|
+
"name": "strict",
|
|
4
|
+
"critical": { "add": ["infra", "deploy", "terraform", "k8s", "helm", ".github/workflows"], "remove": [] },
|
|
5
|
+
"floors": [
|
|
6
|
+
["crew-review-agent", "*", "*", "opus"],
|
|
7
|
+
["*", "test-write", "*", "opus"],
|
|
8
|
+
["*", "audit", "*", "opus"]
|
|
9
|
+
],
|
|
10
|
+
"verify": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Sourced, not run. What a risky command would have touched, in one line, for the gate that stops or asks about it:
|
|
2
|
+
# the user reads "it would delete 7 uncommitted changes", not only "git reset --hard".
|
|
3
|
+
#
|
|
4
|
+
# crew_preview <kind> <command> -> _CREW_PV ("" when there is nothing to say)
|
|
5
|
+
# reset git reset --hard: the uncommitted changes to tracked files it discards, and the commits it drops
|
|
6
|
+
# from the branch when it names a target (git reset --hard HEAD~3)
|
|
7
|
+
# clean git clean -f…: the same command as a dry run (-n, with its -d / -x / -X), and how many paths it lists
|
|
8
|
+
# push git push --force: the commits on the remote branch the local one does not have, as of the last fetch
|
|
9
|
+
# rm rm -r…: the files under each named target inside the project; a target outside it, or a pattern, is
|
|
10
|
+
# not counted and the line says so
|
|
11
|
+
# commit an approved commit: the files and lines it takes (the staged ones, the tracked changes too for -a)
|
|
12
|
+
# push-ask an approved push: the commits it sends, the first three by subject
|
|
13
|
+
#
|
|
14
|
+
# READ ONLY. Every command here only reads (status, diff --stat, rev-list, log, clean -n, find), nothing fetches,
|
|
15
|
+
# and a count of files stops at 10 000. The caller runs this in a subshell and prints what comes back: a preview
|
|
16
|
+
# that fails, or ends the shell, leaves the gate's decision as it was. It never decides anything.
|
|
17
|
+
|
|
18
|
+
_PV_MAX=10000
|
|
19
|
+
|
|
20
|
+
# Is this a repository, without a process where the gate log's helper is loaded (it is, in the gates).
|
|
21
|
+
_pv_repo(){ if declare -F crew_git_dir >/dev/null 2>&1; then crew_git_dir .; else git rev-parse --is-inside-work-tree >/dev/null 2>&1; fi; }
|
|
22
|
+
|
|
23
|
+
# The current branch's upstream as remote/branch, read from HEAD and the config with builtins: _PV_UP ("" = none).
|
|
24
|
+
# A push is asked about on the hot path of a gate whose cost is pinned per call, so the common case (no upstream
|
|
25
|
+
# yet) opens no process at all. A linked worktree keeps HEAD in its own git dir and the config in the common one.
|
|
26
|
+
_pv_upstream(){
|
|
27
|
+
_PV_UP=""; local gd="" cd l br="" sec="" rem="" mer=""
|
|
28
|
+
declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && gd="$_CREW_GD"
|
|
29
|
+
[ -n "$gd" ] && [ -f "$gd/HEAD" ] || return 1
|
|
30
|
+
IFS= read -r l < "$gd/HEAD" || return 1; l="${l%$'\r'}"
|
|
31
|
+
case "$l" in "ref: refs/heads/"*) br="${l#ref: refs/heads/}" ;; *) return 1 ;; esac
|
|
32
|
+
cd="$gd"; if [ -f "$gd/commondir" ]; then IFS= read -r l < "$gd/commondir"; l="${l%$'\r'}"; case "$l" in /*|[A-Za-z]:*) cd="$l" ;; *) cd="$gd/$l" ;; esac; fi
|
|
33
|
+
[ -f "$cd/config" ] || return 1
|
|
34
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
35
|
+
l="${l%$'\r'}"; l="${l#"${l%%[![:space:]]*}"}"
|
|
36
|
+
case "$l" in
|
|
37
|
+
"[branch \"$br\"]") sec=1 ;;
|
|
38
|
+
"["*) sec="" ;;
|
|
39
|
+
remote*=*) [ -n "$sec" ] && { rem="${l#*=}"; rem="${rem#"${rem%%[![:space:]]*}"}"; } ;;
|
|
40
|
+
merge*=*) [ -n "$sec" ] && { mer="${l#*=}"; mer="${mer#"${mer%%[![:space:]]*}"}"; } ;;
|
|
41
|
+
esac
|
|
42
|
+
done < "$cd/config"
|
|
43
|
+
[ -n "$rem" ] && [ -n "$mer" ] && [ "$rem" != . ] && _PV_UP="$rem/${mer#refs/heads/}"
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_pv_words(){ # $1 = command -> _PV_W (the words after the git subcommand $2, quotes dropped)
|
|
48
|
+
local s="$1" sub="$2"
|
|
49
|
+
s="${s#*git}"; s="${s#*"$sub"}"; s="${s%%[;&|]*}"; s="${s//\"/}"; s="${s//\'/}"
|
|
50
|
+
_PV_W="$s"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# With pathname expansion off for the whole preview: the words of the command are split, never expanded here, so
|
|
54
|
+
# `rm -rf build/*` is said as a pattern instead of each file the hook's own shell would find (measured on Windows).
|
|
55
|
+
crew_preview(){
|
|
56
|
+
local _pvf=0; case "$-" in *f*) _pvf=1 ;; esac
|
|
57
|
+
set -f; _crew_preview "$@"; [ "$_pvf" = 1 ] || set +f
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
_crew_preview(){
|
|
61
|
+
_CREW_PV=""
|
|
62
|
+
local kind="$1" cmd="$2" n t w a flags line
|
|
63
|
+
case "$kind" in
|
|
64
|
+
reset|clean|push|commit|push-ask)
|
|
65
|
+
_pv_repo || { _CREW_PV="Preview: not a git work tree here, nothing counted."; return 0; } ;;
|
|
66
|
+
esac
|
|
67
|
+
case "$kind" in
|
|
68
|
+
reset)
|
|
69
|
+
n="$(git status --porcelain --untracked-files=no 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
|
|
70
|
+
_pv_words "$cmd" reset; t=""
|
|
71
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) t="$w"; break ;; esac; done
|
|
72
|
+
a=""
|
|
73
|
+
if [ -n "$t" ] && git rev-parse -q --verify "$t^{commit}" >/dev/null 2>&1; then
|
|
74
|
+
a="$(git rev-list --count "$t..HEAD" 2>/dev/null)"; a="${a//[!0-9]/}"
|
|
75
|
+
fi
|
|
76
|
+
_CREW_PV="Preview: it would discard ${n:-0} uncommitted change(s) to tracked files${a:+ and drop $a commit(s) from the branch (back to $t)}."
|
|
77
|
+
;;
|
|
78
|
+
clean)
|
|
79
|
+
_pv_words "$cmd" clean; flags="-n"
|
|
80
|
+
for w in $_PV_W; do case "$w" in
|
|
81
|
+
--) break ;;
|
|
82
|
+
-*) case "$w" in --*) ;; *) case "$w" in *d*) flags="$flags -d" ;; esac; case "$w" in *x*) flags="$flags -x" ;; *X*) flags="$flags -X" ;; esac ;; esac ;;
|
|
83
|
+
esac; done
|
|
84
|
+
# shellcheck disable=SC2086 # the flags are words of our own
|
|
85
|
+
n="$(git clean $flags 2>/dev/null | wc -l)"; n="${n//[!0-9]/}"
|
|
86
|
+
_CREW_PV="Preview: a dry run (git clean $flags) lists ${n:-0} path(s) it would delete."
|
|
87
|
+
;;
|
|
88
|
+
push)
|
|
89
|
+
_pv_words "$cmd" push; local rem="" ref="" up=""
|
|
90
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
|
|
91
|
+
ref="${ref#+}"; ref="${ref##*:}"
|
|
92
|
+
if [ -n "$rem" ] && [ -n "$ref" ]; then up="refs/remotes/$rem/$ref"
|
|
93
|
+
else up="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; fi
|
|
94
|
+
if [ -n "$up" ] && git rev-parse -q --verify "$up^{commit}" >/dev/null 2>&1; then
|
|
95
|
+
n="$(git rev-list --count "HEAD..$up" 2>/dev/null)"; n="${n//[!0-9]/}"; up="${up#refs/remotes/}"
|
|
96
|
+
if [ "${n:-0}" -gt 0 ]; then _CREW_PV="Preview: $up has $n commit(s) this branch does not, as of the last fetch; a forced push would overwrite them."
|
|
97
|
+
else _CREW_PV="Preview: as of the last fetch, $up has no commit this branch lacks; a fetch now could show otherwise."; fi
|
|
98
|
+
else
|
|
99
|
+
_CREW_PV="Preview: no remote branch known for it here (nothing fetched), so what it would overwrite is not counted."
|
|
100
|
+
fi
|
|
101
|
+
;;
|
|
102
|
+
rm)
|
|
103
|
+
local root out="" one c
|
|
104
|
+
root="$(pwd -P 2>/dev/null)"
|
|
105
|
+
_pv_words "x $cmd" rm
|
|
106
|
+
for w in $_PV_W; do
|
|
107
|
+
case "$w" in -*|'') continue ;; *'*'*|*'?'*|'~'*|'$'*) out="${out:+$out; }$w: a pattern, not counted"; continue ;; esac
|
|
108
|
+
# A drive-letter path (D:/x, as Git Bash passes it) is absolute too: measured on Windows, it read as relative
|
|
109
|
+
# and the line said "not there" for a folder that was.
|
|
110
|
+
one="$w"; case "$one" in /*|[A-Za-z]:[/\\]*) ;; *) one="$root/$one" ;; esac
|
|
111
|
+
if [ ! -e "$one" ]; then out="${out:+$out; }$w: not there"; continue; fi
|
|
112
|
+
c="$(cd "$one" 2>/dev/null && pwd -P)" || c="$(cd "$(dirname "$one")" 2>/dev/null && pwd -P)/${one##*/}"
|
|
113
|
+
case "$c/" in "$root"/*) ;; *) out="${out:+$out; }$w: outside the project, not counted"; continue ;; esac
|
|
114
|
+
n="$(find "$one" -type f 2>/dev/null | head -n $((_PV_MAX + 1)) | wc -l)"; n="${n//[!0-9]/}"
|
|
115
|
+
[ "${n:-0}" -gt "$_PV_MAX" ] && n="more than $_PV_MAX"
|
|
116
|
+
out="${out:+$out; }$w: ${n:-0} file(s)"
|
|
117
|
+
done
|
|
118
|
+
[ -n "$out" ] && _CREW_PV="Preview: $out."
|
|
119
|
+
;;
|
|
120
|
+
commit)
|
|
121
|
+
case " $cmd " in *' -a'*|*' --all'*|*' -[A-Za-z]*a'*) line="$(git diff HEAD --shortstat 2>/dev/null)" ;; *) line="$(git diff --cached --shortstat 2>/dev/null)" ;; esac
|
|
122
|
+
line="${line# }"
|
|
123
|
+
_CREW_PV="Preview: ${line:-nothing staged}."
|
|
124
|
+
;;
|
|
125
|
+
push-ask)
|
|
126
|
+
local subj="" k=0 rem="" ref="" src="" tip=HEAD
|
|
127
|
+
# A push that names its remote and branch (git push origin feat/x) sends to that branch, not the upstream; and
|
|
128
|
+
# what it sends is the named LOCAL branch's commits (src of src:dst, or the name itself), not the current one's.
|
|
129
|
+
_pv_words "$cmd" push
|
|
130
|
+
for w in $_PV_W; do case "$w" in -*) ;; *) if [ -z "$rem" ]; then rem="$w"; elif [ -z "$ref" ]; then ref="$w"; fi ;; esac; done
|
|
131
|
+
ref="${ref#+}"; src="${ref%%:*}"; ref="${ref##*:}"
|
|
132
|
+
if [ -n "$src" ] && [ "$src" != HEAD ]; then
|
|
133
|
+
if [ -f "${_CREW_GD:-.git}/refs/heads/$src" ] || git rev-parse -q --verify "refs/heads/$src" >/dev/null 2>&1; then tip="refs/heads/$src"; fi
|
|
134
|
+
fi
|
|
135
|
+
if [ -n "$rem" ] && [ -n "$ref" ] && [ "$ref" != HEAD ]; then
|
|
136
|
+
if [ -f "${_CREW_GD:-.git}/refs/remotes/$rem/$ref" ] || git rev-parse -q --verify "refs/remotes/$rem/$ref" >/dev/null 2>&1; then _PV_UP="$rem/$ref"
|
|
137
|
+
else _CREW_PV="Preview: $rem/$ref is not known here; the push would create it on the remote."; return 0; fi
|
|
138
|
+
else _pv_upstream; fi
|
|
139
|
+
if [ -n "$_PV_UP" ]; then
|
|
140
|
+
# One process: the subjects of what it sends, counted here and the first three named.
|
|
141
|
+
while IFS= read -r line; do k=$((k+1)); [ "$k" -le 3 ] && subj="${subj:+$subj; }${line:0:72}"; done < <(git log --format=%s "$_PV_UP..$tip" 2>/dev/null)
|
|
142
|
+
[ "$k" -gt 3 ] && subj="$subj …"
|
|
143
|
+
_CREW_PV="Preview: it sends $k commit(s) to $_PV_UP${subj:+ — $subj}."
|
|
144
|
+
else
|
|
145
|
+
_CREW_PV="Preview: the branch has no upstream set here; what it sends is not counted."
|
|
146
|
+
fi
|
|
147
|
+
;;
|
|
148
|
+
esac
|
|
149
|
+
return 0
|
|
150
|
+
}
|