@dev-loops/core 0.1.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/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseReviewThreads, readInput, parseJsonText, formatCliError } from "../src/github/review-threads.mjs";
|
|
6
|
+
import { extractDeepPersonaSignals } from "../src/debt/deep-persona-signals.mjs";
|
|
7
|
+
|
|
8
|
+
export const USAGE = [
|
|
9
|
+
"Usage: capture-deep-persona-signals.mjs --input <path> --pr-number <n> --pr-url <url> [--output-dir <path>]",
|
|
10
|
+
"",
|
|
11
|
+
"Arguments:",
|
|
12
|
+
" --input <path> Path to normalized review-thread JSON (required)",
|
|
13
|
+
" --pr-number <n> PR number for metadata (required)",
|
|
14
|
+
" --pr-url <url> PR URL for metadata (required)",
|
|
15
|
+
" --output-dir <path> Output directory for emitted artifact (default: .pi/debt/signals/)",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse CLI arguments for the capture-deep-persona-signals CLI.
|
|
20
|
+
*
|
|
21
|
+
* @param {string[]} argv - Argument list (e.g. process.argv.slice(2))
|
|
22
|
+
* @returns {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const args = [...argv];
|
|
26
|
+
const options = {
|
|
27
|
+
inputPath: undefined,
|
|
28
|
+
prNumber: undefined,
|
|
29
|
+
prUrl: undefined,
|
|
30
|
+
outputDir: ".pi/debt/signals",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
while (args.length > 0) {
|
|
34
|
+
const token = args.shift();
|
|
35
|
+
|
|
36
|
+
switch (token) {
|
|
37
|
+
case "--input": {
|
|
38
|
+
const value = args.shift();
|
|
39
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
40
|
+
throw Object.assign(new Error("Missing value for --input"), { usage: USAGE });
|
|
41
|
+
}
|
|
42
|
+
options.inputPath = value;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "--pr-number": {
|
|
46
|
+
const value = args.shift();
|
|
47
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
48
|
+
throw Object.assign(new Error("Missing value for --pr-number"), { usage: USAGE });
|
|
49
|
+
}
|
|
50
|
+
if (!/^\d+$/.test(value)) {
|
|
51
|
+
throw Object.assign(new Error(`--pr-number must be a positive integer, got: ${value}`), { usage: USAGE });
|
|
52
|
+
}
|
|
53
|
+
options.prNumber = value;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case "--pr-url": {
|
|
57
|
+
const value = args.shift();
|
|
58
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
59
|
+
throw Object.assign(new Error("Missing value for --pr-url"), { usage: USAGE });
|
|
60
|
+
}
|
|
61
|
+
options.prUrl = value;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "--output-dir": {
|
|
65
|
+
const value = args.shift();
|
|
66
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
67
|
+
throw Object.assign(new Error("Missing value for --output-dir"), { usage: USAGE });
|
|
68
|
+
}
|
|
69
|
+
options.outputDir = value;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
throw Object.assign(new Error(`Unknown argument: ${token}`), { usage: USAGE });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!options.inputPath) {
|
|
78
|
+
throw Object.assign(new Error("--input is required"), { usage: USAGE });
|
|
79
|
+
}
|
|
80
|
+
if (!options.prNumber) {
|
|
81
|
+
throw Object.assign(new Error("--pr-number is required"), { usage: USAGE });
|
|
82
|
+
}
|
|
83
|
+
if (!options.prUrl) {
|
|
84
|
+
throw Object.assign(new Error("--pr-url is required"), { usage: USAGE });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return /** @type {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }} */ (options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate the output filename.
|
|
92
|
+
* @param {string} prNumber
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function outputFilename(prNumber) {
|
|
96
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
97
|
+
return `deep-persona-signals-${prNumber}-${ts}.json`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
101
|
+
const options = parseArgs(argv);
|
|
102
|
+
|
|
103
|
+
// Read and parse the review-thread input
|
|
104
|
+
const rawText = await readInput({ inputPath: options.inputPath });
|
|
105
|
+
const parsed = parseReviewThreads(parseJsonText(rawText));
|
|
106
|
+
|
|
107
|
+
// Extract deep-persona signals
|
|
108
|
+
const signals = extractDeepPersonaSignals(parsed, {
|
|
109
|
+
prNumber: options.prNumber,
|
|
110
|
+
prUrl: options.prUrl,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Build artifact envelope
|
|
114
|
+
const artifact = {
|
|
115
|
+
version: 1,
|
|
116
|
+
generatedAt: new Date().toISOString(),
|
|
117
|
+
prNumber: Number(options.prNumber),
|
|
118
|
+
prUrl: options.prUrl,
|
|
119
|
+
source: "pr_review_deep_persona",
|
|
120
|
+
signalCount: signals.length,
|
|
121
|
+
signals,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Write to output directory
|
|
125
|
+
const outDir = resolve(options.outputDir);
|
|
126
|
+
await mkdir(outDir, { recursive: true });
|
|
127
|
+
const outPath = join(outDir, outputFilename(options.prNumber));
|
|
128
|
+
await writeFile(outPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
129
|
+
|
|
130
|
+
process.stdout.write(JSON.stringify({ ok: true, outputPath: outPath, signalCount: signals.length }) + "\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Only auto-run when executed directly (not imported)
|
|
134
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
135
|
+
if (process.argv[1] === scriptPath) {
|
|
136
|
+
run().catch((error) => {
|
|
137
|
+
if (error.usage) {
|
|
138
|
+
process.stderr.write(error.usage + "\n\n");
|
|
139
|
+
}
|
|
140
|
+
process.stderr.write(formatCliError(error) + "\n");
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
});
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dev-loops/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./bash-exit-one": "./src/bash-exit-one.mjs",
|
|
8
|
+
"./cli/helpers": "./src/cli/helpers.mjs",
|
|
9
|
+
"./cli/primitives": "./src/cli/primitives.mjs",
|
|
10
|
+
"./cli/retry-wrapper": "./src/cli/retry-wrapper.mjs",
|
|
11
|
+
"./cli/subcommand-runner": "./src/cli/subcommand-runner.mjs",
|
|
12
|
+
"./config": "./src/config/config.mjs",
|
|
13
|
+
"./debt/cluster": "./src/debt/cluster.mjs",
|
|
14
|
+
"./debt/finding": "./src/debt/debt-finding.mjs",
|
|
15
|
+
"./debt/remediation-to-issue": "./src/debt/remediation-to-issue.mjs",
|
|
16
|
+
"./debt/score": "./src/debt/score.mjs",
|
|
17
|
+
"./debt/shape": "./src/debt/shape.mjs",
|
|
18
|
+
"./debt/signal": "./src/debt/debt-signal.mjs",
|
|
19
|
+
"./debt/signals": "./src/debt/deep-persona-signals.mjs",
|
|
20
|
+
"./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
|
|
21
|
+
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
22
|
+
"./github/review-threads": "./src/github/review-threads.mjs",
|
|
23
|
+
"./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
|
|
24
|
+
"./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
|
|
25
|
+
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
26
|
+
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
27
|
+
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
28
|
+
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
29
|
+
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
30
|
+
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
31
|
+
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
32
|
+
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
33
|
+
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
34
|
+
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
35
|
+
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
36
|
+
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
37
|
+
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
38
|
+
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
39
|
+
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
40
|
+
"./loop/steering": "./src/loop/steering.mjs",
|
|
41
|
+
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
42
|
+
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
|
43
|
+
"./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
|
|
44
|
+
"./harness": "./src/harness/index.mjs",
|
|
45
|
+
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
46
|
+
"./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
|
|
47
|
+
},
|
|
48
|
+
"bin": {
|
|
49
|
+
"dev-loops-log-bash-exit-1": "./bin/log-bash-exit-1.mjs",
|
|
50
|
+
"dev-loops-ensure-phase-files": "./bin/ensure-phase-files.mjs",
|
|
51
|
+
"dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs",
|
|
52
|
+
"dev-loops-capture-deep-persona-signals": "./bin/capture-deep-persona-signals.mjs"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"src/**/*.mjs",
|
|
56
|
+
"bin/**/*.mjs"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "node --test ./test/*.test.mjs"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public",
|
|
63
|
+
"provenance": true
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "https://github.com/mfittko/dev-loops.git"
|
|
68
|
+
},
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/mfittko/dev-loops/issues"
|
|
71
|
+
},
|
|
72
|
+
"homepage": "https://github.com/mfittko/dev-loops#readme",
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"yaml": "^2.9.0",
|
|
75
|
+
"zod": "^4.4.3"
|
|
76
|
+
},
|
|
77
|
+
"license": "MIT"
|
|
78
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change category classification and angle relevance index.
|
|
3
|
+
*
|
|
4
|
+
* Maps change categories detected by the diff analyzer to relevant gate review
|
|
5
|
+
* angles.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Change categories
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** @enum {string} */
|
|
13
|
+
export const ChangeCategory = Object.freeze({
|
|
14
|
+
RENAME_ONLY: "RENAME_ONLY",
|
|
15
|
+
DOCS_ONLY: "DOCS_ONLY",
|
|
16
|
+
CONFIG_ONLY: "CONFIG_ONLY",
|
|
17
|
+
TEST_ONLY: "TEST_ONLY",
|
|
18
|
+
CI_ONLY: "CI_ONLY",
|
|
19
|
+
COMMENT_ONLY: "COMMENT_ONLY",
|
|
20
|
+
LOGIC_CHANGE: "LOGIC_CHANGE",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Angle relevance index
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map of ChangeCategory → relevant gate angles.
|
|
29
|
+
*
|
|
30
|
+
* Categories not listed default to an empty array (no angles relevant).
|
|
31
|
+
* When multiple categories match, the union of angles is taken.
|
|
32
|
+
*
|
|
33
|
+
* @type {Record<string, string[]>}
|
|
34
|
+
*/
|
|
35
|
+
const CATEGORY_ANGLE_MAP = {
|
|
36
|
+
[ChangeCategory.RENAME_ONLY]: [
|
|
37
|
+
"scope", "correctness", "contract-surface", "docs", "link-check",
|
|
38
|
+
],
|
|
39
|
+
[ChangeCategory.DOCS_ONLY]: [
|
|
40
|
+
"docs", "link-check", "contract-surface", "dry",
|
|
41
|
+
],
|
|
42
|
+
[ChangeCategory.CONFIG_ONLY]: [
|
|
43
|
+
"config-drift", "scope", "correctness", "contract-surface",
|
|
44
|
+
],
|
|
45
|
+
[ChangeCategory.TEST_ONLY]: [
|
|
46
|
+
"coverage", "correctness", "determinism",
|
|
47
|
+
],
|
|
48
|
+
[ChangeCategory.CI_ONLY]: [
|
|
49
|
+
"ci-guard", "scope", "config-drift",
|
|
50
|
+
],
|
|
51
|
+
[ChangeCategory.COMMENT_ONLY]: [
|
|
52
|
+
"dry",
|
|
53
|
+
],
|
|
54
|
+
[ChangeCategory.LOGIC_CHANGE]: [
|
|
55
|
+
"correctness", "coverage", "kiss", "dry", "srp", "soc", "deep",
|
|
56
|
+
"ocp", "lsp", "isp", "dip", "yagni", "scope", "no-op", "determinism",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Angles that are never skipped, regardless of diff analysis.
|
|
62
|
+
*
|
|
63
|
+
* @type {Set<string>}
|
|
64
|
+
*/
|
|
65
|
+
const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-description"]);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Resolution
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} DynamicAngleResult
|
|
73
|
+
* @property {string[]} recommendedAngles — angles to run
|
|
74
|
+
* @property {string[]} skippedAngles — angles skipped with reasons
|
|
75
|
+
* @property {Record<string, string>} reasons — why each angle was skipped
|
|
76
|
+
* @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve which gate angles to run based on detected change categories.
|
|
81
|
+
*
|
|
82
|
+
* When the diff is ambiguous (contains LOGIC_CHANGE or multiple mixed categories),
|
|
83
|
+
* all configured angles are recommended (fallback-to-all).
|
|
84
|
+
*
|
|
85
|
+
* @param {object} options
|
|
86
|
+
* @param {string[]} options.configuredAngles — all angles configured for this gate
|
|
87
|
+
* @param {string[]} options.changeCategories — from diff analysis
|
|
88
|
+
* @param {boolean} [options.ambiguous] — from diff analysis
|
|
89
|
+
* @returns {DynamicAngleResult}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveDynamicAngles({
|
|
92
|
+
configuredAngles,
|
|
93
|
+
changeCategories,
|
|
94
|
+
ambiguous = false,
|
|
95
|
+
}) {
|
|
96
|
+
// Fallback: ambiguous diff → all angles
|
|
97
|
+
if (ambiguous) {
|
|
98
|
+
return {
|
|
99
|
+
recommendedAngles: [...configuredAngles],
|
|
100
|
+
skippedAngles: [],
|
|
101
|
+
reasons: {},
|
|
102
|
+
fallbackToAll: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No change categories → all angles (defensive)
|
|
107
|
+
if (changeCategories.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
recommendedAngles: [...configuredAngles],
|
|
110
|
+
skippedAngles: [],
|
|
111
|
+
reasons: {},
|
|
112
|
+
fallbackToAll: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Build recommended set from category union
|
|
117
|
+
const recommended = new Set();
|
|
118
|
+
for (const cat of changeCategories) {
|
|
119
|
+
const angles = CATEGORY_ANGLE_MAP[cat] ?? [];
|
|
120
|
+
for (const angle of angles) {
|
|
121
|
+
recommended.add(angle);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Always-include angles
|
|
126
|
+
for (const angle of ALWAYS_INCLUDE) {
|
|
127
|
+
recommended.add(angle);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Filter to only angles that are configured
|
|
131
|
+
const recommendedAngles = configuredAngles.filter((a) => recommended.has(a));
|
|
132
|
+
const skippedAngles = configuredAngles.filter((a) => !recommended.has(a));
|
|
133
|
+
|
|
134
|
+
// Build reasons
|
|
135
|
+
const reasons = {};
|
|
136
|
+
for (const angle of skippedAngles) {
|
|
137
|
+
reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
recommendedAngles,
|
|
142
|
+
skippedAngles,
|
|
143
|
+
reasons,
|
|
144
|
+
fallbackToAll: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff analysis for dynamic gate angle resolution.
|
|
3
|
+
*
|
|
4
|
+
* T0 — file-level: classifies files by extension and directory.
|
|
5
|
+
* T1 — hunk-level: classifies hunks by change type (comments, imports, config, etc.).
|
|
6
|
+
*
|
|
7
|
+
* T2 (AST-level) is deferred to a follow-up.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally pure and side-effect free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// T0: File-level analysis
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} T0Result
|
|
18
|
+
* @property {string[]} files — flat file paths
|
|
19
|
+
* @property {string[]} extensions — unique file extensions (lowercase, with dot)
|
|
20
|
+
* @property {string[]} directories — unique top-level path segments
|
|
21
|
+
* @property {boolean} renameOnly — true when all entries are renames (no adds/deletes/modifies)
|
|
22
|
+
* @property {boolean} allDocs — true when all files are under docs/ or are .md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse `git diff --name-status` output into a T0 analysis.
|
|
27
|
+
*
|
|
28
|
+
* Each line format: `<status>\t<path>` or `<status>\t<old>\t<new>`.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} nameStatusOutput — raw stdout from `git diff --name-status`
|
|
31
|
+
* @returns {T0Result}
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize file path separators to forward slashes.
|
|
36
|
+
* Handles Windows backslash paths from git output on Windows.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} filePath
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function normalizeSep(filePath) {
|
|
42
|
+
return filePath.replaceAll("\\", "/");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function analyzeT0(nameStatusOutput) {
|
|
46
|
+
const lines = nameStatusOutput.trim().split("\n").filter(Boolean);
|
|
47
|
+
const files = [];
|
|
48
|
+
const extensions = new Set();
|
|
49
|
+
const directories = new Set();
|
|
50
|
+
let renameCount = 0;
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const parts = line.split("\t");
|
|
54
|
+
const status = parts[0];
|
|
55
|
+
const rawPath = parts.length >= 3 ? parts[2] : parts[1];
|
|
56
|
+
const path = normalizeSep(rawPath);
|
|
57
|
+
if (!path) continue;
|
|
58
|
+
|
|
59
|
+
files.push(path);
|
|
60
|
+
|
|
61
|
+
const ext = path.includes(".") ? "." + path.split(".").pop().toLowerCase() : "";
|
|
62
|
+
if (ext) extensions.add(ext);
|
|
63
|
+
|
|
64
|
+
const dir = path.split("/")[0];
|
|
65
|
+
if (dir) directories.add(dir);
|
|
66
|
+
|
|
67
|
+
if (status.startsWith("R")) renameCount++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const renameOnly = lines.length > 0 && renameCount === lines.length;
|
|
71
|
+
const allDocs = lines.length > 0 && files.every(
|
|
72
|
+
(f) => normalizeSep(f).startsWith("docs/") || f.endsWith(".md") || f === "README.md",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
files,
|
|
77
|
+
extensions: [...extensions].sort(),
|
|
78
|
+
directories: [...directories].sort(),
|
|
79
|
+
renameOnly,
|
|
80
|
+
allDocs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {"code" | "docs" | "config" | "test" | "ci" | "unknown"} FileCategory
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classify a single file path into a high-level category.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} filePath
|
|
92
|
+
* @returns {FileCategory}
|
|
93
|
+
*/
|
|
94
|
+
export function classifyFile(filePath) {
|
|
95
|
+
const fp = normalizeSep(filePath);
|
|
96
|
+
if (fp.startsWith(".github/")) {
|
|
97
|
+
return "ci";
|
|
98
|
+
}
|
|
99
|
+
if (fp.startsWith("docs/") || fp.endsWith(".md") || fp === "README.md") {
|
|
100
|
+
return "docs";
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
fp.endsWith(".yml") || fp.endsWith(".yaml") ||
|
|
104
|
+
fp.endsWith(".json") || fp === "package.json"
|
|
105
|
+
) {
|
|
106
|
+
return "config";
|
|
107
|
+
}
|
|
108
|
+
if (fp.includes(".test.") || fp.startsWith("test/")) {
|
|
109
|
+
return "test";
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
fp.endsWith(".mjs") || fp.endsWith(".js") ||
|
|
113
|
+
fp.endsWith(".ts") || fp.endsWith(".mts")
|
|
114
|
+
) {
|
|
115
|
+
return "code";
|
|
116
|
+
}
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// T1: Hunk-level analysis
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {object} T1Result
|
|
125
|
+
* @property {string[]} changeCategories — detected change categories
|
|
126
|
+
* @property {number} hunkCount
|
|
127
|
+
* @property {{ added: number, deleted: number }} lineStats
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check whether a diff line content (after stripping the + / - prefix) is
|
|
132
|
+
* a comment or blank line — i.e. not logic.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} content — trimmed line content (without + / - prefix)
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function isNonLogicLine(content) {
|
|
138
|
+
if (content === "") return true;
|
|
139
|
+
if (content.startsWith("//") || content.startsWith("/*") || content.startsWith("*")) return true;
|
|
140
|
+
// import/export/require lines are NOT non-logic — they change dependencies
|
|
141
|
+
// and should not be classified as COMMENT_ONLY
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Analyze unified diff hunks to classify change types.
|
|
147
|
+
*
|
|
148
|
+
* Detects:
|
|
149
|
+
* - COMMENT_ONLY: only comment lines changed
|
|
150
|
+
* - DOCS_ONLY: only .md files changed (from extensions)
|
|
151
|
+
* - CONFIG_ONLY: only config files changed
|
|
152
|
+
* - TEST_ONLY: only test files changed
|
|
153
|
+
* - RENAME_ONLY: all renames, no content changes
|
|
154
|
+
* - LOGIC_CHANGE: any non-trivial code change
|
|
155
|
+
*
|
|
156
|
+
* @param {string} diffOutput — raw unified diff output
|
|
157
|
+
* @param {T0Result} t0 — T0 result for context
|
|
158
|
+
* @returns {T1Result}
|
|
159
|
+
*/
|
|
160
|
+
export function analyzeT1(diffOutput, t0) {
|
|
161
|
+
const lines = diffOutput.split("\n");
|
|
162
|
+
let hunkCount = 0;
|
|
163
|
+
let added = 0;
|
|
164
|
+
let deleted = 0;
|
|
165
|
+
const categories = new Set();
|
|
166
|
+
|
|
167
|
+
let inHunk = false;
|
|
168
|
+
let hasLogicChange = false;
|
|
169
|
+
let hasAnyChangedLine = false;
|
|
170
|
+
let allChangedLinesAreNonLogic = true;
|
|
171
|
+
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
// Track hunk headers
|
|
174
|
+
if (line.startsWith("@@")) {
|
|
175
|
+
hunkCount++;
|
|
176
|
+
inHunk = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!inHunk) continue;
|
|
181
|
+
|
|
182
|
+
// Track line stats and classify
|
|
183
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
184
|
+
added++;
|
|
185
|
+
hasAnyChangedLine = true;
|
|
186
|
+
const content = line.slice(1).trim();
|
|
187
|
+
if (!isNonLogicLine(content)) {
|
|
188
|
+
hasLogicChange = true;
|
|
189
|
+
allChangedLinesAreNonLogic = false;
|
|
190
|
+
}
|
|
191
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
192
|
+
deleted++;
|
|
193
|
+
hasAnyChangedLine = true;
|
|
194
|
+
const content = line.slice(1).trim();
|
|
195
|
+
if (!isNonLogicLine(content)) {
|
|
196
|
+
hasLogicChange = true;
|
|
197
|
+
allChangedLinesAreNonLogic = false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Build categories from T0 + hunk analysis
|
|
203
|
+
if (t0.renameOnly) categories.add("RENAME_ONLY");
|
|
204
|
+
if (t0.allDocs) categories.add("DOCS_ONLY");
|
|
205
|
+
if (t0.files.every((f) => classifyFile(f) === "config")) categories.add("CONFIG_ONLY");
|
|
206
|
+
if (t0.files.every((f) => classifyFile(f) === "test")) categories.add("TEST_ONLY");
|
|
207
|
+
if (t0.files.every((f) => classifyFile(f) === "ci")) categories.add("CI_ONLY");
|
|
208
|
+
if (hasLogicChange) categories.add("LOGIC_CHANGE");
|
|
209
|
+
|
|
210
|
+
// COMMENT_ONLY: hunkCount > 0 (real diff), has changed lines, all are non-logic,
|
|
211
|
+
// and not a rename-only change
|
|
212
|
+
if (hunkCount > 0 && hasAnyChangedLine && allChangedLinesAreNonLogic && !t0.renameOnly) {
|
|
213
|
+
categories.add("COMMENT_ONLY");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
changeCategories: [...categories],
|
|
218
|
+
hunkCount,
|
|
219
|
+
lineStats: { added, deleted },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Combined analysis
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @typedef {object} DiffAnalysis
|
|
229
|
+
* @property {T0Result} t0
|
|
230
|
+
* @property {T1Result | null} t1
|
|
231
|
+
* @property {boolean} ambiguous — true when heuristics cannot confidently classify
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Infer change categories from T0 analysis when T1 is not run.
|
|
236
|
+
*
|
|
237
|
+
* @param {T0Result} t0
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
function inferCategoriesFromT0(t0) {
|
|
241
|
+
const categories = [];
|
|
242
|
+
if (t0.renameOnly) categories.push("RENAME_ONLY");
|
|
243
|
+
if (t0.allDocs) categories.push("DOCS_ONLY");
|
|
244
|
+
if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
|
|
245
|
+
if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
|
|
246
|
+
if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
|
|
247
|
+
return categories;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Run full diff analysis (T0 + T1 if needed).
|
|
252
|
+
*
|
|
253
|
+
* T0 always runs. T1 runs when T0 doesn't produce a clear single-category result.
|
|
254
|
+
* When T1 is not run (unambiguous diff), categories are inferred from T0 so
|
|
255
|
+
* dynamic angle resolution can still narrow the angle list.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ nameStatusOutput: string, diffOutput?: string }} input
|
|
258
|
+
* @returns {DiffAnalysis}
|
|
259
|
+
*/
|
|
260
|
+
export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
261
|
+
const t0 = analyzeT0(nameStatusOutput);
|
|
262
|
+
let t1 = null;
|
|
263
|
+
|
|
264
|
+
// T0 is unambiguous when: renameOnly, allDocs, or single clear category
|
|
265
|
+
const t0Ambiguous = !t0.renameOnly && !t0.allDocs && t0.files.length > 1 &&
|
|
266
|
+
new Set(t0.files.map(classifyFile)).size > 1;
|
|
267
|
+
|
|
268
|
+
if (t0Ambiguous && diffOutput) {
|
|
269
|
+
t1 = analyzeT1(diffOutput, t0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// When t1 is null (unambiguous diff), infer categories from t0
|
|
273
|
+
// so dynamic angle resolution can narrow for config-only / test-only etc.
|
|
274
|
+
if (!t1) {
|
|
275
|
+
t1 = {
|
|
276
|
+
changeCategories: inferCategoriesFromT0(t0),
|
|
277
|
+
hunkCount: 0,
|
|
278
|
+
lineStats: { added: 0, deleted: 0 },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ambiguous = t0Ambiguous && (t1.changeCategories.length === 0 || t1.changeCategories.includes("LOGIC_CHANGE"));
|
|
283
|
+
|
|
284
|
+
return { t0, t1, ambiguous };
|
|
285
|
+
}
|