crewforth 3.2.0-rc.1 → 3.2.0-rc.10

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -1
@@ -5,18 +5,27 @@
5
5
  // model fill them, and nothing else:
6
6
  // node.narrative { text, cites } what the module is for, in at most two sentences; `cites` are "file:line"
7
7
  // strings, each one a line the scanner itself cited for THAT node (its evidence)
8
+ // and, optionally, `layer` (one of LAYERS: where the module sits) and `domain` (what part of the
9
+ // business it serves, a short phrase), both resting on the same cites
8
10
  // doc.overview { text, cites } a short orientation for someone new to the project; its cites may be any
9
11
  // node's evidence
12
+ // doc.tour [{ node, text, cites }] one to eight stops a newcomer reads in order: each names a node of the
13
+ // map, says in at most two sentences why to look there, and cites that node's own lines
10
14
  // A narrative with no citation, or one citing a line the scan did not read, is refused: the model may describe
11
15
  // what the scan found, not add to it. A scanner field changed by hand is refused too (validate).
12
16
 
13
17
  const MAX_NODE = 400;
14
18
  const MAX_OVERVIEW = 2000;
19
+ const MAX_STOP = 300;
20
+ const MAX_STOPS = 8;
21
+ const MAX_DOMAIN = 60;
22
+ export const LAYERS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
15
23
 
