ruvnet-brain 4.3.21 → 4.3.25
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/README.md +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/source-tree.mjs — the PINNED GIT TREE a C3 unit inventory is enumerated from.
|
|
4
|
+
*
|
|
5
|
+
* ADR-086 C3, per the EXTEND_FIRST Dual verdict (2026-09-14): "Enumerate the pinned Git tree and read its
|
|
6
|
+
* bound objects. A commit label on a filesystem walk is insufficient." A filesystem walk sees whatever
|
|
7
|
+
* happens to be checked out — ignored files, build output, a dirty worktree, LFS-smudged content — and
|
|
8
|
+
* cannot prove it describes the commit it is labelled with. This module reads the tree object itself.
|
|
9
|
+
*
|
|
10
|
+
* Every tracked entry gets exactly one PRIMARY entry kind, so nothing is silently skipped:
|
|
11
|
+
* file | executable | symlink | gitlink (submodule pointer, content NOT imported) | lfs-pointer | binary
|
|
12
|
+
* Emptiness can never be inferred from what this module could not read: symlinks, gitlinks, LFS pointers
|
|
13
|
+
* and binaries are recorded explicitly, never dropped.
|
|
14
|
+
*
|
|
15
|
+
* Deterministic and model-free. The manifest carries its own sha256 over a canonical encoding, so two
|
|
16
|
+
* enumerations of the same commit are byte-identical across process boundaries.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
export const SOURCE_TREE_VERSION = 'oracle-source-tree/1';
|
|
25
|
+
export const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1';
|
|
26
|
+
// git's own binary heuristic looks for a NUL in the first 8000 bytes; use the same boundary.
|
|
27
|
+
export const BINARY_SNIFF_BYTES = 8000;
|
|
28
|
+
|
|
29
|
+
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
30
|
+
|
|
31
|
+
function git(repoDir, args, { input, maxBuffer = 1024 * 1024 * 1024 } = {}) {
|
|
32
|
+
const r = spawnSync('git', ['-C', repoDir, ...args], { input, maxBuffer });
|
|
33
|
+
if (r.error) throw new Error(`git ${args[0]} failed to start: ${r.error.message}`);
|
|
34
|
+
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr).trim().slice(0, 300)}`);
|
|
35
|
+
return r.stdout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve a commit-ish to its exact commit and root tree. Refuses anything that is not a commit. */
|
|
39
|
+
export function resolveCommit({ repoDir, commit }) {
|
|
40
|
+
const commitSha = String(git(repoDir, ['rev-parse', '--verify', `${commit}^{commit}`])).trim();
|
|
41
|
+
const treeSha = String(git(repoDir, ['rev-parse', '--verify', `${commitSha}^{tree}`])).trim();
|
|
42
|
+
return { commitSha, treeSha };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every entry in the commit's tree, recursively, from the tree OBJECT. Output format of
|
|
47
|
+
* `git ls-tree -r -l -z --full-tree`: "<mode> SP <type> SP <object> SP+ <size>\t<path>\0".
|
|
48
|
+
*/
|
|
49
|
+
export function listTree({ repoDir, commit }) {
|
|
50
|
+
const raw = git(repoDir, ['ls-tree', '-r', '-l', '-z', '--full-tree', commit]);
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const record of raw.toString('utf8').split('\0')) {
|
|
53
|
+
if (!record) continue;
|
|
54
|
+
const tab = record.indexOf('\t');
|
|
55
|
+
if (tab < 0) throw new Error(`unparseable ls-tree record: ${record.slice(0, 120)}`);
|
|
56
|
+
const [mode, type, objectSha, sizeField] = record.slice(0, tab).trim().split(/\s+/);
|
|
57
|
+
entries.push({
|
|
58
|
+
path: record.slice(tab + 1),
|
|
59
|
+
mode,
|
|
60
|
+
type,
|
|
61
|
+
objectSha,
|
|
62
|
+
size: sizeField === '-' ? null : Number(sizeField),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read many blob objects in one `git cat-file --batch` round trip. */
|
|
69
|
+
export function readBlobs({ repoDir, shas }) {
|
|
70
|
+
const unique = [...new Set(shas)];
|
|
71
|
+
const out = new Map();
|
|
72
|
+
if (!unique.length) return out;
|
|
73
|
+
const buf = git(repoDir, ['cat-file', '--batch'], { input: `${unique.join('\n')}\n` });
|
|
74
|
+
let offset = 0;
|
|
75
|
+
while (offset < buf.length) {
|
|
76
|
+
const newline = buf.indexOf(0x0a, offset);
|
|
77
|
+
const header = buf.subarray(offset, newline).toString('utf8');
|
|
78
|
+
const [sha, kind, sizeText] = header.split(' ');
|
|
79
|
+
if (kind === 'missing') throw new Error(`blob ${sha} is missing from the object store`);
|
|
80
|
+
const size = Number(sizeText);
|
|
81
|
+
const start = newline + 1;
|
|
82
|
+
out.set(sha, buf.subarray(start, start + size));
|
|
83
|
+
offset = start + size + 1; // content is followed by a single LF
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The single primary kind of a tree entry. Content is consulted only for regular blobs. */
|
|
89
|
+
export function classifyEntry(entry, bytes) {
|
|
90
|
+
if (entry.type === 'commit' || entry.mode === '160000') return 'gitlink';
|
|
91
|
+
if (entry.mode === '120000') return 'symlink';
|
|
92
|
+
if (!bytes) throw new Error(`classifyEntry(${entry.path}) needs the blob bytes`);
|
|
93
|
+
if (bytes.subarray(0, LFS_POINTER_PREFIX.length).toString('utf8') === LFS_POINTER_PREFIX) return 'lfs-pointer';
|
|
94
|
+
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return 'binary';
|
|
95
|
+
return entry.mode === '100755' ? 'executable' : 'file';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canonical(value) {
|
|
99
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
100
|
+
if (value && typeof value === 'object') {
|
|
101
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
102
|
+
}
|
|
103
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The snapshot manifest: one row per tracked entry, each with its exact object identity, content digest
|
|
108
|
+
* (regular blobs only) and primary kind. Returns `{ manifest, blobs }` so an enumerator can reuse the
|
|
109
|
+
* bytes it already read instead of touching the filesystem.
|
|
110
|
+
*/
|
|
111
|
+
export function snapshotManifest({ repoDir, commit, repo }) {
|
|
112
|
+
const { commitSha, treeSha } = resolveCommit({ repoDir, commit });
|
|
113
|
+
const entries = listTree({ repoDir, commit: commitSha });
|
|
114
|
+
const blobShas = entries.filter((e) => e.type === 'blob' && e.mode !== '120000').map((e) => e.objectSha);
|
|
115
|
+
const blobs = readBlobs({ repoDir, shas: blobShas });
|
|
116
|
+
const rows = entries.map((e) => {
|
|
117
|
+
const bytes = e.type === 'blob' && e.mode !== '120000' ? blobs.get(e.objectSha) : null;
|
|
118
|
+
const entryKind = classifyEntry(e, bytes);
|
|
119
|
+
return {
|
|
120
|
+
path: e.path,
|
|
121
|
+
mode: e.mode,
|
|
122
|
+
objectSha: e.objectSha,
|
|
123
|
+
size: e.size,
|
|
124
|
+
entryKind,
|
|
125
|
+
contentSha256: bytes ? sha256(bytes) : null,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const body = { version: SOURCE_TREE_VERSION, repo, commitSha, treeSha, entries: rows };
|
|
129
|
+
return { manifest: { ...body, manifestSha256: sha256(Buffer.from(canonical(body), 'utf8')) }, blobs };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function arg(argv, flag) { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; }
|
|
133
|
+
|
|
134
|
+
export function main(argv = process.argv.slice(2)) {
|
|
135
|
+
const repoDir = arg(argv, '--repo-dir');
|
|
136
|
+
const commit = arg(argv, '--commit');
|
|
137
|
+
const repo = arg(argv, '--repo');
|
|
138
|
+
const out = arg(argv, '--out');
|
|
139
|
+
if (!repoDir || !commit || !repo) {
|
|
140
|
+
process.stderr.write('Usage: source-tree.mjs --repo-dir <git dir> --commit <sha> --repo <name> [--out <manifest.json>]\n');
|
|
141
|
+
return 64;
|
|
142
|
+
}
|
|
143
|
+
const { manifest } = snapshotManifest({ repoDir: path.resolve(repoDir), commit, repo });
|
|
144
|
+
const json = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
145
|
+
if (out) fs.writeFileSync(out, json); else process.stdout.write(json);
|
|
146
|
+
const kinds = manifest.entries.reduce((acc, e) => ({ ...acc, [e.entryKind]: (acc[e.entryKind] || 0) + 1 }), {});
|
|
147
|
+
process.stderr.write(`[source-tree] ${repo}@${manifest.commitSha.slice(0, 12)} tree ${manifest.treeSha.slice(0, 12)}: ${manifest.entries.length} entries ${JSON.stringify(kinds)}\n`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
|
|
152
|
+
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
153
|
+
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
154
|
+
// never runs -- and because nothing throws, the process exits 0. Pinned by
|
|
155
|
+
// tests/unit/entrypoint-symlink.test.mjs.
|
|
156
|
+
function isDirectInvocation() {
|
|
157
|
+
try {
|
|
158
|
+
if (!process.argv[1]) return false;
|
|
159
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isDirectInvocation()) process.exitCode = main();
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/source-units.mjs — Step 14 (ADR-086, owner criterion C3) deterministic inventory.
|
|
4
|
+
*
|
|
5
|
+
* NO MODEL. Given a checked-out upstream snapshot and its exact commit, enumerate U "meaningful
|
|
6
|
+
* source units", stratify them by top-level directory × language, and select min(100, U) with a
|
|
7
|
+
* PRNG seeded from `${repo}@${commit}`. Same input → byte-identical JSON across a process boundary
|
|
8
|
+
* (tests/unit/oracle-source-units.test.mjs proves it by running the CLI twice).
|
|
9
|
+
*
|
|
10
|
+
* Every unit is bound to path + git blob SHA (sha1 over "blob <len>\0<bytes>", identical to
|
|
11
|
+
* `git hash-object`) + 1-based inclusive line span + sha256 of the unit text. A label produced later
|
|
12
|
+
* can therefore be re-checked against exact upstream bytes, and regenerated ONLY when the blob
|
|
13
|
+
* changes — the amortization Dual's C3 spec asks for.
|
|
14
|
+
*
|
|
15
|
+
* Covered: Markdown (ATX H2/H3 sections), JavaScript/TypeScript (@babel/parser AST: exported or
|
|
16
|
+
* doc-commented top-level functions, classes, interfaces, type aliases, enums, namespaces), Rust
|
|
17
|
+
* (regex: bare `pub` fn/struct/enum/trait/type/union, brace-matched), Python (regex: module-level
|
|
18
|
+
* public def/class blocks). NOT covered (inventoried as `unsupported`, counted, never silently
|
|
19
|
+
* dropped): Go, Java, C/C++, Shell, Svelte, YAML/JSON/TOML, setext Markdown headings, CommonJS
|
|
20
|
+
* `module.exports` assignments, Rust items under `#[cfg(test)]`, Rust `pub(crate)` items.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { parse as babelParse } from '@babel/parser';
|
|
28
|
+
|
|
29
|
+
export const RULES_VERSION = 'oracle-source-units/1';
|
|
30
|
+
export const MAX_SELECTED = 100;
|
|
31
|
+
export const MIN_MARKDOWN_WORDS = 40;
|
|
32
|
+
export const MIN_CODE_TOKENS = 25;
|
|
33
|
+
export const MAX_FILE_BYTES = 1_048_576;
|
|
34
|
+
|
|
35
|
+
export const LANGUAGE_BY_EXT = Object.freeze({
|
|
36
|
+
'.md': 'markdown', '.markdown': 'markdown',
|
|
37
|
+
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
|
|
38
|
+
'.ts': 'typescript', '.tsx': 'typescript', '.mts': 'typescript', '.cts': 'typescript',
|
|
39
|
+
'.rs': 'rust', '.py': 'python',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const VCS_IDE_DIRS = new Set(['.git', '.github', '.gitlab', '.vscode', '.idea', '.circleci', '.husky', '.devcontainer', '.svn', '.hg']);
|
|
43
|
+
const DEP_BUILD_DIRS = new Set(['node_modules', 'vendor', 'vendors', 'third_party', 'third-party', 'bower_components',
|
|
44
|
+
'dist', 'build', 'out', 'target', 'coverage', '__pycache__', '.venv', 'venv', '.cache', '.next', '.turbo',
|
|
45
|
+
'.pytest_cache', '.mypy_cache', 'site-packages']);
|
|
46
|
+
const TEST_DIRS = new Set(['test', 'tests', '__tests__', '__mocks__', 'fixtures', 'fixture', 'snapshots', '__snapshots__', 'e2e', 'spec']);
|
|
47
|
+
const LOCKFILES = new Set(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock',
|
|
48
|
+
'bun.lockb', 'go.sum', 'composer.lock', 'Gemfile.lock', 'flake.lock', 'uv.lock']);
|
|
49
|
+
const META_FILE = /^(license|licence|copying|notice|patents|changelog|changes|history|code_of_conduct|contributing|contributors|authors|security|codeowners)(\.[a-z0-9]+)?$/i;
|
|
50
|
+
const GENERATED_MARKER = /@generated|code generated|do not edit|automatically generated|auto-generated/i;
|
|
51
|
+
const BOILERPLATE_HEADING = /^(table of contents|contents|toc|license|licence|contributing|contributors|acknowledg(e)?ments|changelog|badges|star history|sponsors?|citation|credits)$/i;
|
|
52
|
+
|
|
53
|
+
/** Path-only exclusion rules, applied to every file. Order matters: the first match names the reason. */
|
|
54
|
+
export const PATH_RULES = Object.freeze([
|
|
55
|
+
['vcs-or-ide-dir', (rel) => dirSegments(rel).some((s) => VCS_IDE_DIRS.has(s))],
|
|
56
|
+
['dependency-or-build-dir', (rel) => dirSegments(rel).some((s) => DEP_BUILD_DIRS.has(s))],
|
|
57
|
+
['test-dir', (rel) => dirSegments(rel).some((s) => TEST_DIRS.has(s))],
|
|
58
|
+
['test-file', (rel) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || /(^|\/)test_[^/]*\.py$/.test(rel) || /_test\.py$/.test(rel)],
|
|
59
|
+
['license-changelog-or-meta', (rel) => META_FILE.test(path.posix.basename(rel))],
|
|
60
|
+
['lockfile', (rel) => LOCKFILES.has(path.posix.basename(rel)) || /\.lock$/.test(rel)],
|
|
61
|
+
['minified-map-or-typings', (rel) => /\.min\.[cm]?js$/.test(rel) || /\.map$/.test(rel) || /\.d\.[cm]?ts$/.test(rel) || /\.snap$/.test(rel)],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Byte rules, applied only to files in a covered language (the only ones ever read). */
|
|
65
|
+
export const BYTE_RULES = Object.freeze([
|
|
66
|
+
['oversized', (buf) => buf.length > MAX_FILE_BYTES],
|
|
67
|
+
['binary', (buf) => buf.subarray(0, 8000).includes(0)],
|
|
68
|
+
['generated-marker', (buf) => GENERATED_MARKER.test(buf.subarray(0, 2000).toString('utf8').split('\n').slice(0, 5).join('\n'))],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function dirSegments(rel) { return rel.split('/').slice(0, -1); }
|
|
72
|
+
export function sha256Hex(input) { return createHash('sha256').update(input).digest('hex'); }
|
|
73
|
+
/** Exactly what `git hash-object <file>` prints. */
|
|
74
|
+
export function gitBlobSha(buffer) {
|
|
75
|
+
return createHash('sha1').update(`blob ${buffer.length}\0`).update(buffer).digest('hex');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Markdown ───────────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
const ATX = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
|
|
80
|
+
const FENCE = /^[ \t]{0,3}(```|~~~)/;
|
|
81
|
+
const WORD = /[A-Za-z][A-Za-z0-9'’-]+/g;
|
|
82
|
+
|
|
83
|
+
function fenceTracker() {
|
|
84
|
+
let mark = null;
|
|
85
|
+
return (line) => { // returns true when the line is a fence delimiter or inside a fence
|
|
86
|
+
const f = FENCE.exec(line);
|
|
87
|
+
if (f) {
|
|
88
|
+
if (mark === null) mark = f[1];
|
|
89
|
+
else if (line.trim().startsWith(mark)) mark = null;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return mark !== null;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function countProseWords(lines) {
|
|
97
|
+
const fenced = fenceTracker();
|
|
98
|
+
let words = 0;
|
|
99
|
+
for (const raw of lines) {
|
|
100
|
+
if (fenced(raw)) continue;
|
|
101
|
+
const line = raw.trim();
|
|
102
|
+
if (!line || line.startsWith('|')) continue;
|
|
103
|
+
if (/^(!?\[[^\]]*\]\([^)]*\)\s*)+$/.test(line)) continue; // badges / bare links
|
|
104
|
+
if (/^<[^>]+>$/.test(line)) continue; // html-only line
|
|
105
|
+
const prose = line.replace(/https?:\/\/\S+/g, ' ').replace(/<[^>]+>/g, ' ');
|
|
106
|
+
words += (prose.match(WORD) || []).length;
|
|
107
|
+
}
|
|
108
|
+
return words;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function extractMarkdownUnits(text) {
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const fenced = fenceTracker();
|
|
114
|
+
const headings = [];
|
|
115
|
+
lines.forEach((line, i) => {
|
|
116
|
+
if (fenced(line)) return;
|
|
117
|
+
const m = ATX.exec(line);
|
|
118
|
+
if (m) headings.push({ line: i, level: m[1].length, title: m[2].trim() });
|
|
119
|
+
});
|
|
120
|
+
const units = [];
|
|
121
|
+
headings.forEach((h, idx) => {
|
|
122
|
+
if (h.level !== 2 && h.level !== 3) return;
|
|
123
|
+
if (BOILERPLATE_HEADING.test(h.title.replace(/[*_`:]/g, '').trim())) return;
|
|
124
|
+
let end = lines.length;
|
|
125
|
+
for (let j = idx + 1; j < headings.length; j++) if (headings[j].level <= 3) { end = headings[j].line; break; }
|
|
126
|
+
while (end > h.line + 1 && lines[end - 1].trim() === '') end--;
|
|
127
|
+
const words = countProseWords(lines.slice(h.line + 1, end));
|
|
128
|
+
if (words < MIN_MARKDOWN_WORDS) return;
|
|
129
|
+
units.push({ startLine: h.line + 1, endLine: end, kind: 'md-section', name: h.title });
|
|
130
|
+
});
|
|
131
|
+
return units;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── JavaScript / TypeScript ────────────────────────────────────────────────────────────────────
|
|
135
|
+
const JS_KINDS = {
|
|
136
|
+
FunctionDeclaration: 'js-function', ClassDeclaration: 'js-class', TSInterfaceDeclaration: 'ts-interface',
|
|
137
|
+
TSTypeAliasDeclaration: 'ts-type', TSEnumDeclaration: 'ts-enum', TSDeclareFunction: 'ts-declare-function',
|
|
138
|
+
TSModuleDeclaration: 'ts-namespace',
|
|
139
|
+
};
|
|
140
|
+
const isDocComment = (c) => c.type === 'CommentBlock' && c.value.startsWith('*');
|
|
141
|
+
|
|
142
|
+
function commentStartLine(node) {
|
|
143
|
+
const comments = node.leadingComments || [];
|
|
144
|
+
let start = node.loc.start.line;
|
|
145
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
146
|
+
if (comments[i].loc.end.line < start - 1) break;
|
|
147
|
+
start = comments[i].loc.start.line;
|
|
148
|
+
}
|
|
149
|
+
return start;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function extractJsUnits(text, { typescript = false, jsx = false } = {}) {
|
|
153
|
+
let ast;
|
|
154
|
+
try {
|
|
155
|
+
ast = babelParse(text, {
|
|
156
|
+
sourceType: 'unambiguous', errorRecovery: true, attachComment: true,
|
|
157
|
+
plugins: [...(typescript ? ['typescript'] : []), ...(jsx ? ['jsx'] : []), 'decorators-legacy', 'importAttributes'],
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { error: `parse: ${error.message}`, units: [] };
|
|
161
|
+
}
|
|
162
|
+
const units = [];
|
|
163
|
+
for (const node of ast.program.body) {
|
|
164
|
+
let decl = node;
|
|
165
|
+
let exported = false;
|
|
166
|
+
if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
|
|
167
|
+
if (!node.declaration) continue;
|
|
168
|
+
decl = node.declaration;
|
|
169
|
+
exported = true;
|
|
170
|
+
}
|
|
171
|
+
const documented = (node.leadingComments || []).some(isDocComment);
|
|
172
|
+
if (!exported && !documented) continue;
|
|
173
|
+
let kind = JS_KINDS[decl.type];
|
|
174
|
+
let name = decl.id?.name ?? 'default';
|
|
175
|
+
if (!kind && decl.type === 'VariableDeclaration') {
|
|
176
|
+
const fns = decl.declarations.filter((d) => d.init && /^(Arrow)?FunctionExpression$/.test(d.init.type));
|
|
177
|
+
if (fns.length === 0) continue;
|
|
178
|
+
kind = 'js-const-function';
|
|
179
|
+
name = fns.map((d) => d.id?.name ?? '(pattern)').join(',');
|
|
180
|
+
}
|
|
181
|
+
if (!kind) continue;
|
|
182
|
+
units.push({ startLine: commentStartLine(node), endLine: node.loc.end.line, kind, name });
|
|
183
|
+
}
|
|
184
|
+
return { units };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Rust (regex, conservative) ─────────────────────────────────────────────────────────────────
|
|
188
|
+
const RUST_ITEM = /^\s*pub\s+(?:(?:async|unsafe|const|extern\s+"[^"]*")\s+)*(fn|struct|enum|trait|type|union)\s+([A-Za-z_][A-Za-z0-9_]*)/;
|
|
189
|
+
const stripRustLine = (line) => line
|
|
190
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)'/g, "''").replace(/\/\/.*$/, '');
|
|
191
|
+
|
|
192
|
+
function findRustEnd(lines, from) {
|
|
193
|
+
let depth = 0;
|
|
194
|
+
let opened = false;
|
|
195
|
+
for (let j = from; j < Math.min(lines.length, from + 3000); j++) {
|
|
196
|
+
for (const ch of stripRustLine(lines[j])) {
|
|
197
|
+
if (ch === '{') { depth++; opened = true; } else if (ch === '}') { depth--; if (opened && depth === 0) return j; } else if (ch === ';' && !opened) return j;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function extractRustUnits(text) {
|
|
204
|
+
const lines = text.split('\n');
|
|
205
|
+
const units = [];
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
const m = RUST_ITEM.exec(lines[i]);
|
|
208
|
+
if (!m) continue;
|
|
209
|
+
let start = i;
|
|
210
|
+
while (start > 0 && /^\s*(\/\/\/|#\[)/.test(lines[start - 1])) start--;
|
|
211
|
+
const end = findRustEnd(lines, i);
|
|
212
|
+
if (end < 0) continue;
|
|
213
|
+
units.push({ startLine: start + 1, endLine: end + 1, kind: `rust-${m[1]}`, name: m[2] });
|
|
214
|
+
i = end;
|
|
215
|
+
}
|
|
216
|
+
return units;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Python (regex, conservative) ───────────────────────────────────────────────────────────────
|
|
220
|
+
const PY_DEF = /^(async\s+def|def|class)\s+([A-Za-z][A-Za-z0-9_]*)/;
|
|
221
|
+
|
|
222
|
+
export function extractPythonUnits(text) {
|
|
223
|
+
const lines = text.split('\n');
|
|
224
|
+
const units = [];
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
const m = PY_DEF.exec(lines[i]);
|
|
227
|
+
if (!m) continue;
|
|
228
|
+
let start = i;
|
|
229
|
+
while (start > 0 && /^@/.test(lines[start - 1])) start--;
|
|
230
|
+
let end = i;
|
|
231
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
232
|
+
const l = lines[j];
|
|
233
|
+
if (l.trim() === '') continue;
|
|
234
|
+
if (/^[\s)\]]/.test(l)) { end = j; continue; }
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
units.push({ startLine: start + 1, endLine: end + 1, kind: m[1].startsWith('class') ? 'py-class' : 'py-function', name: m[2] });
|
|
238
|
+
i = end;
|
|
239
|
+
}
|
|
240
|
+
return units;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Per-file extraction ────────────────────────────────────────────────────────────────────────
|
|
244
|
+
const CODE_TOKEN = /[A-Za-z_$][A-Za-z0-9_$]+/g;
|
|
245
|
+
export function unitText(lines, startLine, endLine) { return lines.slice(startLine - 1, endLine).join('\n'); }
|
|
246
|
+
|
|
247
|
+
export function extractUnits(rel, text) {
|
|
248
|
+
const ext = path.posix.extname(rel).toLowerCase();
|
|
249
|
+
const language = LANGUAGE_BY_EXT[ext];
|
|
250
|
+
if (language === 'markdown') return { language, units: extractMarkdownUnits(text) };
|
|
251
|
+
let result;
|
|
252
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
253
|
+
result = extractJsUnits(text, { typescript: language === 'typescript', jsx: ['.js', '.mjs', '.cjs', '.jsx', '.tsx'].includes(ext) });
|
|
254
|
+
} else if (language === 'rust') result = { units: extractRustUnits(text) };
|
|
255
|
+
else if (language === 'python') result = { units: extractPythonUnits(text) };
|
|
256
|
+
else return { language: null, units: [] };
|
|
257
|
+
const lines = text.split('\n');
|
|
258
|
+
const units = result.units.filter((u) => (unitText(lines, u.startLine, u.endLine).match(CODE_TOKEN) || []).length >= MIN_CODE_TOKENS);
|
|
259
|
+
return { language, units, error: result.error };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Snapshot walk ──────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
export function listSnapshotFiles(dir) {
|
|
264
|
+
const out = [];
|
|
265
|
+
const walk = (abs, rel) => {
|
|
266
|
+
let entries;
|
|
267
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
268
|
+
for (const e of entries) {
|
|
269
|
+
if (e.isSymbolicLink()) continue;
|
|
270
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
271
|
+
if (e.isDirectory()) { if (e.name !== '.git') walk(path.join(abs, e.name), childRel); } else if (e.isFile()) out.push(childRel);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
walk(dir, '');
|
|
275
|
+
return out.sort();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Deterministic selection ────────────────────────────────────────────────────────────────────
|
|
279
|
+
export function seededRandom(seed) {
|
|
280
|
+
let counter = 0;
|
|
281
|
+
return () => createHash('sha256').update(seed).update(`\n${counter++}`).digest().readUIntBE(0, 6) / 2 ** 48;
|
|
282
|
+
}
|
|
283
|
+
export function seededShuffle(items, rand) {
|
|
284
|
+
const a = [...items];
|
|
285
|
+
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
|
286
|
+
return a;
|
|
287
|
+
}
|
|
288
|
+
/** Hamilton (largest-remainder) apportionment of `total` slots over strata sizes. */
|
|
289
|
+
export function apportion(sizes, total) {
|
|
290
|
+
const keys = Object.keys(sizes).sort();
|
|
291
|
+
const U = keys.reduce((s, k) => s + sizes[k], 0);
|
|
292
|
+
if (U <= total) return Object.fromEntries(keys.map((k) => [k, sizes[k]]));
|
|
293
|
+
const quota = Object.fromEntries(keys.map((k) => [k, (sizes[k] * total) / U]));
|
|
294
|
+
const alloc = Object.fromEntries(keys.map((k) => [k, Math.floor(quota[k])]));
|
|
295
|
+
let remaining = total - keys.reduce((s, k) => s + alloc[k], 0);
|
|
296
|
+
const byFraction = keys.filter((k) => alloc[k] < sizes[k])
|
|
297
|
+
.sort((a, b) => (quota[b] - alloc[b]) - (quota[a] - alloc[a]) || (a < b ? -1 : 1));
|
|
298
|
+
for (const k of byFraction) { if (remaining <= 0) break; alloc[k]++; remaining--; }
|
|
299
|
+
return alloc;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function resolveCommit(dir, explicit) {
|
|
303
|
+
if (explicit) return explicit;
|
|
304
|
+
return execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function buildInventory({ dir, repo, commit, maxSelected = MAX_SELECTED }) {
|
|
308
|
+
const files = listSnapshotFiles(dir);
|
|
309
|
+
const excluded = {};
|
|
310
|
+
const unsupported = {};
|
|
311
|
+
const uncoveredFiles = [];
|
|
312
|
+
const all = [];
|
|
313
|
+
let filesConsidered = 0;
|
|
314
|
+
for (const rel of files) {
|
|
315
|
+
const rule = PATH_RULES.find(([, test]) => test(rel));
|
|
316
|
+
if (rule) { excluded[rule[0]] = (excluded[rule[0]] || 0) + 1; continue; }
|
|
317
|
+
const ext = path.posix.extname(rel).toLowerCase();
|
|
318
|
+
const language = LANGUAGE_BY_EXT[ext];
|
|
319
|
+
if (!language) { const key = ext || '(none)'; unsupported[key] = (unsupported[key] || 0) + 1; continue; }
|
|
320
|
+
const buf = fs.readFileSync(path.join(dir, rel));
|
|
321
|
+
const byteRule = BYTE_RULES.find(([, test]) => test(buf));
|
|
322
|
+
if (byteRule) { excluded[byteRule[0]] = (excluded[byteRule[0]] || 0) + 1; continue; }
|
|
323
|
+
filesConsidered++;
|
|
324
|
+
const text = buf.toString('utf8');
|
|
325
|
+
const { units, error } = extractUnits(rel, text);
|
|
326
|
+
if (units.length === 0) { uncoveredFiles.push({ path: rel, reason: error ? 'parse-error' : 'no-units' }); continue; }
|
|
327
|
+
const blobSha = gitBlobSha(buf);
|
|
328
|
+
const lines = text.split('\n');
|
|
329
|
+
const topDir = rel.includes('/') ? rel.split('/')[0] : '(root)';
|
|
330
|
+
for (const u of units) {
|
|
331
|
+
const body = unitText(lines, u.startLine, u.endLine);
|
|
332
|
+
all.push({
|
|
333
|
+
unitId: sha256Hex(`${RULES_VERSION}\n${rel}\n${blobSha}\n${u.startLine}\n${u.endLine}`).slice(0, 16),
|
|
334
|
+
path: rel, blobSha, startLine: u.startLine, endLine: u.endLine, kind: u.kind, name: u.name,
|
|
335
|
+
language, stratum: `${topDir}|${language}`, bytesSha256: sha256Hex(Buffer.from(body, 'utf8')),
|
|
336
|
+
chars: body.length,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const sizes = {};
|
|
341
|
+
for (const u of all) sizes[u.stratum] = (sizes[u.stratum] || 0) + 1;
|
|
342
|
+
const alloc = apportion(sizes, maxSelected);
|
|
343
|
+
const seed = `${RULES_VERSION}|${repo}@${commit}`;
|
|
344
|
+
const selectedIds = new Set();
|
|
345
|
+
for (const key of Object.keys(alloc)) {
|
|
346
|
+
const pool = all.filter((u) => u.stratum === key).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.startLine - b.startLine));
|
|
347
|
+
for (const u of seededShuffle(pool, seededRandom(`${seed}|${key}`)).slice(0, alloc[key])) selectedIds.add(u.unitId);
|
|
348
|
+
}
|
|
349
|
+
const selected = all.filter((u) => selectedIds.has(u.unitId));
|
|
350
|
+
return {
|
|
351
|
+
schemaVersion: 1, kind: 'oracle-source-inventory', rulesVersion: RULES_VERSION, repo, commit, seed,
|
|
352
|
+
U: all.length, selectedCount: selected.length, selected,
|
|
353
|
+
strata: Object.keys(sizes).sort().map((key) => ({ key, U: sizes[key], selected: alloc[key] || 0 })),
|
|
354
|
+
coverage: {
|
|
355
|
+
filesTotal: files.length, filesExcluded: excluded, filesUnsupported: unsupported, filesConsidered,
|
|
356
|
+
filesWithUnits: filesConsidered - uncoveredFiles.length, uncoveredFilesCount: uncoveredFiles.length, uncoveredFiles,
|
|
357
|
+
},
|
|
358
|
+
rules: { minMarkdownWords: MIN_MARKDOWN_WORDS, minCodeTokens: MIN_CODE_TOKENS, maxFileBytes: MAX_FILE_BYTES, languages: [...new Set(Object.values(LANGUAGE_BY_EXT))] },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function arg(argv, flag) { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; }
|
|
363
|
+
|
|
364
|
+
export function main(argv = process.argv.slice(2)) {
|
|
365
|
+
const dir = arg(argv, '--dir');
|
|
366
|
+
const repo = arg(argv, '--repo');
|
|
367
|
+
const out = arg(argv, '--out');
|
|
368
|
+
if (!dir || !repo) { process.stderr.write('Usage: source-units.mjs --dir <snapshot> --repo <name> [--commit <sha>] [--out <file>]\n'); return 64; }
|
|
369
|
+
const inventory = buildInventory({ dir: path.resolve(dir), repo, commit: resolveCommit(dir, arg(argv, '--commit')) });
|
|
370
|
+
const json = `${JSON.stringify(inventory, null, 2)}\n`;
|
|
371
|
+
if (out) fs.writeFileSync(out, json); else process.stdout.write(json);
|
|
372
|
+
process.stderr.write(`[source-units] ${repo}@${inventory.commit.slice(0, 10)} U=${inventory.U} selected=${inventory.selectedCount} files=${inventory.coverage.filesTotal} uncovered=${inventory.coverage.uncoveredFilesCount}\n`);
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
|
|
377
|
+
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
378
|
+
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
379
|
+
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
380
|
+
// from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
|
|
381
|
+
// archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
382
|
+
function isDirectInvocation() {
|
|
383
|
+
try {
|
|
384
|
+
if (!process.argv[1]) return false;
|
|
385
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (isDirectInvocation()) process.exitCode = main();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/spike-run.mjs — Step 14 feasibility-spike driver (ADR-086 C3 oracle).
|
|
4
|
+
*
|
|
5
|
+
* One repository per invocation, every stage synchronous, every artefact written OUTSIDE the
|
|
6
|
+
* checkout: inventory (source-units) → subscription-only producer (produce-questions) → deterministic
|
|
7
|
+
* validation (validate-labels) → one summary JSON the report is written from. This is the spike's
|
|
8
|
+
* human-run driver; Step 15 decides whether and how prepareCorpusCandidate consumes these modules,
|
|
9
|
+
* which is why nothing in the pipeline imports this file.
|
|
10
|
+
*
|
|
11
|
+
* node scripts/oracle/spike-run.mjs --repo <name> --dir <snapshot> --out <dir> \
|
|
12
|
+
* [--commit <sha>] [--batch 20] [--max-claude-calls 8] [--max-codex-calls 8] [--effort medium] [--skip-produce]
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { buildInventory, resolveCommit } from './source-units.mjs';
|
|
18
|
+
import { produceQuestions, DEFAULT_BATCH } from './produce-questions.mjs';
|
|
19
|
+
import { loadBgeEmbedder, validateLabels } from './validate-labels.mjs';
|
|
20
|
+
|
|
21
|
+
function arg(argv, flag) { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; }
|
|
22
|
+
const writeJson = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
23
|
+
|
|
24
|
+
export async function runSpike({ repo, dir, out, commit, batchSize = DEFAULT_BATCH, maxClaudeCalls = 8, maxCodexCalls = 8, effort = 'medium', skipProduce = false, log = () => {} }) {
|
|
25
|
+
fs.mkdirSync(out, { recursive: true });
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const inventory = buildInventory({ dir, repo, commit: resolveCommit(dir, commit) });
|
|
28
|
+
writeJson(path.join(out, 'inventory.json'), inventory);
|
|
29
|
+
log(`[spike] ${repo}@${inventory.commit.slice(0, 10)} U=${inventory.U} selected=${inventory.selectedCount}`);
|
|
30
|
+
|
|
31
|
+
const labelsFile = path.join(out, 'labels.json');
|
|
32
|
+
let labels;
|
|
33
|
+
const produceStarted = Date.now();
|
|
34
|
+
if (skipProduce && fs.existsSync(labelsFile)) {
|
|
35
|
+
labels = JSON.parse(fs.readFileSync(labelsFile, 'utf8'));
|
|
36
|
+
log('[spike] reusing existing labels.json');
|
|
37
|
+
} else {
|
|
38
|
+
labels = await produceQuestions({ inventory, snapshotDir: dir, batchSize, maxClaudeCalls, maxCodexCalls, effort, workDir: path.join(out, 'producer-work'), log });
|
|
39
|
+
writeJson(labelsFile, labels);
|
|
40
|
+
}
|
|
41
|
+
const produceMs = Date.now() - produceStarted;
|
|
42
|
+
|
|
43
|
+
const validateStarted = Date.now();
|
|
44
|
+
const embed = await loadBgeEmbedder();
|
|
45
|
+
const validation = await validateLabels({ labels, snapshotDir: dir, embed });
|
|
46
|
+
writeJson(path.join(out, 'validation.json'), validation);
|
|
47
|
+
const validateMs = Date.now() - validateStarted;
|
|
48
|
+
|
|
49
|
+
const a = validation.aggregate;
|
|
50
|
+
const calls = labels.calls;
|
|
51
|
+
const summary = {
|
|
52
|
+
repo, commit: inventory.commit, U: inventory.U, selected: inventory.selectedCount, N: 2 * inventory.selectedCount,
|
|
53
|
+
strata: inventory.strata.length, coverage: { ...inventory.coverage, uncoveredFiles: undefined },
|
|
54
|
+
producer: {
|
|
55
|
+
claude: { requestedModel: labels.producer.claude.requestedModel, calls: calls.filter((c) => c.host === 'claude').length, ok: calls.filter((c) => c.host === 'claude' && c.ok).length, modelUsage: [...new Set(calls.flatMap((c) => c.modelUsage || []))], reportedCostEstimateUsd: calls.reduce((s, c) => s + (c.reportedCostEstimateUsd || 0), 0), timeouts: calls.filter((c) => c.host === 'claude' && c.timedOut).length },
|
|
56
|
+
codex: { requestedModel: labels.producer.codex.requestedModel, calls: calls.filter((c) => c.host === 'codex').length, ok: calls.filter((c) => c.host === 'codex' && c.ok).length, timeouts: calls.filter((c) => c.host === 'codex' && c.timedOut).length, hostErrors: [...new Set(calls.flatMap((c) => c.hostErrors || []))] },
|
|
57
|
+
envAudit: labels.envAudit, skipped: a.skipped, producerErrors: a.producerErrors,
|
|
58
|
+
},
|
|
59
|
+
validation: { total: a.total, pass: a.pass, byCheck: a.byCheck, informationalE: a.informationalE, secondary: a.secondary, codex: a.codex, cosineCalibration: a.cosineCalibration },
|
|
60
|
+
wallClockMs: { produce: produceMs, validate: validateMs, total: Date.now() - started },
|
|
61
|
+
callDurationsMs: calls.map((c) => ({ host: c.host, batch: c.batchIndex, ms: c.durationMs, ok: c.ok })),
|
|
62
|
+
};
|
|
63
|
+
writeJson(path.join(out, 'summary.json'), summary);
|
|
64
|
+
log(`[spike] ${repo}: validation ${a.pass}/${a.total} pass; codex both-yes ${a.codex.bothYes}/${a.codex.withVerdicts}; ${Math.round(summary.wallClockMs.total / 1000)}s`);
|
|
65
|
+
return summary;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
69
|
+
const repo = arg(argv, '--repo');
|
|
70
|
+
const dir = arg(argv, '--dir');
|
|
71
|
+
const out = arg(argv, '--out');
|
|
72
|
+
if (!repo || !dir || !out) { process.stderr.write('Usage: spike-run.mjs --repo <name> --dir <snapshot> --out <dir> [--commit <sha>] [--batch 20] [--max-claude-calls 8] [--max-codex-calls 8] [--effort medium] [--skip-produce]\n'); return 64; }
|
|
73
|
+
const summary = await runSpike({
|
|
74
|
+
repo, dir: path.resolve(dir), out: path.resolve(out), commit: arg(argv, '--commit'),
|
|
75
|
+
batchSize: Number(arg(argv, '--batch') || DEFAULT_BATCH), maxClaudeCalls: Number(arg(argv, '--max-claude-calls') || 8),
|
|
76
|
+
maxCodexCalls: Number(arg(argv, '--max-codex-calls') || 8), effort: arg(argv, '--effort') || 'medium',
|
|
77
|
+
skipProduce: argv.includes('--skip-produce'), log: (line) => process.stderr.write(`${line}\n`),
|
|
78
|
+
});
|
|
79
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
|
|
84
|
+
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
85
|
+
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
86
|
+
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
87
|
+
// from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
|
|
88
|
+
// archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
89
|
+
function isDirectInvocation() {
|
|
90
|
+
try {
|
|
91
|
+
if (!process.argv[1]) return false;
|
|
92
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isDirectInvocation()) process.exitCode = await main();
|