@magnusekdahl/parallix 1.1.1 → 1.2.0
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/config/integration-pipelines.json +9 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/lib/commands/handoff.js +94 -0
- package/lib/commands/integrate.js +1 -1
- package/lib/core/nels.js +199 -0
- package/package.json +1 -1
- package/templates/mission-scaffold.md +1 -1
|
@@ -55,22 +55,23 @@ Also adopt one explicit place for selection metadata:
|
|
|
55
55
|
|
|
56
56
|
`## Refinement Signals` must contain:
|
|
57
57
|
|
|
58
|
-
- `
|
|
58
|
+
- `Predicted NEL bucket`: one of `Small (0–80)`, `Medium (81–235)`, `Large (235+)` per ADR 0047
|
|
59
59
|
- `Confidence`: `High`, `Medium`, or `Low`
|
|
60
60
|
- `Selection note`: `activate as-is`, `split first`, or `defer`, with one short reason
|
|
61
61
|
- `Main drivers`: the 2-4 factors driving the estimate
|
|
62
62
|
|
|
63
|
-
Interpretation rule for
|
|
63
|
+
Interpretation rule for NEL bucket:
|
|
64
64
|
|
|
65
|
-
- it is an approximate
|
|
65
|
+
- it is an approximate measure of engineering change volume (insertions + deletions, whitespace-ignored) for the mission's merge diff against the primary branch
|
|
66
66
|
- it may reflect scope breadth, trust-tier sensitivity, validation burden, review/handoff overhead, context churn, and cross-surface coupling
|
|
67
67
|
- it is a selection aid, not a delivery promise or pseudo-scientific schedule estimate
|
|
68
|
+
- NEL is computed by the reusable `nels` module (`lib/core/nels.js`) which excludes workflow/bookkeeping files per ADR 0047
|
|
68
69
|
|
|
69
70
|
Default activation guidance:
|
|
70
71
|
|
|
71
|
-
- `0
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
72
|
+
- `Small (0–80)`: normally safe to activate as-is when confidence is not low
|
|
73
|
+
- `Medium (81–235)`: split first unless the mission is unusually high leverage and already sharply bounded
|
|
74
|
+
- `Large (235+)`: defer or split before activation under normal conditions
|
|
74
75
|
- if confidence is `Low`, keep refining instead of treating the estimate as reliable enough for pickup
|
|
75
76
|
|
|
76
77
|
Current rollout blocker:
|
|
@@ -84,14 +85,14 @@ Current rollout blocker:
|
|
|
84
85
|
|
|
85
86
|
- The workflow now distinguishes "not ready yet" from "ready, but not yet started."
|
|
86
87
|
- Mission comparison becomes more deliberate because shovel-ready candidates can be compared without immediately activating them.
|
|
87
|
-
- The
|
|
88
|
+
- The NEL bucket signal is lightweight enough to aid selection without creating a second planning system.
|
|
88
89
|
- `MISSION.md` remains the detailed execution contract while Backlog stays the current-state surface.
|
|
89
90
|
|
|
90
91
|
### Negative
|
|
91
92
|
|
|
92
93
|
- The lifecycle gains one more state conceptually, so operators must learn one more transition.
|
|
93
94
|
- Some older workflow docs and historical artifacts will continue to mention the earlier five-state model.
|
|
94
|
-
-
|
|
95
|
+
- NEL bucket estimates still depend on judgment and may drift if agents stop recording the main drivers behind the estimate.
|
|
95
96
|
|
|
96
97
|
## Alternatives Considered
|
|
97
98
|
|
|
@@ -132,4 +133,5 @@ Negative:
|
|
|
132
133
|
## Links
|
|
133
134
|
|
|
134
135
|
- [ADR 0026](0026-ai-task-state-and-agent-recovery-surface.md)
|
|
136
|
+
- [ADR 0047: Per-Mission Change Size Budget](0047-per-mission-change-size-budget.md) — NEL bucket definitions and exclusion globs
|
|
135
137
|
- [Mission](../missions/2026/task-024-mission-complexity-estimation/MISSION.md)
|
|
@@ -18,21 +18,21 @@ Introduce three sizing tracks and a dependency-wave planning heuristic.
|
|
|
18
18
|
|
|
19
19
|
### 1. Sizing Tracks
|
|
20
20
|
|
|
21
|
-
| Track | Scope | Process |
|
|
22
|
-
|
|
23
|
-
| **Quick Flow** | < 3 files, < 2h estimated, or docs-only. | Minimal `MISSION.md` (Goal, Why Now, Refinement Signals, Gates). Skip detailed checkpoints if straightforward. |
|
|
24
|
-
| **Full Method** | > 3 files, complex logic, or > 2h. | Standard `MISSION.md` with all sections. Detailed checkpoints. Mandatory external review (C2 review remains required for sensitive scopes per AGENTS.md). |
|
|
25
|
-
| **Multi-Wave** | Very large, high risk, or complex dependencies. | Split into multiple `Full Method` missions (waves). |
|
|
21
|
+
| Track | Scope | Process | NEL Budget |
|
|
22
|
+
|-------|-------|---------|------------|
|
|
23
|
+
| **Quick Flow** | < 3 files, < 2h estimated, or docs-only. | Minimal `MISSION.md` (Goal, Why Now, Refinement Signals, Gates). Skip detailed checkpoints if straightforward. | 0–80 NEL (Small) |
|
|
24
|
+
| **Full Method** | > 3 files, complex logic, or > 2h. | Standard `MISSION.md` with all sections. Detailed checkpoints. Mandatory external review (C2 review remains required for sensitive scopes per AGENTS.md). | 81–235 NEL (Medium) |
|
|
25
|
+
| **Multi-Wave** | Very large, high risk, or complex dependencies. | Split into multiple `Full Method` missions (waves). | 235+ NEL (Large) |
|
|
26
26
|
|
|
27
|
-
### 2. "Too Large" Thresholds
|
|
27
|
+
### 2. "Too Large" Thresholds
|
|
28
28
|
|
|
29
|
-
A mission is "Too Large"
|
|
29
|
+
A mission is "Too Large" for single-wave execution when its predicted NEL bucket exceeds the following thresholds:
|
|
30
30
|
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
31
|
+
- **Small (0–80 NEL)**: suitable for Quick Flow; no decomposition needed.
|
|
32
|
+
- **Medium (81–235 NEL)**: suitable for Full Method; consider splitting if dependencies are complex.
|
|
33
|
+
- **Large (235+ NEL)**: MUST be split into dependency waves before activation.
|
|
34
34
|
|
|
35
|
-
Missions exceeding these thresholds MUST be split into dependency waves before activation.
|
|
35
|
+
These thresholds are derived from empirical terciles in task-1355 data (n=29 missions) as documented in ADR 0047. Missions exceeding these thresholds MUST be split into dependency waves before activation.
|
|
36
36
|
|
|
37
37
|
### 3. Dependency-Wave Heuristic
|
|
38
38
|
|
|
@@ -70,10 +70,12 @@ Agents MUST assign themselves to a task in `Backlog.md` before beginning work in
|
|
|
70
70
|
- Improved visibility of parallel work via mandatory assignment.
|
|
71
71
|
|
|
72
72
|
### Negative
|
|
73
|
+
|
|
73
74
|
- Requires upfront estimation effort during the `draft` phase.
|
|
74
|
-
- Agents must
|
|
75
|
+
- Agents must estimate NEL buckets, which requires understanding the exclusion rules from ADR 0047.
|
|
75
76
|
|
|
76
77
|
## Links
|
|
77
78
|
- [ADR 0032](0032-mission-refinement-state-and-usage-budget-signals.md)
|
|
79
|
+
- [ADR 0047: Per-Mission Change Size Budget](0047-per-mission-change-size-budget.md) — NEL bucket definitions and exclusion globs
|
|
78
80
|
- [MISSION_FLOW.md](../../MISSION_FLOW.md)
|
|
79
81
|
- [AGENTS.md](../../AGENTS.md)
|
|
@@ -145,7 +145,7 @@ Option B (Forgejo Actions) would require runner credentials for docker push and
|
|
|
145
145
|
|
|
146
146
|
## Deliverables
|
|
147
147
|
|
|
148
|
-
1. **Repo-side config:** `config/integration-pipelines.json` with entries for `server`, `auth-server`, `web-client`, `web-e2e`; `web-e2e` carries `run_last: true`
|
|
148
|
+
1. **Repo-side config:** `config/integration-pipelines.json` with entries for `server`, `auth-server`, `web-client`, `web-e2e`; `web-e2e` carries `run_last: true`. Task-1362 added a `lib` entry mapping to `./scripts/verify-local.sh static-analysis` as a required gate for `lib/` changes.
|
|
149
149
|
2. **Change detection:** Compute the set of top-level dirs touched by `mission/<slug>` vs the primary branch
|
|
150
150
|
3. **Dispatch in `scripts/verify-local.sh`:** New `gate_integrate()` function and `integrate` area in the case statement
|
|
151
151
|
4. **`node workflow integrate` preflight:** Calls the new `integrate` area as part of `printIntegrationPreflight`
|
package/lib/commands/handoff.js
CHANGED
|
@@ -12,6 +12,7 @@ const fmt = require('../core/fmt');
|
|
|
12
12
|
const { runVerificationGate } = require('../core/verification');
|
|
13
13
|
const { isForgejoReviewEnabled } = require('../core/product-config');
|
|
14
14
|
const { rebaseBeforeReviewRound } = require('../review/rebase');
|
|
15
|
+
const nels = require('../core/nels');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Verifies that the current environment is ready for handoff.
|
|
@@ -228,6 +229,15 @@ const { rebaseBeforeReviewRound } = require('../review/rebase');
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
// Step 1.7: NEL capture — compute actual NEL from merge diff and persist record
|
|
233
|
+
log('Step 1.7: Capturing Net Engineering Lines (NEL) at handoff...');
|
|
234
|
+
const nelResult = captureNelAtHandoff(slug, { rootDir, missionDir: missionDirPath, log, error });
|
|
235
|
+
if (nelResult.ok) {
|
|
236
|
+
log(fmt.status('PASS', `NEL captured: ${nelResult.nel} NEL (${nelResult.bucket.label} bucket)`));
|
|
237
|
+
} else {
|
|
238
|
+
log(fmt.status('WARN', `NEL capture skipped: ${nelResult.error}`));
|
|
239
|
+
}
|
|
240
|
+
|
|
231
241
|
// Step 2: Forgejo PR Update/Create (optional mirror when Forgejo is enabled)
|
|
232
242
|
let token = null;
|
|
233
243
|
let fallbackUser = null;
|
|
@@ -570,6 +580,89 @@ function buildAutoCheckpointContent(slug) {
|
|
|
570
580
|
return true;
|
|
571
581
|
}
|
|
572
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Capture NEL (Net Engineering Lines) at handoff time.
|
|
585
|
+
*
|
|
586
|
+
* Computes actual NEL from the merge diff (primary..HEAD), reads the predicted
|
|
587
|
+
* bucket from the mission's Refinement Signals, resolves review rounds from
|
|
588
|
+
* review-state.json, and persists a per-mission NEL record as `nel-record.json`.
|
|
589
|
+
*
|
|
590
|
+
* This is purely observational — no enforcement, gates, or blocks.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} slug - Mission slug
|
|
593
|
+
* @param {{ rootDir: string, missionDir: string, log: Function, error: Function }} options
|
|
594
|
+
* @returns {{ ok: boolean, nel?: number, bucket?: string, error?: string }}
|
|
595
|
+
*/
|
|
596
|
+
function captureNelAtHandoff(slug, options) {
|
|
597
|
+
const { rootDir, missionDir, error } = options;
|
|
598
|
+
|
|
599
|
+
// 1. Determine primary branch for diff range
|
|
600
|
+
let primaryBranch;
|
|
601
|
+
try {
|
|
602
|
+
primaryBranch = missionUtils.getPrimaryBranch(rootDir);
|
|
603
|
+
} catch (_) {
|
|
604
|
+
return { ok: false, error: 'could not detect primary branch for NEL diff range' };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!primaryBranch) {
|
|
608
|
+
return { ok: false, error: 'primary branch is empty' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Compute actual NEL from primary..HEAD
|
|
612
|
+
let nelRecord;
|
|
613
|
+
try {
|
|
614
|
+
nelRecord = nels.computeNELRecord(`${primaryBranch}..HEAD`, { cwd: rootDir });
|
|
615
|
+
} catch (_) {
|
|
616
|
+
return { ok: false, error: 'NEL computation failed' };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const actualNel = nelRecord.nel;
|
|
620
|
+
const actualBucket = nelRecord.bucket.label;
|
|
621
|
+
|
|
622
|
+
// 3. Read predicted bucket from MISSION.md Refinement Signals
|
|
623
|
+
const missionMdPath = path.join(missionDir, 'MISSION.md');
|
|
624
|
+
let predictedBucket = 'Unknown';
|
|
625
|
+
if (fs.existsSync(missionMdPath)) {
|
|
626
|
+
const content = fs.readFileSync(missionMdPath, 'utf8');
|
|
627
|
+
const predictedMatch = content.match(/Predicted NEL bucket:\s*(Small|Medium|Large)/i);
|
|
628
|
+
if (predictedMatch) {
|
|
629
|
+
predictedBucket = predictedMatch[1];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 4. Read review rounds from review-state.json
|
|
634
|
+
let reviewRounds = 1;
|
|
635
|
+
const reviewStatePath = path.join(missionDir, 'review-state.json');
|
|
636
|
+
if (fs.existsSync(reviewStatePath)) {
|
|
637
|
+
try {
|
|
638
|
+
const rs = JSON.parse(fs.readFileSync(reviewStatePath, 'utf8'));
|
|
639
|
+
reviewRounds = rs.round || 1;
|
|
640
|
+
} catch (_) {
|
|
641
|
+
// ignore parse errors
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 5. Persist NEL record
|
|
646
|
+
const nelRecordPath = path.join(missionDir, 'nel-record.json');
|
|
647
|
+
const record = {
|
|
648
|
+
slug,
|
|
649
|
+
predictedBucket,
|
|
650
|
+
actualNel,
|
|
651
|
+
actualBucket,
|
|
652
|
+
reviewRounds,
|
|
653
|
+
capturedAt: new Date().toISOString(),
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
fs.writeFileSync(nelRecordPath, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
658
|
+
} catch (err) {
|
|
659
|
+
error(`Failed to write NEL record: ${err.message}`);
|
|
660
|
+
return { ok: false, error: `failed to write NEL record: ${err.message}` };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return { ok: true, nel: actualNel, bucket: actualBucket };
|
|
664
|
+
}
|
|
665
|
+
|
|
573
666
|
/** @param {string[]} args */
|
|
574
667
|
async function handoffCommand(args) {
|
|
575
668
|
const explicitSlug = args[0];
|
|
@@ -593,3 +686,4 @@ module.exports.verifyHandoff = verifyHandoff;
|
|
|
593
686
|
module.exports.performHandoff = performHandoff;
|
|
594
687
|
module.exports.gatekeeper = gatekeeper;
|
|
595
688
|
module.exports.runDeclaredGates = runDeclaredGates;
|
|
689
|
+
module.exports.captureNelAtHandoff = captureNelAtHandoff;
|
|
@@ -269,7 +269,7 @@ function detectChangedAreas(slug, opts = {}) {
|
|
|
269
269
|
/** @param {string} filesOutput */
|
|
270
270
|
function parseFilesToAreas(filesOutput) {
|
|
271
271
|
const areas = new Set();
|
|
272
|
-
const knownAreas = ['server', 'auth-server', 'web-client', 'docs', 'workflow', 'android', 'kubernetes'];
|
|
272
|
+
const knownAreas = ['lib', 'server', 'auth-server', 'web-client', 'docs', 'workflow', 'android', 'kubernetes'];
|
|
273
273
|
|
|
274
274
|
filesOutput.split('\n').forEach((/** @type{string} */ file) => {
|
|
275
275
|
file = file.trim();
|
package/lib/core/nels.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Net Engineering Lines (NEL) computation.
|
|
3
|
+
*
|
|
4
|
+
* Computes NEL from `git diff --numstat -w` for a given diff range,
|
|
5
|
+
* excluding workflow/process bookkeeping, documentation, and generated files
|
|
6
|
+
* per ADR 0047.
|
|
7
|
+
*
|
|
8
|
+
* Bucket thresholds (empirical terciles from task-1355 data, n=29):
|
|
9
|
+
* - Small: 0–80 NEL (11% rework rate)
|
|
10
|
+
* - Medium: 81–235 NEL (22% rework rate)
|
|
11
|
+
* - Large: 235+ NEL (73% rework rate)
|
|
12
|
+
*
|
|
13
|
+
* This module is purely observational — no enforcement, gates, or blocks.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/core/nels
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
// ---------- exclusion globs (ADR 0047) ----------
|
|
21
|
+
// Each entry is a minimatch-compatible pattern. Patterns are checked in order;
|
|
22
|
+
// the first match wins.
|
|
23
|
+
|
|
24
|
+
const EXCLUSION_PATTERNS = [
|
|
25
|
+
'missions/**', // workflow/process bookkeeping
|
|
26
|
+
'backlog/**', // workflow/process bookkeeping
|
|
27
|
+
'review-*', // workflow/process bookkeeping
|
|
28
|
+
'CP-*', // workflow/process bookkeeping
|
|
29
|
+
'**/*.md', // documentation
|
|
30
|
+
'docs/**', // documentation
|
|
31
|
+
'package-lock.json', // generated/vendored
|
|
32
|
+
'coverage/**', // generated/vendored
|
|
33
|
+
'*lock*', // lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, etc.)
|
|
34
|
+
'*.lock', // additional lockfile safety net
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bucket constants derived from ADR 0047 empirical terciles.
|
|
39
|
+
*/
|
|
40
|
+
const BUCKET_SMALL_MAX = 80;
|
|
41
|
+
const BUCKET_MEDIUM_MAX = 235;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classify NEL count into a bucket label.
|
|
45
|
+
*
|
|
46
|
+
* @param {number} nel
|
|
47
|
+
* @returns {{ label: 'Small' | 'Medium' | 'Large', min: number, max: number }}
|
|
48
|
+
*/
|
|
49
|
+
function classifyBucket(nel) {
|
|
50
|
+
if (nel <= BUCKET_SMALL_MAX) {
|
|
51
|
+
return { label: 'Small', min: 0, max: BUCKET_SMALL_MAX };
|
|
52
|
+
}
|
|
53
|
+
if (nel <= BUCKET_MEDIUM_MAX) {
|
|
54
|
+
return { label: 'Medium', min: BUCKET_SMALL_MAX + 1, max: BUCKET_MEDIUM_MAX };
|
|
55
|
+
}
|
|
56
|
+
return { label: 'Large', min: BUCKET_MEDIUM_MAX + 1, max: Infinity };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check whether a file path matches any exclusion pattern.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} filePath - Relative file path from repo root
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function isExcluded(filePath) {
|
|
66
|
+
// Normalize to forward slashes
|
|
67
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
68
|
+
|
|
69
|
+
for (const pattern of EXCLUSION_PATTERNS) {
|
|
70
|
+
if (patternMatches(normalized, pattern)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Simple glob matcher supporting *, **, and literal segments.
|
|
79
|
+
* Does not require a full minimatch dependency — keeps this module light.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} str
|
|
82
|
+
* @param {string} pattern
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function patternMatches(str, pattern) {
|
|
86
|
+
// Convert minimatch-style glob to regex
|
|
87
|
+
// Escape regex special chars except * and ?
|
|
88
|
+
let regexStr = '';
|
|
89
|
+
let i = 0;
|
|
90
|
+
const pLen = pattern.length;
|
|
91
|
+
|
|
92
|
+
while (i < pLen) {
|
|
93
|
+
const ch = pattern[i];
|
|
94
|
+
if (ch === '*') {
|
|
95
|
+
if (i + 1 < pLen && pattern[i + 1] === '*') {
|
|
96
|
+
// ** matches everything including /
|
|
97
|
+
if (i + 2 < pLen && pattern[i + 2] === '/') {
|
|
98
|
+
regexStr += '(?:.*/)?';
|
|
99
|
+
i += 3;
|
|
100
|
+
continue;
|
|
101
|
+
} else {
|
|
102
|
+
regexStr += '.*';
|
|
103
|
+
i += 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
regexStr += '[^/]*';
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
} else if (ch === '?') {
|
|
112
|
+
regexStr += '[^/]';
|
|
113
|
+
} else if ('.+^${}()|[]\\'.includes(ch)) {
|
|
114
|
+
regexStr += '\\' + ch;
|
|
115
|
+
} else {
|
|
116
|
+
regexStr += ch;
|
|
117
|
+
}
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const re = new RegExp('^' + regexStr + '$');
|
|
122
|
+
return re.test(str);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compute Net Engineering Lines (NEL) from a git diff range.
|
|
127
|
+
*
|
|
128
|
+
* Runs `git diff --numstat -w <range>` and sums insertions+deletions
|
|
129
|
+
* for files not matching exclusion globs from ADR 0047.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} range - Git diff range, e.g. `main..HEAD` or `HEAD~1..HEAD`
|
|
132
|
+
* @param {{ cwd?: string }} [options]
|
|
133
|
+
* @returns {number} Total NEL count (insertions + deletions, whitespace-ignored)
|
|
134
|
+
*/
|
|
135
|
+
function computeNEL(range, options = {}) {
|
|
136
|
+
const cwd = options.cwd || process.cwd();
|
|
137
|
+
|
|
138
|
+
const result = spawnSync('git', ['diff', '--numstat', '-w', range], {
|
|
139
|
+
cwd,
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
maxBuffer: 50 * 1024 * 1024, // 50 MB
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (result.status !== 0) {
|
|
145
|
+
// Git error, empty diff, or uncomputable — return 0
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const stdout = result.stdout || '';
|
|
150
|
+
const lines = stdout.split('\n').filter(line => line.trim().length > 0);
|
|
151
|
+
|
|
152
|
+
let total = 0;
|
|
153
|
+
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
// numstat format: <insertions>\t<deletions>\t<filename>
|
|
156
|
+
// Binary files show: -\t-\t<filename>
|
|
157
|
+
const parts = line.split('\t');
|
|
158
|
+
if (parts.length < 3) { continue; }
|
|
159
|
+
|
|
160
|
+
const adds = parts[0];
|
|
161
|
+
const dels = parts[1];
|
|
162
|
+
const filePath = parts.slice(2).join('\t');
|
|
163
|
+
|
|
164
|
+
// Skip binary files
|
|
165
|
+
if (adds === '-' || dels === '-') { continue; }
|
|
166
|
+
|
|
167
|
+
// Apply exclusion globs
|
|
168
|
+
if (isExcluded(filePath)) { continue; }
|
|
169
|
+
|
|
170
|
+
const addsNum = parseInt(adds, 10) || 0;
|
|
171
|
+
const delsNum = parseInt(dels, 10) || 0;
|
|
172
|
+
|
|
173
|
+
total += addsNum + delsNum;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return total;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Compute NEL and return the full NEL record including bucket classification.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} range - Git diff range
|
|
183
|
+
* @param {{ cwd?: string }} [options]
|
|
184
|
+
* @returns {{ nel: number, bucket: { label: string, min: number, max: number } }}
|
|
185
|
+
*/
|
|
186
|
+
function computeNELRecord(range, options) {
|
|
187
|
+
const nel = computeNEL(range, options);
|
|
188
|
+
return { nel, bucket: classifyBucket(nel) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
computeNEL,
|
|
193
|
+
computeNELRecord,
|
|
194
|
+
classifyBucket,
|
|
195
|
+
isExcluded,
|
|
196
|
+
EXCLUSION_PATTERNS,
|
|
197
|
+
BUCKET_SMALL_MAX,
|
|
198
|
+
BUCKET_MEDIUM_MAX,
|
|
199
|
+
};
|
package/package.json
CHANGED