crewforth 3.1.0 → 3.2.0-rc.1
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 +151 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +91 -11
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +9 -8
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +91 -3
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-json.awk +5 -0
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +115 -0
- package/kit/eval/smoke-test.sh +465 -38
- package/kit/hooks/agent-outcome.sh +6 -3
- package/kit/hooks/guard-agent-model.sh +15 -5
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/owned-blobs.tsv +4 -1
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +114 -11
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +214 -12
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +125 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Top-level modules of the Python standard library (sys.stdlib_module_names, CPython 3.13, private names dropped). An import of one of these is never a dependency.",
|
|
3
|
+
"modules": [
|
|
4
|
+
"abc",
|
|
5
|
+
"antigravity",
|
|
6
|
+
"argparse",
|
|
7
|
+
"array",
|
|
8
|
+
"ast",
|
|
9
|
+
"asyncio",
|
|
10
|
+
"atexit",
|
|
11
|
+
"base64",
|
|
12
|
+
"bdb",
|
|
13
|
+
"binascii",
|
|
14
|
+
"bisect",
|
|
15
|
+
"builtins",
|
|
16
|
+
"bz2",
|
|
17
|
+
"cProfile",
|
|
18
|
+
"calendar",
|
|
19
|
+
"cmath",
|
|
20
|
+
"cmd",
|
|
21
|
+
"code",
|
|
22
|
+
"codecs",
|
|
23
|
+
"codeop",
|
|
24
|
+
"collections",
|
|
25
|
+
"colorsys",
|
|
26
|
+
"compileall",
|
|
27
|
+
"concurrent",
|
|
28
|
+
"configparser",
|
|
29
|
+
"contextlib",
|
|
30
|
+
"contextvars",
|
|
31
|
+
"copy",
|
|
32
|
+
"copyreg",
|
|
33
|
+
"csv",
|
|
34
|
+
"ctypes",
|
|
35
|
+
"curses",
|
|
36
|
+
"dataclasses",
|
|
37
|
+
"datetime",
|
|
38
|
+
"dbm",
|
|
39
|
+
"decimal",
|
|
40
|
+
"difflib",
|
|
41
|
+
"dis",
|
|
42
|
+
"doctest",
|
|
43
|
+
"email",
|
|
44
|
+
"encodings",
|
|
45
|
+
"ensurepip",
|
|
46
|
+
"enum",
|
|
47
|
+
"errno",
|
|
48
|
+
"faulthandler",
|
|
49
|
+
"fcntl",
|
|
50
|
+
"filecmp",
|
|
51
|
+
"fileinput",
|
|
52
|
+
"fnmatch",
|
|
53
|
+
"fractions",
|
|
54
|
+
"ftplib",
|
|
55
|
+
"functools",
|
|
56
|
+
"gc",
|
|
57
|
+
"genericpath",
|
|
58
|
+
"getopt",
|
|
59
|
+
"getpass",
|
|
60
|
+
"gettext",
|
|
61
|
+
"glob",
|
|
62
|
+
"graphlib",
|
|
63
|
+
"grp",
|
|
64
|
+
"gzip",
|
|
65
|
+
"hashlib",
|
|
66
|
+
"heapq",
|
|
67
|
+
"hmac",
|
|
68
|
+
"html",
|
|
69
|
+
"http",
|
|
70
|
+
"idlelib",
|
|
71
|
+
"imaplib",
|
|
72
|
+
"importlib",
|
|
73
|
+
"inspect",
|
|
74
|
+
"io",
|
|
75
|
+
"ipaddress",
|
|
76
|
+
"itertools",
|
|
77
|
+
"json",
|
|
78
|
+
"keyword",
|
|
79
|
+
"linecache",
|
|
80
|
+
"locale",
|
|
81
|
+
"logging",
|
|
82
|
+
"lzma",
|
|
83
|
+
"mailbox",
|
|
84
|
+
"marshal",
|
|
85
|
+
"math",
|
|
86
|
+
"mimetypes",
|
|
87
|
+
"mmap",
|
|
88
|
+
"modulefinder",
|
|
89
|
+
"msvcrt",
|
|
90
|
+
"multiprocessing",
|
|
91
|
+
"netrc",
|
|
92
|
+
"nt",
|
|
93
|
+
"ntpath",
|
|
94
|
+
"nturl2path",
|
|
95
|
+
"numbers",
|
|
96
|
+
"opcode",
|
|
97
|
+
"operator",
|
|
98
|
+
"optparse",
|
|
99
|
+
"os",
|
|
100
|
+
"pathlib",
|
|
101
|
+
"pdb",
|
|
102
|
+
"pickle",
|
|
103
|
+
"pickletools",
|
|
104
|
+
"pkgutil",
|
|
105
|
+
"platform",
|
|
106
|
+
"plistlib",
|
|
107
|
+
"poplib",
|
|
108
|
+
"posix",
|
|
109
|
+
"posixpath",
|
|
110
|
+
"pprint",
|
|
111
|
+
"profile",
|
|
112
|
+
"pstats",
|
|
113
|
+
"pty",
|
|
114
|
+
"pwd",
|
|
115
|
+
"py_compile",
|
|
116
|
+
"pyclbr",
|
|
117
|
+
"pydoc",
|
|
118
|
+
"pydoc_data",
|
|
119
|
+
"pyexpat",
|
|
120
|
+
"queue",
|
|
121
|
+
"quopri",
|
|
122
|
+
"random",
|
|
123
|
+
"re",
|
|
124
|
+
"readline",
|
|
125
|
+
"reprlib",
|
|
126
|
+
"resource",
|
|
127
|
+
"rlcompleter",
|
|
128
|
+
"runpy",
|
|
129
|
+
"sched",
|
|
130
|
+
"secrets",
|
|
131
|
+
"select",
|
|
132
|
+
"selectors",
|
|
133
|
+
"shelve",
|
|
134
|
+
"shlex",
|
|
135
|
+
"shutil",
|
|
136
|
+
"signal",
|
|
137
|
+
"site",
|
|
138
|
+
"smtplib",
|
|
139
|
+
"socket",
|
|
140
|
+
"socketserver",
|
|
141
|
+
"sqlite3",
|
|
142
|
+
"sre_compile",
|
|
143
|
+
"sre_constants",
|
|
144
|
+
"sre_parse",
|
|
145
|
+
"ssl",
|
|
146
|
+
"stat",
|
|
147
|
+
"statistics",
|
|
148
|
+
"string",
|
|
149
|
+
"stringprep",
|
|
150
|
+
"struct",
|
|
151
|
+
"subprocess",
|
|
152
|
+
"symtable",
|
|
153
|
+
"sys",
|
|
154
|
+
"sysconfig",
|
|
155
|
+
"syslog",
|
|
156
|
+
"tabnanny",
|
|
157
|
+
"tarfile",
|
|
158
|
+
"tempfile",
|
|
159
|
+
"termios",
|
|
160
|
+
"textwrap",
|
|
161
|
+
"this",
|
|
162
|
+
"threading",
|
|
163
|
+
"time",
|
|
164
|
+
"timeit",
|
|
165
|
+
"tkinter",
|
|
166
|
+
"token",
|
|
167
|
+
"tokenize",
|
|
168
|
+
"tomllib",
|
|
169
|
+
"trace",
|
|
170
|
+
"traceback",
|
|
171
|
+
"tracemalloc",
|
|
172
|
+
"tty",
|
|
173
|
+
"turtle",
|
|
174
|
+
"turtledemo",
|
|
175
|
+
"types",
|
|
176
|
+
"typing",
|
|
177
|
+
"unicodedata",
|
|
178
|
+
"unittest",
|
|
179
|
+
"urllib",
|
|
180
|
+
"uuid",
|
|
181
|
+
"venv",
|
|
182
|
+
"warnings",
|
|
183
|
+
"wave",
|
|
184
|
+
"weakref",
|
|
185
|
+
"webbrowser",
|
|
186
|
+
"winreg",
|
|
187
|
+
"winsound",
|
|
188
|
+
"wsgiref",
|
|
189
|
+
"xml",
|
|
190
|
+
"xmlrpc",
|
|
191
|
+
"zipapp",
|
|
192
|
+
"zipfile",
|
|
193
|
+
"zipimport",
|
|
194
|
+
"zlib",
|
|
195
|
+
"zoneinfo"
|
|
196
|
+
]
|
|
197
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scan.mjs — Crew Map's scanner: reads a project and writes .crewforth/crew-map.json, a map of its modules, the
|
|
3
|
+
// external resources it touches, the endpoints it provides and calls, and the gaps between them.
|
|
4
|
+
//
|
|
5
|
+
// node scan.mjs [--root DIR] scan, verify, write DIR/.crewforth/crew-map.json, print its path
|
|
6
|
+
// node scan.mjs [--root DIR] --stdout scan and verify; print the document, write nothing
|
|
7
|
+
// node scan.mjs [--root DIR] --check rc 0 when the file on disk is what a scan writes now, 1 when it is not
|
|
8
|
+
// rc 1 = the scan produced a document that does not verify (nothing is written) · rc 2 = usage
|
|
9
|
+
//
|
|
10
|
+
// What it guarantees, each one a gate in packaging/crew-map-test:
|
|
11
|
+
// evidence every node, edge, endpoint and finding cites file:line, and the document goes through the
|
|
12
|
+
// findings core (kit/eval/lib/findings) before it is written: one that does not verify is not
|
|
13
|
+
// deterministic no timestamp, every list in a fixed order: the same tree gives the same bytes, wherever it sits
|
|
14
|
+
// no secrets a configuration value is read only to name its kind; only a host is kept, credentials cut off
|
|
15
|
+
// and masked by Crewforth's own secret patterns. A line that carries values is cited by its key and
|
|
16
|
+
// never hashed (a hash of `DB_PASSWORD=hunter2` is an oracle for the password)
|
|
17
|
+
// no machine paths are relative to the project root; the findings core refuses a document that carries the
|
|
18
|
+
// machine's own directories
|
|
19
|
+
// honest what it cannot read goes to notMeasured with the reason, never read partly
|
|
20
|
+
// cheap two child processes per scan (git ls-files, git rev-parse), none per file
|
|
21
|
+
//
|
|
22
|
+
// Host-independent like the findings core: plain JSON out, Node's standard library only, no hook payload read.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
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';
|
|
30
|
+
import { detect } from './detect.mjs';
|
|
31
|
+
import { stamp, write, serialize, validate, OUT_DIR } from '../findings/findings.mjs';
|
|
32
|
+
import { carry, strip, validate as validateNarrative } from './narrative.mjs';
|
|
33
|
+
|
|
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',
|
|
36
|
+
c: 'C', cc: 'C++', cpp: 'C++', m: 'Objective-C', mm: 'Objective-C', lua: 'Lua', clj: 'Clojure', hs: 'Haskell', erl: 'Erlang' };
|
|
37
|
+
|
|
38
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
39
|
+
const HERE = path.dirname(SELF);
|
|
40
|
+
export const SCHEMA = 'crewforth.crew-map/1';
|
|
41
|
+
export const NAME = 'crew-map';
|
|
42
|
+
const MAX_EV = 5;
|
|
43
|
+
const load = (f) => JSON.parse(fs.readFileSync(path.join(HERE, 'rules', f), 'utf8'));
|
|
44
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
45
|
+
|
|
46
|
+
// opts.hostFilter replaces the masking: ONLY the gate's must-fail twin uses it. No command-line flag reaches it.
|
|
47
|
+
export function scan(root = '.', opts = {}) {
|
|
48
|
+
const pj = openProject(root);
|
|
49
|
+
const patterns = loadSecretPatterns(HERE);
|
|
50
|
+
const hostOf = opts.hostFilter || makeHostFilter(patterns);
|
|
51
|
+
const rules = load('packages.json'); const names = load('names.json');
|
|
52
|
+
const stdlib = new Set(load('python-stdlib.json').modules);
|
|
53
|
+
const st = scanStacks(pj);
|
|
54
|
+
const out = { edges: [], notMeasured: st.notMeasured, configUrls: [], codeHosts: new Set(), patterns };
|
|
55
|
+
const ex = externals(pj, st, rules, hostOf, out);
|
|
56
|
+
const cfg = configuration(pj, st, rules, hostOf, ex, out);
|
|
57
|
+
codeUrls(pj, st, hostOf, ex, out);
|
|
58
|
+
const eps = endpoints(pj, st, out);
|
|
59
|
+
const services = containers(pj, st, rules, hostOf, ex, cfg, out);
|
|
60
|
+
const findings = detect({ st, cfg, eps, names, stdlib, notMeasured: out.notMeasured, configUrls: out.configUrls, codeHosts: out.codeHosts });
|
|
61
|
+
|
|
62
|
+
const ids = new Set([...st.modules.map((m) => m.id), ...services.map((s) => s.id), ...ex.ext.keys()]);
|
|
63
|
+
for (const m of st.modules) {
|
|
64
|
+
for (const r of m.refs) {
|
|
65
|
+
if (ids.has(r.to)) { if (r.to !== m.id) out.edges.push({ from: m.id, to: r.to, kind: 'references', confidence: r.confidence, via: 'code', evidence: [r.ev] }); }
|
|
66
|
+
else out.notMeasured.push({ what: `${r.ev.file}:${r.ev.line}`, reason: `names ${r.to.replace(/^missing:/, '')}, which is not in this repository` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const f of pj.tooBig) out.notMeasured.push({ what: f, reason: 'over 2 MiB, not read' });
|
|
70
|
+
// Source in a language no reader here knows: say so, or its modules are absent from the map without a word.
|
|
71
|
+
const unread = new Map();
|
|
72
|
+
for (const f of pj.files) {
|
|
73
|
+
const lang = UNREAD[/\.([a-z]+)$/i.exec(f)?.[1]?.toLowerCase()];
|
|
74
|
+
if (lang && !isTestPath(f)) unread.set(lang, (unread.get(lang) ?? 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
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
|
+
|
|
78
|
+
// ---- one edge per (from, to, kind, confidence), its evidence merged ----
|
|
79
|
+
const em = new Map();
|
|
80
|
+
for (const e of out.edges) {
|
|
81
|
+
if (!ids.has(e.to)) { out.notMeasured.push({ what: `${e.evidence[0].file}:${e.evidence[0].line}`, reason: `names ${e.to}, which is not in this repository` }); continue; }
|
|
82
|
+
const k = [e.from, e.to, e.kind, e.confidence].join('\u0000');
|
|
83
|
+
if (!em.has(k)) em.set(k, { from: e.from, to: e.to, kind: e.kind, confidence: e.confidence, source: 'scanner', vias: new Set(), evidence: [] });
|
|
84
|
+
const x = em.get(k); x.vias.add(e.via); x.evidence.push(...e.evidence);
|
|
85
|
+
}
|
|
86
|
+
const capped = (list) => { const all = sortEv(list); return all.length > MAX_EV ? { evidence: all.slice(0, MAX_EV), evidenceTotal: all.length } : { evidence: all }; };
|
|
87
|
+
const edges = [...em.values()].map((x) => ({ from: x.from, to: x.to, kind: x.kind, confidence: x.confidence, source: x.source,
|
|
88
|
+
via: [...x.vias].sort(), ...capped(x.evidence) })).sort((a, b) => cmp([a.from, a.to, a.kind, a.confidence].join('\u0000'), [b.from, b.to, b.kind, b.confidence].join('\u0000')));
|
|
89
|
+
|
|
90
|
+
const nodes = [
|
|
91
|
+
...st.modules.map((m) => ({ id: m.id, kind: 'module', name: m.name, stack: m.stack, path: m.dir, manifest: m.manifest,
|
|
92
|
+
tech: [...m.tech].sort(), entry: m.entry,
|
|
93
|
+
// Named `evidence` so the findings core verifies it like every other citation. An empty __main__.py has no
|
|
94
|
+
// line to cite: the entry is then the file, and it says so.
|
|
95
|
+
...(m.entry ? { entryPoint: m.entryEv ? { evidence: [m.entryEv] } : { file: m.entryFile, evidence: [] } } : {}),
|
|
96
|
+
dependencies: m.deps.length,
|
|
97
|
+
...capped(m.evidence), narrative: null })),
|
|
98
|
+
...services.map((s) => ({ id: s.id, kind: 'service', name: s.name, ...(s.image ? { image: s.image } : {}), ...capped(s.evidence), narrative: null })),
|
|
99
|
+
...[...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 })),
|
|
100
|
+
].sort((a, b) => cmp(a.id, b.id));
|
|
101
|
+
|
|
102
|
+
const epSort = (list) => {
|
|
103
|
+
const seen = new Set(); const res = [];
|
|
104
|
+
for (const e of list) { const k = [e.module, e.method, e.path, e.evidence[0].file, e.evidence[0].line].join('\u0000'); if (!seen.has(k)) { seen.add(k); res.push(e); } }
|
|
105
|
+
return res.sort((a, b) => cmp([a.module, a.path, a.method, a.evidence[0].file, String(a.evidence[0].line).padStart(9, '0')].join('\u0000'),
|
|
106
|
+
[b.module, b.path, b.method, b.evidence[0].file, String(b.evidence[0].line).padStart(9, '0')].join('\u0000')));
|
|
107
|
+
};
|
|
108
|
+
const nm = new Map();
|
|
109
|
+
for (const x of out.notMeasured) nm.set(`${x.what}\u0000${x.reason}`, x);
|
|
110
|
+
|
|
111
|
+
const stacks = [...new Set([...st.modules.map((m) => m.stack), ...(services.length ? ['container'] : [])])].sort();
|
|
112
|
+
const doc = {
|
|
113
|
+
schema: SCHEMA,
|
|
114
|
+
producer: { name: NAME, version: '1' },
|
|
115
|
+
...(pj.commit ? { source: { commit: pj.commit } } : {}),
|
|
116
|
+
stacks,
|
|
117
|
+
coverage: { files: pj.files.length, modules: st.modules.length, listing: pj.git ? 'git' : 'walk' },
|
|
118
|
+
nodes,
|
|
119
|
+
edges,
|
|
120
|
+
provides: { endpoints: epSort(eps.provides) },
|
|
121
|
+
consumes: { endpoints: epSort(eps.consumes) },
|
|
122
|
+
findings,
|
|
123
|
+
notMeasured: [...nm.values()].sort((a, b) => cmp(`${a.what}\u0000${a.reason}`, `${b.what}\u0000${b.reason}`)),
|
|
124
|
+
overview: null,
|
|
125
|
+
};
|
|
126
|
+
// The last net, whatever cited it: a quote is cut before anything that looks like a credential, and a line that
|
|
127
|
+
// carries values (by its file, or by what is on it) is never hashed.
|
|
128
|
+
const lineAt = (c) => { const r = pj.read(c.file); return r && r.lines[c.line - 1] !== undefined ? r.lines[c.line - 1] : ''; };
|
|
129
|
+
const cites = [];
|
|
130
|
+
const collect = (v) => {
|
|
131
|
+
if (Array.isArray(v)) v.forEach(collect);
|
|
132
|
+
else if (v && typeof v === 'object') {
|
|
133
|
+
if (typeof v.file === 'string' && typeof v.line === 'number' && typeof v.text === 'string') cites.push(v);
|
|
134
|
+
Object.values(v).forEach(collect);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
collect(doc);
|
|
138
|
+
for (const c of cites) {
|
|
139
|
+
const at = risky(c.text, patterns);
|
|
140
|
+
if (at === 0) c.text = c.text[0]; else if (at > 0) c.text = c.text.slice(0, at);
|
|
141
|
+
}
|
|
142
|
+
const report = stamp(doc, pj.root);
|
|
143
|
+
for (const c of cites) if (c.sha256 && (bearsValues(c.file) || risky(lineAt(c), patterns) >= 0)) delete c.sha256;
|
|
144
|
+
return { doc, report, spawned: spawned.count };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function usage(msg) {
|
|
148
|
+
if (msg) console.error(`crew-map: ${msg}`);
|
|
149
|
+
console.error('usage: scan.mjs [--root DIR] [--stdout | --check | --validate]');
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function summary(doc) {
|
|
154
|
+
const hidden = doc.findings.filter((f) => f.data && f.data.defaultHidden).length;
|
|
155
|
+
const mods = doc.nodes.filter((n) => n.kind === 'module').length;
|
|
156
|
+
const ext = doc.nodes.filter((n) => n.kind === 'external').length;
|
|
157
|
+
return `crew-map: ${mods} module(s) · ${ext} external resource(s) · ${doc.edges.length} edge(s) · `
|
|
158
|
+
+ `${doc.provides.endpoints.length} endpoint(s) provided · ${doc.consumes.endpoints.length} called · `
|
|
159
|
+
+ `${doc.findings.length} finding(s) (${hidden} hidden by default) · ${doc.notMeasured.length} not measured`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function main(argv) {
|
|
163
|
+
let root = '.'; let mode = 'write';
|
|
164
|
+
for (let i = 0; i < argv.length; i++) {
|
|
165
|
+
const a = argv[i];
|
|
166
|
+
if (a === '--root') { root = argv[++i]; if (root === undefined) return usage('--root needs a directory'); }
|
|
167
|
+
else if (a === '--stdout' || a === '--check' || a === '--validate') { if (mode !== 'write') return usage('--stdout, --check and --validate do not go together'); mode = a.slice(2); }
|
|
168
|
+
else return usage(`no such option: ${a}`);
|
|
169
|
+
}
|
|
170
|
+
try { if (!fs.statSync(root).isDirectory()) throw new Error('not a directory'); }
|
|
171
|
+
catch (e) { console.error(`crew-map: --root ${root}: ${e.message}`); return 2; }
|
|
172
|
+
const { doc, report } = scan(root);
|
|
173
|
+
if (!report.ok) {
|
|
174
|
+
for (const p of report.problems) console.error(` FAIL ${p.at}: ${p.code} — ${p.message}`);
|
|
175
|
+
console.error('crew-map: the scan produced citations that do not verify, so nothing was written (a scanner defect)');
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
if (mode === 'stdout') { process.stdout.write(serialize(doc)); console.error(summary(doc)); return 0; }
|
|
179
|
+
const file = path.join(root, OUT_DIR, `${NAME}.json`);
|
|
180
|
+
let old = null; try { old = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
181
|
+
let was = null; try { was = old === null ? null : JSON.parse(old); } catch { /* unreadable: different */ }
|
|
182
|
+
if (mode === 'validate') {
|
|
183
|
+
if (!was) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json ${old === null ? 'does not exist' : 'is not JSON'}`); return 1; }
|
|
184
|
+
const problems = validateNarrative(was, doc, serialize);
|
|
185
|
+
const written = (was.nodes ?? []).filter((n) => n.narrative != null).length + (was.overview != null ? 1 : 0);
|
|
186
|
+
if (!problems.length) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json holds: ${written} description(s), each citing lines the scan read; the scanner's fields untouched`); return 0; }
|
|
187
|
+
for (const p of problems) console.log(` FAIL ${p.at}: ${p.message}`);
|
|
188
|
+
console.log(`crew-map: ${problems.length} problem(s); fix the descriptions, never the scanner's fields`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
if (mode === 'check') {
|
|
192
|
+
if (old === null) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json does not exist`); return 1; }
|
|
193
|
+
// The content is compared, not the commit it was written at: a map committed to the repository is always
|
|
194
|
+
// one commit behind the commit that carries it, and that alone does not make it stale. What the model wrote
|
|
195
|
+
// (narrative, overview) is not the scan's, so it is left out of the comparison.
|
|
196
|
+
const bare = (d) => { const { source, ...rest } = strip(d); return serialize(rest); };
|
|
197
|
+
const then = was && was.source && was.source.commit; const now = doc.source && doc.source.commit;
|
|
198
|
+
const at = then && now && then !== now ? ` (written at ${then.slice(0, 12)}, HEAD is ${now.slice(0, 12)})` : '';
|
|
199
|
+
if (was && bare(was) === bare(doc)) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json is up to date${at}`); return 0; }
|
|
200
|
+
console.log(`crew-map: ${OUT_DIR}/${NAME}.json differs from a scan of this tree${at}`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
// The descriptions /crew-map wrote are kept where they still cite only lines this scan cites; the rest are dropped.
|
|
204
|
+
const carried = was ? carry(was, doc) : { doc, kept: 0, dropped: 0 };
|
|
205
|
+
const r = write(carried.doc, root, NAME);
|
|
206
|
+
if (!r.ok) { for (const p of r.problems) console.error(` FAIL ${p.at}: ${p.code} — ${p.message}`); return 1; }
|
|
207
|
+
console.error(summary(doc) + (carried.kept || carried.dropped ? ` · ${carried.kept} description(s) kept, ${carried.dropped} dropped (they cited lines this scan no longer reads)` : ''));
|
|
208
|
+
console.log(r.file);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let direct = false;
|
|
213
|
+
try { direct = !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF); } catch { /* imported */ }
|
|
214
|
+
if (direct) process.exitCode = main(process.argv.slice(2));
|