16
- /** The "file:line" a node's evidence lets a narrative cite. */
24
+ /** The "file:line" a narrative of this node may cite. */
17
25
  export function citesOf(node) {
18
26
  const out = new Set();
19
- for (const e of Array.isArray(node?.evidence) ? node.evidence : []) {
27
+ // Its evidence, its entry point and what its README says about it: every line the scan read for this node.
28
+ for (const e of [node?.evidence, node?.entryPoint?.evidence, node?.about?.evidence].flatMap((x) => (Array.isArray(x) ? x : []))) {
20
29
  if (e && typeof e.file === 'string' && Number.isInteger(e.line)) out.add(`${e.file}:${e.line}`);
21
30
  }
22
31
  return out;
@@ -35,11 +44,14 @@ const sentences = (t) => (String(t).trim().match(/[.!?](\s|$)/g) ?? []).length;
35
44
  * Why a narrative is not acceptable, or null when it is (null itself is: not written yet).
36
45
  * @param allowed the set of "file:line" it may cite
37
46
  */
38
- export function problemOf(n, allowed, { max = MAX_NODE, twoSentences = true } = {}) {
47
+ export function problemOf(n, allowed, { max = MAX_NODE, twoSentences = true, module = false } = {}) {
39
48
  if (n === null || n === undefined) return null;
40
49
  if (typeof n !== 'object' || Array.isArray(n)) return 'is not { text, cites }';
41
- const keys = Object.keys(n).sort().join();
42
- if (keys !== 'cites,text') return `has fields other than text and cites (${keys})`;
50
+ const may = module ? ['cites', 'domain', 'layer', 'text'] : ['cites', 'text'];
51
+ const odd = Object.keys(n).filter((k) => !may.includes(k));
52
+ if (odd.length || !('text' in n) || !('cites' in n)) return `has fields other than ${may.join(', ')} (${Object.keys(n).sort().join()})`;
53
+ if ('layer' in n && !LAYERS.includes(n.layer)) return `names the layer ${JSON.stringify(n.layer)}, not one of ${LAYERS.join(', ')}`;
54
+ if ('domain' in n && (typeof n.domain !== 'string' || !n.domain.trim() || n.domain.length > MAX_DOMAIN || /[.!?]/.test(n.domain))) return `has a domain that is not a short phrase (at most ${MAX_DOMAIN} characters, no sentence)`;
43
55
  if (typeof n.text !== 'string' || !n.text.trim()) return 'has no text';
44
56
  if (n.text.length > max) return `is ${n.text.length} characters, more than ${max}`;
45
57
  if (twoSentences && sentences(n.text) > 2) return 'is more than two sentences';
@@ -52,7 +64,24 @@ export function problemOf(n, allowed, { max = MAX_NODE, twoSentences = true } =
52
64
  /** The document without what the model may add: the scanner's fields alone. */
53
65
  export function strip(doc) {
54
66
  if (!doc || typeof doc !== 'object') return doc;
55
- return { ...doc, nodes: (doc.nodes ?? []).map((n) => ({ ...n, narrative: null })), overview: null };
67
+ return { ...doc, nodes: (doc.nodes ?? []).map((n) => ({ ...n, narrative: null })), overview: null, tour: null };
68
+ }
69
+
70
+ /** Why a tour is not acceptable, or null when it is (null itself is: not written yet). */
71
+ export function tourProblem(tour, doc) {
72
+ if (tour === null || tour === undefined) return null;
73
+ if (!Array.isArray(tour) || !tour.length || tour.length > MAX_STOPS) return `is not a list of 1 to ${MAX_STOPS} stops`;
74
+ const byId = new Map((doc?.nodes ?? []).map((n) => [n.id, n]));
75
+ const seen = new Set();
76
+ for (const [i, s] of tour.entries()) {
77
+ if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).sort().join() !== 'cites,node,text') return `stop ${i + 1} is not { node, text, cites }`;
78
+ if (!byId.has(s.node)) return `stop ${i + 1} names ${JSON.stringify(s.node)}, which is not a node of this map`;
79
+ if (seen.has(s.node)) return `stop ${i + 1} names ${s.node} again: a tour stops at a node once`;
80
+ seen.add(s.node);
81
+ const p = problemOf({ text: s.text, cites: s.cites }, citesOf(byId.get(s.node)), { max: MAX_STOP });
82
+ if (p) return `stop ${i + 1} (${s.node}) ${p}`;
83
+ }
84
+ return null;
56
85
  }
57
86
 
58
87
  /**
@@ -65,7 +94,7 @@ export function carry(old, fresh) {
65
94
  const nodes = fresh.nodes.map((n) => {
66
95
  const nar = was.get(n.id);
67
96
  if (nar === null || nar === undefined) return n;
68
- if (problemOf(nar, citesOf(n)) === null) { kept += 1; return { ...n, narrative: nar }; }
97
+ if (problemOf(nar, citesOf(n), { module: n.kind === 'module' }) === null) { kept += 1; return { ...n, narrative: nar }; }
69
98
  dropped += 1; return n;
70
99
  });
71
100
  const doc = { ...fresh, nodes };
@@ -73,6 +102,9 @@ export function carry(old, fresh) {
73
102
  if (problemOf(old.overview, allCites(doc), { max: MAX_OVERVIEW, twoSentences: false }) === null) { doc.overview = old.overview; kept += 1; }
74
103
  else dropped += 1;
75
104
  }
105
+ if (old?.tour != null) {
106
+ if (tourProblem(old.tour, doc) === null) { doc.tour = old.tour; kept += 1; } else dropped += 1;
107
+ }
76
108
  return { doc, kept, dropped };
77
109
  }
78
110
 
@@ -89,10 +121,12 @@ export function validate(file, fresh, serialize) {
89
121
  }
90
122
  const byId = new Map((fresh.nodes ?? []).map((n) => [n.id, n]));
91
123
  for (const n of file.nodes ?? []) {
92
- const p = problemOf(n.narrative, citesOf(byId.get(n.id) ?? n));
124
+ const p = problemOf(n.narrative, citesOf(byId.get(n.id) ?? n), { module: (byId.get(n.id) ?? n).kind === 'module' });
93
125
  if (p) out.push({ at: n.id, message: `the narrative ${p}` });
94
126
  }
95
127
  const p = problemOf(file.overview, allCites(fresh), { max: MAX_OVERVIEW, twoSentences: false });
96
128
  if (p) out.push({ at: 'overview', message: `the overview ${p}` });
129
+ const t = tourProblem(file.tour, fresh);
130
+ if (t) out.push({ at: 'tour', message: `the tour ${t}` });
97
131
  return out;
98
132
  }
@@ -130,14 +130,15 @@ export function risky(text, patterns) {
130
130
  // A host is the only part of a connection string or URL the map keeps. Credentials are cut off first; then a host
131
131
  // that looks like a secret by Crewforth's own scanner patterns is replaced. Without those patterns every host is
132
132
  // replaced: a map with fewer names is a smaller loss than one that published a token.
133
- export function loadSecretPatterns(fromDir) {
133
+ // skip: pattern lines left out, as written (a project's .secret-allowlist.txt names the exact line it allows).
134
+ export function loadSecretPatterns(fromDir, skip = new Set()) {
134
135
  for (const rel of ['../../../hooks/secret-blocklist.txt', '../../../../hooks/secret-blocklist.txt']) {
135
136
  const f = path.join(fromDir, rel);
136
137
  let text; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
137
138
  const out = [];
138
139
  for (const raw of text.split('\n')) {
139
140
  const l = raw.replace(/\r$/, '');
140
- if (!l || l.startsWith('#')) continue;
141
+ if (!l || l.startsWith('#') || skip.has(l)) continue;
141
142
  const js = l.replace(/\[\[:alnum:\]\]/g, '[A-Za-z0-9]').replace(/\[:alnum:\]/g, 'A-Za-z0-9')
142
143
  .replace(/\[:space:\]/g, '\\s').replace(/\[:alpha:\]/g, 'A-Za-z').replace(/\[:digit:\]/g, '0-9');
143
144
  try { out.push(new RegExp(js)); } catch { /* a pattern JS cannot read is skipped; the rest still apply */ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "about": "What a module is built with, in the words a newcomer reads: a package a manifest declares, or a file the module holds, gives a label. One list for every stack; a label is shown once per module, frameworks before tools, in the order of this file. Versions are not part of a label: the manifest line the label was read from is the module's evidence.",
3
+ "packages": {
4
+ "npm": [
5
+ ["next", "Next.js"], ["react", "React"], ["vue", "Vue"], ["svelte", "Svelte"], ["@angular/core", "Angular"],
6
+ ["astro", "Astro"], ["react-native", "React Native"], ["expo", "Expo"], ["electron", "Electron"],
7
+ ["hono", "Hono"], ["express", "Express"], ["fastify", "Fastify"], ["@nestjs/core", "NestJS"], ["koa", "Koa"],
8
+ ["graphql", "GraphQL"], ["socket.io", "Socket.IO"], ["wrangler", "Cloudflare Workers"],
9
+ ["@prisma/client", "Prisma"], ["prisma", "Prisma"], ["drizzle-orm", "Drizzle"], ["typeorm", "TypeORM"],
10
+ ["mongoose", "Mongoose"], ["tailwindcss", "Tailwind CSS"], ["vite", "Vite"], ["typescript", "TypeScript"],
11
+ ["vitest", "Vitest"], ["jest", "Jest"], ["@playwright/test", "Playwright"]
12
+ ],
13
+ "pypi": [
14
+ ["django", "Django"], ["fastapi", "FastAPI"], ["flask", "Flask"], ["sqlalchemy", "SQLAlchemy"],
15
+ ["celery", "Celery"], ["pydantic", "Pydantic"], ["langchain", "LangChain"], ["torch", "PyTorch"],
16
+ ["transformers", "Transformers"], ["spacy", "spaCy"], ["uvicorn", "Uvicorn"], ["pytest", "pytest"]
17
+ ],
18
+ "pub": [
19
+ ["flutter_riverpod", "Riverpod"], ["hooks_riverpod", "Riverpod"], ["riverpod", "Riverpod"],
20
+ ["flutter_bloc", "Bloc"], ["bloc", "Bloc"], ["provider", "Provider"], ["get", "GetX"], ["go_router", "go_router"],
21
+ ["dio", "Dio"], ["http", "http"], ["drift", "Drift"], ["sqflite", "sqflite"], ["hive", "Hive"], ["isar", "Isar"],
22
+ ["firebase_core", "Firebase"], ["freezed", "Freezed"], ["google_mobile_ads", "Google Mobile Ads"],
23
+ ["in_app_purchase", "In-app purchase"], ["flutter_test", "flutter_test"]
24
+ ],
25
+ "go": [
26
+ ["github.com/gin-gonic/gin", "Gin"], ["github.com/labstack/echo/v4", "Echo"], ["github.com/go-chi/chi/v5", "chi"],
27
+ ["gorm.io/gorm", "GORM"]
28
+ ]
29
+ },
30
+ "files": [
31
+ ["wrangler.toml", "Cloudflare Workers"], ["wrangler.jsonc", "Cloudflare Workers"], ["tsconfig.json", "TypeScript"],
32
+ ["Dockerfile", "Docker"], ["vercel.json", "Vercel"], ["netlify.toml", "Netlify"], ["fly.toml", "Fly.io"],
33
+ ["firebase.json", "Firebase"]
34
+ ]
35
+ }
@@ -24,15 +24,15 @@
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
- import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath } from './project.mjs';
28
- import { scanStacks } from './stacks.mjs';
29
- import { externals, configuration, codeUrls, endpoints, containers } from './surface.mjs';
27
+ import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath, ev } from './project.mjs';
28
+ import { scanStacks, applyTech } from './stacks.mjs';
29
+ import { externals, configuration, codeUrls, servedCalls, endpoints, containers } from './surface.mjs';
30
30
  import { detect } from './detect.mjs';
