@scalequality/check 0.4.0 → 0.5.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.
@@ -0,0 +1,225 @@
1
+ import { readCommits, listHeadFiles, blameFile } from "./GitReader.mjs";
2
+ import { classifyCommit } from "./OriginClassifier.mjs";
3
+ const DEFAULT_CHURN_DAYS = 14;
4
+ const DEFAULT_MAX_BLAME_FILES = 500;
5
+ const HIGH_VELOCITY_LOC_PER_WEEK = 1500; // "AI-like" cadence threshold
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const sum = (xs) => xs.reduce((a, b) => a + b, 0);
8
+ function assembleSurvival(introduced, surviving) {
9
+ const out = {};
10
+ for (const key of new Set([...Object.keys(introduced), ...Object.keys(surviving)])) {
11
+ const intro = introduced[key] ?? 0;
12
+ const surv = surviving[key] ?? 0;
13
+ out[key] = {
14
+ linesIntroduced: intro,
15
+ linesSurviving: Math.min(surv, intro),
16
+ survivalRate: intro > 0 ? Math.min(1, Math.round((surv / intro) * 1000) / 1000) : 0,
17
+ };
18
+ }
19
+ return out;
20
+ }
21
+ const FIX_RE = /\b(fix|bug|hotfix|patch|revert|broke|regression)\b/i;
22
+ const REVERT_RE = /\brevert(s|ed|ing)?\b|this reverts commit/i;
23
+ function isRevert(c) {
24
+ return REVERT_RE.test(c.body);
25
+ }
26
+ /** Churn: a file touched again within churnDays of a prior touch. */
27
+ function computeChurn(commits, churnDays) {
28
+ const fileTouches = new Map();
29
+ for (const c of commits) {
30
+ const t = Date.parse(c.date);
31
+ const perFile = c.files.length > 0 ? (c.additions + c.deletions) / c.files.length : 0;
32
+ for (const f of c.files) {
33
+ if (!fileTouches.has(f))
34
+ fileTouches.set(f, []);
35
+ fileTouches.get(f).push({ t, addDel: perFile });
36
+ }
37
+ }
38
+ let churnLinesAffected = 0;
39
+ let churnEvents = 0;
40
+ let filesWithNew = 0;
41
+ let filesReChurned2w = 0;
42
+ const TWO_WEEKS = 14 * DAY_MS;
43
+ for (const touches of fileTouches.values()) {
44
+ touches.sort((a, b) => a.t - b.t);
45
+ filesWithNew++;
46
+ let reChurned2w = false;
47
+ for (let i = 1; i < touches.length; i++) {
48
+ const gap = Math.abs(touches[i].t - touches[i - 1].t);
49
+ if (gap <= churnDays * DAY_MS) {
50
+ churnEvents++;
51
+ churnLinesAffected += Math.round(touches[i].addDel);
52
+ }
53
+ if (touches[i].t - touches[0].t <= TWO_WEEKS)
54
+ reChurned2w = true;
55
+ }
56
+ if (reChurned2w)
57
+ filesReChurned2w++;
58
+ }
59
+ const newCodeChurn2w = filesWithNew > 0 ? Math.round((filesReChurned2w / filesWithNew) * 1000) / 1000 : 0;
60
+ return { churnLinesAffected, churnEvents, newCodeChurn2w };
61
+ }
62
+ /** Cascade: fix-commits that closely follow a prior commit on shared files. */
63
+ function computeCascadeRate(commits, churnDays) {
64
+ if (commits.length === 0)
65
+ return 0;
66
+ const sorted = [...commits].sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
67
+ let cascades = 0;
68
+ for (let i = 0; i < sorted.length; i++) {
69
+ if (!FIX_RE.test(sorted[i].body))
70
+ continue;
71
+ const ti = Date.parse(sorted[i].date);
72
+ const fi = new Set(sorted[i].files);
73
+ for (let j = i - 1; j >= 0; j--) {
74
+ if (ti - Date.parse(sorted[j].date) > churnDays * DAY_MS)
75
+ break;
76
+ if (sorted[j].files.some((f) => fi.has(f))) {
77
+ cascades++;
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ return Math.round((cascades / sorted.length) * 1000) / 1000;
83
+ }
84
+ /** Stabilization: share of touched files touched exactly once (settled). */
85
+ function computeStabilization(commits) {
86
+ const counts = new Map();
87
+ for (const c of commits)
88
+ for (const f of c.files)
89
+ counts.set(f, (counts.get(f) ?? 0) + 1);
90
+ if (counts.size === 0)
91
+ return 0;
92
+ let stabilized = 0;
93
+ for (const n of counts.values())
94
+ if (n === 1)
95
+ stabilized++;
96
+ return Math.round((stabilized / counts.size) * 1000) / 1000;
97
+ }
98
+ /**
99
+ * Attribution gap (directional): authors writing at AI-like velocity
100
+ * (>threshold loc/week) but with low AI attribution → suspected
101
+ * unattributed AI. Never falsely re-classifies a commit; this is a flag.
102
+ */
103
+ function computeAttributionGap(commits, windowDays) {
104
+ const weeks = Math.max(1, windowDays / 7);
105
+ const byAuthor = new Map();
106
+ for (const c of commits) {
107
+ if (c.origin === "BOT")
108
+ continue;
109
+ const a = byAuthor.get(c.email) ?? { total: 0, ai: 0, loc: 0 };
110
+ a.total++;
111
+ if (c.origin === "AI_ATTRIBUTED")
112
+ a.ai++;
113
+ a.loc += c.additions + c.deletions;
114
+ byAuthor.set(c.email, a);
115
+ }
116
+ let suspected = 0;
117
+ let total = 0;
118
+ for (const a of byAuthor.values()) {
119
+ total += a.total;
120
+ const aiPct = a.total > 0 ? a.ai / a.total : 0;
121
+ const locPerWeek = a.loc / weeks;
122
+ if (locPerWeek > HIGH_VELOCITY_LOC_PER_WEEK && aiPct < 0.5) {
123
+ suspected += a.total * (1 - aiPct); // the non-attributed share
124
+ }
125
+ }
126
+ const pct = total > 0 ? Math.round((suspected / total) * 100) : 0;
127
+ const confidence = "LOW";
128
+ return { suspectedAttributionGapPct: pct, attributionConfidence: confidence };
129
+ }
130
+ /**
131
+ * Native durability engine. Analyses a cloned repo's git history (log +
132
+ * blame) and returns durability/origin/churn/attribution metrics by
133
+ * origin and tool. Deterministic, no LLM, no external engine.
134
+ */
135
+ export async function analyzeDurability(repoPath, opts) {
136
+ const churnDays = opts.churnDays ?? DEFAULT_CHURN_DAYS;
137
+ const maxBlame = opts.maxBlameFiles ?? DEFAULT_MAX_BLAME_FILES;
138
+ const log = opts.onLog ?? (() => { });
139
+ const endDaysAgo = opts.endDaysAgo ?? 0;
140
+ const settleDays = opts.settleDays ?? 0;
141
+ const raw = await readCommits(repoPath, opts.windowDays + endDaysAgo, endDaysAgo);
142
+ const commits = raw.map((c) => ({ ...c, ...classifyCommit(c) }));
143
+ log(`commits in window: ${commits.length}`);
144
+ // Survival is measured ONLY on commits old enough to have settled (older than
145
+ // settleDays). Code written in the last settleDays is right-censored — it
146
+ // hasn't had time to be reverted, so counting it would inflate survival.
147
+ // Churn, revert, cascade and attribution stay on the full window.
148
+ const settleCutoff = Date.now() - settleDays * DAY_MS;
149
+ const measureCommits = settleDays > 0
150
+ ? commits.filter((c) => Date.parse(c.date) <= settleCutoff)
151
+ : commits;
152
+ const measureShas = new Map(measureCommits.map((c) => [c.sha, c]));
153
+ const introducedByOrigin = {};
154
+ const introducedByTool = {};
155
+ for (const c of measureCommits) {
156
+ introducedByOrigin[c.origin] = (introducedByOrigin[c.origin] ?? 0) + c.additions;
157
+ if (c.tool)
158
+ introducedByTool[c.tool] = (introducedByTool[c.tool] ?? 0) + c.additions;
159
+ }
160
+ const headFiles = await listHeadFiles(repoPath);
161
+ const touched = new Set();
162
+ for (const c of commits)
163
+ for (const f of c.files)
164
+ if (headFiles.has(f))
165
+ touched.add(f);
166
+ let files = [...touched];
167
+ if (files.length > maxBlame) {
168
+ log(`blame capped: ${maxBlame}/${files.length} files (perf)`);
169
+ files = files.slice(0, maxBlame);
170
+ }
171
+ log(`blaming ${files.length} files...`);
172
+ const survivingByOrigin = {};
173
+ const survivingByTool = {};
174
+ for (const f of files) {
175
+ const counts = await blameFile(repoPath, f);
176
+ for (const [sha, n] of counts) {
177
+ const c = measureShas.get(sha);
178
+ if (!c)
179
+ continue; // line introduced outside the settled measurement window
180
+ survivingByOrigin[c.origin] = (survivingByOrigin[c.origin] ?? 0) + n;
181
+ if (c.tool)
182
+ survivingByTool[c.tool] = (survivingByTool[c.tool] ?? 0) + n;
183
+ }
184
+ }
185
+ const byOrigin = assembleSurvival(introducedByOrigin, survivingByOrigin);
186
+ const byTool = assembleSurvival(introducedByTool, survivingByTool);
187
+ const linesIntroduced = sum(Object.values(introducedByOrigin));
188
+ // Clamp: a surviving line was, by definition, introduced — survival
189
+ // can't exceed 100% (guards against any blame/window edge).
190
+ const linesSurviving = Math.min(sum(Object.values(survivingByOrigin)), linesIntroduced);
191
+ const survivalRate = linesIntroduced > 0 ? linesSurviving / linesIntroduced : 0;
192
+ const { churnLinesAffected, newCodeChurn2w } = computeChurn(commits, churnDays);
193
+ const cascadeRate = computeCascadeRate(commits, churnDays);
194
+ const stabilizationRatio = computeStabilization(commits);
195
+ const revertCount = commits.filter(isRevert).length;
196
+ const revertRate = commits.length > 0 ? revertCount / commits.length : 0;
197
+ const aiCommits = commits.filter((c) => c.origin === "AI_ATTRIBUTED").length;
198
+ const nonBot = commits.filter((c) => c.origin !== "BOT").length;
199
+ const aiDetectionCoveragePct = nonBot > 0 ? Math.round((aiCommits / nonBot) * 100) : 0;
200
+ const { suspectedAttributionGapPct, attributionConfidence } = computeAttributionGap(commits, opts.windowDays);
201
+ const durablePct = Math.round(survivalRate * 100);
202
+ const revertPct = Math.round(revertRate * 100);
203
+ const churnPct = Math.max(0, 100 - durablePct - revertPct);
204
+ const signalToNoise = churnLinesAffected > 0 ? Math.round((linesSurviving / churnLinesAffected) * 100) / 100 : null;
205
+ return {
206
+ commitsTotal: commits.length,
207
+ linesIntroduced,
208
+ linesSurviving,
209
+ survivalRate: Math.round(survivalRate * 1000) / 1000,
210
+ stabilizationRatio,
211
+ churnLinesAffected,
212
+ revertRate: Math.round(revertRate * 1000) / 1000,
213
+ newCodeChurn2w,
214
+ cascadeRate,
215
+ aiDetectionCoveragePct,
216
+ durablePct,
217
+ churnPct,
218
+ revertPct,
219
+ signalToNoise,
220
+ suspectedAttributionGapPct,
221
+ attributionConfidence,
222
+ byOrigin,
223
+ byTool,
224
+ };
225
+ }
@@ -0,0 +1,121 @@
1
+ import { runGit as git, runGitLines, isFatalGitError, GitMeasurementError } from "./runtime.mjs";
2
+ const US = "\x1f"; // unit separator (field)
3
+ const RS = "\x1e"; // record separator (commit)
4
+ const CO_AUTHOR_RE = /co-authored-by:\s*(.*?)\s*<([^>]+)>/gi;
5
+ function parseCoAuthors(body) {
6
+ const out = [];
7
+ let m;
8
+ CO_AUTHOR_RE.lastIndex = 0;
9
+ while ((m = CO_AUTHOR_RE.exec(body)) !== null) {
10
+ out.push({ name: m[1].trim(), email: m[2].trim().toLowerCase() });
11
+ }
12
+ return out;
13
+ }
14
+ /**
15
+ * Reads commits in the window (metadata + co-authors via one log call,
16
+ * file stats via a second --numstat call) and merges them. Two calls so
17
+ * the multi-line commit body can't corrupt numstat line parsing.
18
+ */
19
+ export async function readCommits(repoPath, sinceDays, untilDays = 0) {
20
+ const since = `${sinceDays} days ago`;
21
+ // untilDays > 0 bounds the window's END in the past (point-in-time backfill).
22
+ const untilArgs = untilDays > 0 ? [`--until=${untilDays} days ago`] : [];
23
+ // Pass 1: metadata + body (for co-author trailers).
24
+ const metaFmt = ["%H", "%an", "%ae", "%cI", "%B"].join(US) + RS;
25
+ const metaOut = await git(repoPath, [
26
+ "log",
27
+ `--since=${since}`,
28
+ ...untilArgs,
29
+ "--no-merges",
30
+ `--pretty=format:${metaFmt}`,
31
+ ]);
32
+ const bySha = new Map();
33
+ for (const rec of metaOut.split(RS)) {
34
+ const trimmed = rec.replace(/^\n+/, "");
35
+ if (!trimmed.trim())
36
+ continue;
37
+ const parts = trimmed.split(US);
38
+ if (parts.length < 5)
39
+ continue;
40
+ const [sha, author, email, date, body] = parts;
41
+ bySha.set(sha, {
42
+ sha,
43
+ author,
44
+ email: email.toLowerCase(),
45
+ date,
46
+ body,
47
+ coAuthors: parseCoAuthors(body),
48
+ additions: 0,
49
+ deletions: 0,
50
+ files: [],
51
+ });
52
+ }
53
+ // Pass 2: per-commit numstat.
54
+ const statOut = await git(repoPath, [
55
+ "log",
56
+ `--since=${since}`,
57
+ ...untilArgs,
58
+ "--no-merges",
59
+ "--numstat",
60
+ `--pretty=format:${RS}%H`,
61
+ ]);
62
+ for (const block of statOut.split(RS)) {
63
+ if (!block.trim())
64
+ continue;
65
+ const lines = block.split("\n").filter((l) => l.length > 0);
66
+ const sha = lines.shift()?.trim();
67
+ if (!sha)
68
+ continue;
69
+ const rec = bySha.get(sha);
70
+ if (!rec)
71
+ continue;
72
+ for (const line of lines) {
73
+ const cols = line.split("\t");
74
+ if (cols.length < 3)
75
+ continue;
76
+ const add = Number.parseInt(cols[0], 10); // "-" (binary) → NaN
77
+ const del = Number.parseInt(cols[1], 10);
78
+ rec.additions += Number.isFinite(add) ? add : 0;
79
+ rec.deletions += Number.isFinite(del) ? del : 0;
80
+ rec.files.push(cols.slice(2).join("\t"));
81
+ }
82
+ }
83
+ return [...bySha.values()];
84
+ }
85
+ /** Files that currently exist at HEAD (tracked). */
86
+ export async function listHeadFiles(repoPath) {
87
+ const out = await git(repoPath, ["ls-tree", "-r", "--name-only", "HEAD"]);
88
+ return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
89
+ }
90
+ const BLAME_GROUP = /^([0-9a-f]{40}) [1-9][0-9]* [1-9][0-9]* ([1-9][0-9]*)$/;
91
+ /**
92
+ * Blames a file at HEAD and returns, per introducing commit sha, how many
93
+ * CURRENT lines it owns. Incremental output describes identical attribution
94
+ * in groups, without source lines or repeated per-line commit metadata.
95
+ * Stream those groups so large output does not require a large buffer.
96
+ * Ordinary per-file failures return no counts; resource/history errors abort.
97
+ */
98
+ export async function blameFile(repoPath, file) {
99
+ const counts = new Map();
100
+ try {
101
+ await runGitLines(repoPath, [
102
+ "blame",
103
+ "--incremental",
104
+ "HEAD",
105
+ "--",
106
+ file,
107
+ ], line => {
108
+ const m = BLAME_GROUP.exec(line);
109
+ if (!m) return;
110
+ const total = (counts.get(m[1]) ?? 0) + Number(m[2]);
111
+ if (!Number.isSafeInteger(total)) throw new GitMeasurementError('Git blame line count exceeded the numeric limit');
112
+ counts.set(m[1], total);
113
+ });
114
+ }
115
+ catch (error) {
116
+ if (isFatalGitError(error)) throw error;
117
+ // deleted at HEAD / binary / unreadable → no surviving lines counted
118
+ counts.clear();
119
+ }
120
+ return counts;
121
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Native origin classifier (our own). Deterministic, confidence-aware.
3
+ *
4
+ * Signals (high confidence): Co-Authored-By trailers + author identity
5
+ * matching known AI tools / bots. NO content analysis, NO false
6
+ * attribution — a commit without an AI signal is HUMAN. Unattributed-but-
7
+ * likely-AI is surfaced separately by AttributionGapAnalyzer (SUSPECTED),
8
+ * never silently classified here.
9
+ */
10
+ const AI_TOOL_PATTERNS = [
11
+ [/copilot|github-copilot/i, "Copilot"],
12
+ [/claude|anthropic/i, "Claude"],
13
+ [/cursor/i, "Cursor"],
14
+ [/codeium/i, "Codeium"],
15
+ [/tabnine/i, "Tabnine"],
16
+ [/amazon[-\s]?q/i, "Amazon Q"],
17
+ [/gemini/i, "Gemini"],
18
+ [/windsurf/i, "Windsurf"],
19
+ [/devin/i, "Devin"],
20
+ [/aider/i, "Aider"],
21
+ ];
22
+ const BOT_PATTERN = /\[bot\]|-bot\b|\bbot@|dependabot|renovate|github-actions|mergify|snyk-bot|copilot-autofix/i;
23
+ function matchTool(haystack) {
24
+ for (const [re, tool] of AI_TOOL_PATTERNS) {
25
+ if (re.test(haystack))
26
+ return tool;
27
+ }
28
+ return null;
29
+ }
30
+ export function classifyCommit(c) {
31
+ // 1. Co-author trailers → strongest AI signal.
32
+ for (const ca of c.coAuthors) {
33
+ const tool = matchTool(`${ca.email} ${ca.name}`);
34
+ if (tool)
35
+ return { origin: "AI_ATTRIBUTED", tool, confidence: "HIGH" };
36
+ }
37
+ // 2. Author identity is itself an AI tool (e.g. copilot@github.com).
38
+ const authorTool = matchTool(`${c.email} ${c.author}`);
39
+ if (authorTool && !BOT_PATTERN.test(`${c.author} ${c.email}`)) {
40
+ return { origin: "AI_ATTRIBUTED", tool: authorTool, confidence: "HIGH" };
41
+ }
42
+ // 3. Bot.
43
+ if (BOT_PATTERN.test(`${c.author} ${c.email}`)) {
44
+ return { origin: "BOT", tool: null, confidence: "HIGH" };
45
+ }
46
+ // 4. Default human.
47
+ return { origin: "HUMAN", tool: null, confidence: "HIGH" };
48
+ }
@@ -0,0 +1,42 @@
1
+ # Bundled local Git measurement
2
+
3
+ The four generated modules come from `scalequality-ai-governance-service` commit
4
+ `35a9ac72e2a131c108b40c9d2477f58a90deb4c1`, under
5
+ `src/application/services/durability/`. `provenance.json` records original SHA-256
6
+ hashes, compiler settings, generated hashes, and the source-manifest digest exported
7
+ by `../durability.mjs`.
8
+
9
+ To regenerate, extract the four named TypeScript files from that exact Git commit
10
+ into a temporary directory. Compile with TypeScript 5.9.3, target/module ES2022,
11
+ moduleResolution node, strict true, esModuleInterop true, skipLibCheck true, and
12
+ Node type declarations. No dependencies are needed by the resulting runtime.
13
+ Rename the generated files to `.mjs` and give their relative imports `.mjs`
14
+ extensions. The engine's calculations and classifier remain unchanged. In the
15
+ reader, replace its promisified `execFile` wrapper with `runtime.mjs`'s `runGit`.
16
+ For blame, use `runGitLines` with `--incremental` and sum each group's line count
17
+ instead of counting individual `--line-porcelain` headers. Attribution is unchanged;
18
+ source text and repeated per-line metadata are not emitted or retained. Rethrow
19
+ `isFatalGitError(error)` in the per-file blame catch. Discard partial streamed
20
+ counts on ordinary per-file failures, retaining the native empty-map behavior.
21
+ Recompute provenance hashes and run the
22
+ parity and cancellation tests. The reference modules under
23
+ `test/native-durability-reference/` preserve the original reader for small fixture
24
+ comparisons only; they are excluded from the published package.
25
+
26
+ The runtime keeps the native 90-day window, 14-day settlement and churn settings,
27
+ and native maximum of 500 blamed files. A native cap warning is logged; this does
28
+ not certify that every tracked file was blamed. Tracked dependency files remain
29
+ part of native Git measurement even if archive exclusions omit those directories.
30
+
31
+ No clone, fetch, checkout or Git write is performed. Shallow or partial histories,
32
+ missing objects, changing HEAD and invalid reports produce no measurement.
33
+ The separate worker has a 120-second total budget and 512 MiB V8 heap ceiling;
34
+ each Git command has at most 30 seconds. Buffered commands retain at most 16 MiB
35
+ of output. Incremental blame streams stdout, retaining one line (at most 16 MiB)
36
+ and at most 16 MiB of stderr, rather than limiting total streamed output. The complete HEAD
37
+ object inventory uses this same limit, so sufficiently large object inventories
38
+ can be unavailable even when the 90-day change window is small. Limit failures
39
+ are reported as unavailable, never converted into successful zero measurements.
40
+ Only the measurement worker's own process group and Git children are cleaned up
41
+ on cancellation or parent IPC disconnection. Git command output, author details,
42
+ credentials and customer environment tokens are not logged or sent to the worker.
@@ -0,0 +1,53 @@
1
+ {
2
+ "engineCommit": "35a9ac72e2a131c108b40c9d2477f58a90deb4c1",
3
+ "sourceRepository": "scalequality-ai-governance-service",
4
+ "sourceFiles": [
5
+ {
6
+ "file": "src/application/services/durability/GitDurabilityEngine.ts",
7
+ "sha256": "4210aba0a7dbfa6ca62178ff434dcaef81b0ca77c72a8fa488ae48f03c734105"
8
+ },
9
+ {
10
+ "file": "src/application/services/durability/GitReader.ts",
11
+ "sha256": "bc6ca21db40bbdadebdccfbfb7f3dcab3125eeed96d2199a6bd309c949410e9b"
12
+ },
13
+ {
14
+ "file": "src/application/services/durability/OriginClassifier.ts",
15
+ "sha256": "ac58ba1684b63ee34c168b39574f777087fadebe9ce80e2c1da8948f7bb3bd62"
16
+ },
17
+ {
18
+ "file": "src/application/services/durability/types.ts",
19
+ "sha256": "f102fabbeb0a3eee09ca0be64bb7777f12c553c91ebd615340132e674ea66026"
20
+ }
21
+ ],
22
+ "sourceManifestSha256": "bc7a01ce93df3aad2f80da281934868ba7327a1a04fad840f35ecc8b422c74cf",
23
+ "compiler": {
24
+ "version": "5.9.3",
25
+ "target": "ES2022",
26
+ "module": "ES2022",
27
+ "strictTypecheckPassed": true
28
+ },
29
+ "runtimeChanges": [
30
+ "ESM relative import extensions only in engine/classifier",
31
+ "GitReader process wrapper replaced by bounded local-only runner",
32
+ "GitReader blame catch rethrows resource/incomplete-history failures; ordinary per-file errors retain native handling",
33
+ "GitReader blame streams incremental groups and sums their line counts with exact native attribution; bounded lines and stderr preserve resource limits without capping total streamed stdout"
34
+ ],
35
+ "generatedFiles": [
36
+ {
37
+ "file": "GitDurabilityEngine.mjs",
38
+ "sha256": "7aee834f36a2737f1e7dd459ed067653c264a5d46ce5d602ed3fbba24ae1cc8a"
39
+ },
40
+ {
41
+ "file": "GitReader.mjs",
42
+ "sha256": "727cc9dd9278ac29dd18d5d482918c20ee7c7550356376614051566ca2f4abd0"
43
+ },
44
+ {
45
+ "file": "OriginClassifier.mjs",
46
+ "sha256": "f1da36019423b8e60ad9c9c69b690c55fde4333de27dd3ee08a95ec97f09d7b7"
47
+ },
48
+ {
49
+ "file": "types.mjs",
50
+ "sha256": "50401f50b011535140ddc298f3b532cc931a0039247b96e64e7352c04c022723"
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,120 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+
3
+ export const MAX_GIT_BUFFER = 16 * 1024 * 1024
4
+ export const MAX_GIT_TIME_MS = 30_000
5
+ let deadline = 0
6
+ const children = new Set()
7
+
8
+ export class GitMeasurementError extends Error {
9
+ constructor(reason) { super(reason); this.reason = reason; this.fatalMeasurement = true }
10
+ }
11
+ export const isFatalGitError = (error) => error?.fatalMeasurement === true
12
+ export function setDeadline(value) { deadline = value }
13
+ export function remainingTime() {
14
+ const remaining = deadline - Date.now()
15
+ if (remaining <= 0) throw new GitMeasurementError('time budget exceeded')
16
+ return remaining
17
+ }
18
+ export function stopGitChildren() {
19
+ for (const child of children) { try { child.kill('SIGKILL') } catch {} }
20
+ }
21
+
22
+ export function localGitEnvironment() {
23
+ const env = {}
24
+ for (const name of ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TMPDIR', 'TMP', 'TEMP']) {
25
+ if (process.env[name] !== undefined) env[name] = process.env[name]
26
+ }
27
+ return { ...env, LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1' }
28
+ }
29
+
30
+ function gitArgs(repoPath, args) {
31
+ return [
32
+ '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'),
33
+ '-c', 'credential.helper=', '-c', 'protocol.allow=never', '-c', 'protocol.file.allow=never',
34
+ '-C', repoPath, ...args,
35
+ ]
36
+ }
37
+
38
+ function gitError(error, stderr) {
39
+ if (error.code === 'ENOENT') return new GitMeasurementError('Git is unavailable')
40
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS') return new GitMeasurementError('Git output exceeded the 16 MiB resource limit')
41
+ if (error.killed || error.signal || error.code === 'ETIMEDOUT') return new GitMeasurementError('Git command exceeded the time budget')
42
+ if (/missing (?:blob|tree|commit|object)|bad object|unable to read|could not read|object .*corrupt|object file .*empty|invalid object|promisor|transport .*not allowed/i.test(stderr || '')) {
43
+ return new GitMeasurementError('Git history is incomplete or unavailable locally')
44
+ }
45
+ // Never propagate command output, author identities or credentials to logs.
46
+ const safeError = new Error('Git command failed')
47
+ safeError.code = error.code
48
+ return safeError
49
+ }
50
+
51
+ export async function runGit(repoPath, args, { allowMissingConfig = false } = {}) {
52
+ const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
53
+ return new Promise((resolve, reject) => {
54
+ const child = execFile('git', gitArgs(repoPath, args), { encoding: 'utf8', maxBuffer: MAX_GIT_BUFFER, timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment() }, (error, stdout, stderr) => {
55
+ children.delete(child)
56
+ if (!error) { try { remainingTime(); resolve(stdout) } catch (e) { reject(e) }; return }
57
+ if (allowMissingConfig && error.code === 1) { resolve(''); return }
58
+ reject(gitError(error, stderr))
59
+ })
60
+ children.add(child)
61
+ })
62
+ }
63
+
64
+ /** Consume stdout a line at a time; retain at most one bounded line, not the full output. */
65
+ export async function runGitLines(repoPath, args, onLine) {
66
+ const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
67
+ return new Promise((resolve, reject) => {
68
+ let pending = ''
69
+ let stderr = ''
70
+ let failure
71
+ const child = spawn('git', gitArgs(repoPath, args), {
72
+ stdio: ['ignore', 'pipe', 'pipe'], timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment(),
73
+ })
74
+ children.add(child)
75
+ const fail = error => {
76
+ failure ??= error
77
+ child.kill('SIGKILL')
78
+ }
79
+ const emit = line => {
80
+ if (Buffer.byteLength(line) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
81
+ onLine(line)
82
+ }
83
+ child.stdout.setEncoding('utf8')
84
+ child.stdout.on('data', chunk => {
85
+ if (failure) return
86
+ try {
87
+ remainingTime()
88
+ const text = pending + chunk
89
+ let start = 0
90
+ for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
91
+ emit(text.slice(start, end))
92
+ start = end + 1
93
+ }
94
+ pending = text.slice(start)
95
+ if (Buffer.byteLength(pending) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
96
+ } catch (error) {
97
+ fail(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
98
+ }
99
+ })
100
+ child.stderr.setEncoding('utf8')
101
+ child.stderr.on('data', chunk => {
102
+ if (failure) return
103
+ if (Buffer.byteLength(stderr) + Buffer.byteLength(chunk) > MAX_GIT_BUFFER) fail(gitError({ code: 'ENOBUFS' }))
104
+ else stderr += chunk
105
+ })
106
+ child.on('error', error => { failure ??= gitError(error, stderr) })
107
+ child.on('close', (code, signal) => {
108
+ children.delete(child)
109
+ if (failure) { reject(failure); return }
110
+ if (code !== 0 || signal) { reject(gitError({ code, signal, killed: child.killed }, stderr)); return }
111
+ try {
112
+ remainingTime()
113
+ if (pending) emit(pending)
114
+ resolve()
115
+ } catch (error) {
116
+ reject(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
117
+ }
118
+ })
119
+ })
120
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Native durability engine — domain types. Our own reimplementation of
3
+ * git-history durability analysis (no external engine dependency).
4
+ */
5
+ export {};
@@ -0,0 +1,31 @@
1
+ export const ENGINE_COMMIT = '35a9ac72e2a131c108b40c9d2477f58a90deb4c1'
2
+ const COUNTS = ['commitsTotal', 'linesIntroduced', 'linesSurviving', 'churnLinesAffected']
3
+ const RATIOS = ['survivalRate', 'stabilizationRatio', 'revertRate', 'newCodeChurn2w', 'cascadeRate']
4
+ const PCTS = ['aiDetectionCoveragePct', 'durablePct', 'churnPct', 'revertPct', 'suspectedAttributionGapPct']
5
+ const TOOLS = ['Copilot', 'Claude', 'Cursor', 'Codeium', 'Tabnine', 'Amazon Q', 'Gemini', 'Windsurf', 'Devin', 'Aider']
6
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
7
+ const keys = (v, names) => object(v) && Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k))
8
+ const count = (v) => Number.isSafeInteger(v) && v >= 0
9
+ const ratio = (v) => Number.isFinite(v) && v >= 0 && v <= 1
10
+ const roundedRatio = (surviving, introduced) => introduced > 0 ? Math.round(surviving / introduced * 1000) / 1000 : 0
11
+ function survivalMap(map, allowed) {
12
+ return object(map) && Object.entries(map).every(([key, v]) => allowed.includes(key) && keys(v, ['linesIntroduced', 'linesSurviving', 'survivalRate']) && count(v.linesIntroduced) && count(v.linesSurviving) && ratio(v.survivalRate) && v.linesSurviving <= v.linesIntroduced && v.survivalRate === roundedRatio(v.linesSurviving, v.linesIntroduced))
13
+ }
14
+ export function validateReport(report, now = Date.now()) {
15
+ try {
16
+ if (!keys(report, ['version', 'source', 'engineCommit', 'windowDays', 'settleDays', 'headSha', 'measuredAt', 'result'])) return false
17
+ if (report.version !== 1 || report.source !== 'ci' || report.engineCommit !== ENGINE_COMMIT || report.windowDays !== 90 || report.settleDays !== 14 || !/^[0-9a-f]{40}$/.test(report.headSha)) return false
18
+ if (typeof report.measuredAt !== 'string' || !Number.isFinite(Date.parse(report.measuredAt)) || Math.abs(now - Date.parse(report.measuredAt)) > 86_400_000) return false
19
+ const r = report.result
20
+ if (!keys(r, [...COUNTS, ...RATIOS, ...PCTS, 'signalToNoise', 'attributionConfidence', 'byOrigin', 'byTool'])) return false
21
+ if (!COUNTS.every(k => count(r[k])) || !RATIOS.every(k => ratio(r[k])) || !PCTS.every(k => count(r[k]) && r[k] <= 100)) return false
22
+ if (!(r.signalToNoise === null || (Number.isFinite(r.signalToNoise) && r.signalToNoise >= 0)) || r.attributionConfidence !== 'LOW') return false
23
+ if (!survivalMap(r.byOrigin, ['AI_ATTRIBUTED', 'BOT', 'HUMAN']) || !survivalMap(r.byTool, TOOLS)) return false
24
+ const sum = (map, key) => Object.values(map).reduce((n, v) => n + v[key], 0)
25
+ if (sum(r.byOrigin, 'linesIntroduced') !== r.linesIntroduced || sum(r.byOrigin, 'linesSurviving') > r.linesSurviving || r.linesSurviving > r.linesIntroduced || r.survivalRate !== roundedRatio(r.linesSurviving, r.linesIntroduced)) return false
26
+ if (r.durablePct !== (r.linesIntroduced > 0 ? Math.round(r.linesSurviving / r.linesIntroduced * 100) : 0) || Math.abs(r.revertPct - r.revertRate * 100) > 0.55 || r.churnPct !== Math.max(0, 100 - r.durablePct - r.revertPct)) return false
27
+ if (sum(r.byTool, 'linesIntroduced') !== (r.byOrigin.AI_ATTRIBUTED?.linesIntroduced ?? 0) || sum(r.byTool, 'linesSurviving') > (r.byOrigin.AI_ATTRIBUTED?.linesSurviving ?? 0)) return false
28
+ if (r.signalToNoise !== (r.churnLinesAffected > 0 ? Math.round(r.linesSurviving / r.churnLinesAffected * 100) / 100 : null)) return false
29
+ return Buffer.byteLength(JSON.stringify(report)) <= 1024 * 1024
30
+ } catch { return false }
31
+ }