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.
- package/CHANGELOG.md +190 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +55 -6
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +6 -6
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +37 -3
- 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 +81 -3
- 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-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +14 -4
- package/kit/eval/lib/statusline.sh +4 -19
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +424 -26
- package/kit/hooks/agent-outcome.sh +38 -9
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +163 -20
- package/kit/hooks/guard-bash.sh +69 -36
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +74 -5
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +18 -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/skills/crew-ship/SKILL.md +3 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +85 -22
- package/kit/studio/server/lib/code-stamp.js +35 -0
- 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/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- 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 +79 -11
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/liveness.js +14 -0
- 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 +40 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- 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
|
|
24
|
+
/** The "file:line" a narrative of this node may cite. */
|
|
17
25
|
export function citesOf(node) {
|
|
18
26
|
const out = new Set();
|
|
19
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
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
|
|
@@ -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
|
|
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
|
+
}
|