31
31
  import { stamp, write, serialize, validate, OUT_DIR } from '../findings/findings.mjs';
32
32
  import { carry, strip, validate as validateNarrative } from './narrative.mjs';
33
33
 
34
34
  // Languages with source the scanner has no reader for, by extension.
35
- const UNREAD = { dart: 'Dart', swift: 'Swift', rb: 'Ruby', php: 'PHP', rs: 'Rust', scala: 'Scala', ex: 'Elixir', exs: 'Elixir',
35
+ const UNREAD = { swift: 'Swift', rb: 'Ruby', php: 'PHP', rs: 'Rust', scala: 'Scala', ex: 'Elixir', exs: 'Elixir',
36
36
  c: 'C', cc: 'C++', cpp: 'C++', m: 'Objective-C', mm: 'Objective-C', lua: 'Lua', clj: 'Clojure', hs: 'Haskell', erl: 'Erlang' };
37
37
 
38
38
  const SELF = fileURLToPath(import.meta.url);
@@ -43,6 +43,50 @@ const MAX_EV = 5;
43
43
  const load = (f) => JSON.parse(fs.readFileSync(path.join(HERE, 'rules', f), 'utf8'));
44
44
  const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
45
45
 
46
+ /**
47
+ * What is inside a module, for a reader who has not opened it: its folders and how many source files each holds.
48
+ * A folder holding nearly everything (lib/ of a Flutter app, src/ of a package) is opened one level, at most twice,
49
+ * so the rows say lib/data and lib/features and not just lib. Tests are counted apart. Twelve rows at most.
50
+ */
51
+ const MAX_ROWS = 12;
52
+
53
+ /**
54
+ * What a module says about itself: the first line of prose in its own README (a heading, a badge, an image, an
55
+ * HTML tag or a code fence is not prose). Kept apart from the evidence, which is cut at five lines, so a
56
+ * description always has a line to rest on that says what the module is for. Measured on a real project: four of
57
+ * ten modules were left undescribed because the only line the scan cited for them was the manifest's name.
58
+ */
59
+ function aboutOf(pj, m) {
60
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
61
+ const f = pj.files.find((x) => x.startsWith(base) && /^readme(\.(md|markdown|txt|rst))?$/i.test(x.slice(base.length)));
62
+ const src = f && pj.read(f); if (!src) return null;
63
+ let fence = false;
64
+ for (const [i, raw] of src.lines.entries()) {
65
+ const l = raw.trim();
66
+ if (/^(```|~~~)/.test(l)) { fence = !fence; continue; }
67
+ if (fence || !l || /^(#|=+$|-+$|\[!|!\[|<|\||>\s*\[!)/.test(l)) continue;
68
+ return { evidence: [ev(f, i + 1, l.slice(0, 60))] };
69
+ }
70
+ return null;
71
+ }
72
+ function structure(m) {
73
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
74
+ let rel = m.files.filter((f) => !isTestPath(f)).map((f) => f.slice(base.length));
75
+ const tests = m.files.length - rel.length;
76
+ let at = '';
77
+ const group = (list) => { const g = new Map(); for (const r of list) { const k = r.includes('/') ? r.split('/')[0] : ''; g.set(k, (g.get(k) ?? 0) + 1); } return g; };
78
+ for (let d = 0; d < 2 && rel.length; d++) {
79
+ const [top, n] = [...group(rel)].sort((a, b) => b[1] - a[1] || cmp(a[0], b[0]))[0];
80
+ if (!top || n / rel.length < 0.8 || !rel.some((r) => r.startsWith(`${top}/`) && r.split('/').length > 2)) break;
81
+ at += `${top}/`; rel = rel.filter((r) => r.startsWith(`${top}/`)).map((r) => r.slice(top.length + 1));
82
+ }
83
+ const rows = [...group(rel)].map(([k, n]) => ({ folder: k ? `${at}${k}` : (at.replace(/\/$/, '') || '.'), files: n }))
84
+ .sort((a, b) => b.files - a.files || cmp(a.folder, b.folder));
85
+ const shown = rows.slice(0, MAX_ROWS);
86
+ const rest = rows.slice(MAX_ROWS).reduce((s, r) => s + r.files, 0);
87
+ return { folders: shown, ...(rest ? { otherFiles: rest } : {}), testFiles: tests };
88
+ }
89
+
46
90
  // opts.hostFilter replaces the masking: ONLY the gate's must-fail twin uses it. No command-line flag reaches it.
47
91
  export function scan(root = '.', opts = {}) {
48
92
  const pj = openProject(root);
@@ -51,10 +95,12 @@ export function scan(root = '.', opts = {}) {
51
95
  const rules = load('packages.json'); const names = load('names.json');
52
96
  const stdlib = new Set(load('python-stdlib.json').modules);
53
97
  const st = scanStacks(pj);
98
+ applyTech(st, pj, load('tech.json'));
54
99
  const out = { edges: [], notMeasured: st.notMeasured, configUrls: [], codeHosts: new Set(), patterns };
55
100
  const ex = externals(pj, st, rules, hostOf, out);
56
101
  const cfg = configuration(pj, st, rules, hostOf, ex, out);
57
102
  codeUrls(pj, st, hostOf, ex, out);
103
+ servedCalls(pj, st, ex, out);
58
104
  const eps = endpoints(pj, st, out);
59
105
  const services = containers(pj, st, rules, hostOf, ex, cfg, out);
60
106
  const findings = detect({ st, cfg, eps, names, stdlib, notMeasured: out.notMeasured, configUrls: out.configUrls, codeHosts: out.codeHosts });
@@ -73,6 +119,9 @@ export function scan(root = '.', opts = {}) {
73
119
  const lang = UNREAD[/\.([a-z]+)$/i.exec(f)?.[1]?.toLowerCase()];
74
120
  if (lang && !isTestPath(f)) unread.set(lang, (unread.get(lang) ?? 0) + 1);
75
121
  }
122
+ // Dart is read through its pubspec.yaml: a .dart file no package owns has no module to be drawn in, so it is said.
123
+ const loose = pj.files.filter((f) => /\.dart$/.test(f) && !isTestPath(f) && !st.modules.some((m) => m.stack === 'dart' && m.files.includes(f))).length;
124
+ if (loose) out.notMeasured.push({ what: `Dart source outside any pubspec.yaml (${loose} file${loose === 1 ? '' : 's'})`, reason: 'no package owns it: it is not on the map' });
76
125
  for (const [lang, n] of unread) out.notMeasured.push({ what: `${lang} source (${n} file${n === 1 ? '' : 's'})`, reason: 'no reader for this language: its modules, dependencies and endpoints are not on the map' });
77
126
 
78
127
  // ---- one edge per (from, to, kind, confidence), its evidence merged ----
@@ -94,6 +143,8 @@ export function scan(root = '.', opts = {}) {
94
143
  // line to cite: the entry is then the file, and it says so.
95
144
  ...(m.entry ? { entryPoint: m.entryEv ? { evidence: [m.entryEv] } : { file: m.entryFile, evidence: [] } } : {}),
96
145
  dependencies: m.deps.length,
146
+ structure: structure(m),
147
+ ...((() => { const a = aboutOf(pj, m); return a ? { about: a } : {}; })()),
97
148
  ...capped(m.evidence), narrative: null })),
98
149
  ...services.map((s) => ({ id: s.id, kind: 'service', name: s.name, ...(s.image ? { image: s.image } : {}), ...capped(s.evidence), narrative: null })),
99
150
  ...[...ex.ext.values()].map((n) => ({ id: n.id, kind: 'external', resource: n.resource, label: n.label, hosts: [...n.hosts].sort(), ...capped(n.evidence), narrative: null })),
@@ -122,6 +173,7 @@ export function scan(root = '.', opts = {}) {
122
173
  findings,
123
174
  notMeasured: [...nm.values()].sort((a, b) => cmp(`${a.what}\u0000${a.reason}`, `${b.what}\u0000${b.reason}`)),
124
175
  overview: null,
176
+ tour: null,
125
177
  };
126
178
  // The last net, whatever cited it: a quote is cut before anything that looks like a credential, and a line that
127
179
  // carries values (by its file, or by what is on it) is never hashed.
@@ -1,5 +1,5 @@
1
1
  // stacks.mjs — modules, their declared dependencies, their imports and their references to each other, for the
2
- // five stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
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(`image:${from[1]}`); }
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
@@ -224,6 +270,24 @@ function firstArg(L, i, from) {
224
270
  }
225
271
  return null;
226
272
  }
273
+ // The arguments of a call whose `(` ends at `from` on line i, up to its own `)`, across at most four lines: strings
274
+ // and nested brackets are stepped over, so `method:` is read from this call's options and never from the next call's.
275
+ function callArgs(L, i, from) {
276
+ let out = ''; let depth = 0; let q = null;
277
+ for (let j = i; j < Math.min(L.length, i + 4); j++) {
278
+ const s = j === i ? L[i].slice(from) : L[j];
279
+ for (let k = 0; k < s.length; k++) {
280
+ const c = s[k];
281
+ if (q) { if (c === '\\') { out += c + (s[k + 1] ?? ''); k += 1; continue; } if (c === q) q = null; }
282
+ else if (c === '"' || c === "'" || c === '`') q = c;
283
+ else if (c === '(' || c === '[' || c === '{') depth += 1;
284
+ else if (c === ')' || c === ']' || c === '}') { if (depth === 0) return out; depth -= 1; }
285
+ out += c;
286
+ }
287
+ out += ' ';
288
+ }
289
+ return out;
290
+ }
227
291
  // The names a module calls HTTP through: axios, and every `x = axios.create(…)` in any of its files (a client made
228
292
  // in lib/api.ts is called from every component). A name a file itself makes a server of is not one there.
229
293
  function httpClients(pj, m) {
@@ -312,8 +376,7 @@ export function endpoints(pj, st, out) {
312
376
  if (nm && nest !== null) add(provides, m, nm[1] === 'All' ? '*' : nm[1], join(nest, nm[2] || ''), f, i, `@${nm[1]}`);
313
377
  const fe = /\bfetch\(\s*(['"`])([^'"`]+)\1/.exec(l);
314
378
  if (fe && !/^https?:/.test(fe[2])) {
315
- const win = L.slice(i, i + 4).join(' ');
316
- const mt = /method\s*:\s*['"](\w+)['"]/.exec(win);
379
+ const mt = /method\s*:\s*['"](\w+)['"]/.exec(callArgs(L, i, fe.index + l.slice(fe.index).indexOf('(') + 1));
317
380
  add(consumes, m, mt ? mt[1] : 'GET', fe[2], f, i, fe[2]);
318
381
  }
319
382
  // `api.get<User[]>("/x")` and a call whose path is on the next line read alike.
@@ -322,6 +385,21 @@ export function endpoints(pj, st, out) {
322
385
  if (axa && axa.s && !/^https?:/.test(axa.s)) add(consumes, m, ax[2], axa.s, f, axa.at, axa.s);
323
386
  });
324
387
  }
388
+ // Dart consumers: a Dio client's relative path, and a full URL (http.get(Uri.parse(…)), Uri.https(host, path))
389
+ // only when its host is one this project serves; any other host is someone else's API. `$id` is a parameter.
390
+ if (/\.dart$/.test(f)) {
391
+ const ours = (h) => out.served?.has(h.toLowerCase());
392
+ const dp = (s) => s.replace(/\$\{[^}]*\}|\$\w+/g, '{x}');
393
+ L.forEach((l, i) => {
394
+ if (isComment(l)) return;
395
+ const dio = /\b(\w*[dD]io\w*|_?client|_?api)\.(get|post|put|patch|delete)(?:<[^()]*?>)?\(\s*(['"])(\/[^'"]*)\3/.exec(l);
396
+ if (dio) add(consumes, m, dio[2], dp(dio[4]), f, i, dio[4]);
397
+ const hp = /\.(get|post|put|patch|delete)\(\s*Uri\.parse\(\s*(['"])https?:\/\/([^/'"$]+)(\/[^'"]*)\2/.exec(l);
398
+ if (hp && ours(hp[3])) add(consumes, m, hp[1], dp(hp[4]), f, i, hp[4]);
399
+ const uh = /\bUri\.https?\(\s*(['"])([^'"$]+)\1\s*,\s*(['"])(\/[^'"]*)\3/.exec(l);
400
+ if (uh && ours(uh[2])) add(consumes, m, 'GET', dp(uh[4]), f, i, uh[4]);
401
+ });
402
+ }
325
403
  // Python: FastAPI / Flask / Django providers; requests / httpx consumers
326
404
  if (/\.py$/.test(f)) {
327
405
  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
+ }