@apdesign/cursor-roi-tracker 0.5.2

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/src/git.js ADDED
@@ -0,0 +1,170 @@
1
+ const path = require("path");
2
+ const { execFileSync } = require("child_process");
3
+
4
+ function runGit(repoRoot, args) {
5
+ return execFileSync("git", args, {
6
+ cwd: repoRoot,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ }).trimEnd();
10
+ }
11
+
12
+ function getRepoRoot(cwd = process.cwd()) {
13
+ return runGit(cwd, ["rev-parse", "--show-toplevel"]).trim();
14
+ }
15
+
16
+ function getGitDir(repoRoot) {
17
+ const gitDir = runGit(repoRoot, ["rev-parse", "--git-dir"]).trim();
18
+ if (!gitDir) {
19
+ throw new Error("Unable to resolve git dir");
20
+ }
21
+ return path.isAbsolute(gitDir)
22
+ ? path.resolve(gitDir)
23
+ : path.resolve(repoRoot, gitDir);
24
+ }
25
+
26
+ function getBranch(repoRoot) {
27
+ try {
28
+ return runGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
29
+ } catch (_error) {
30
+ return runGit(repoRoot, ["symbolic-ref", "--short", "HEAD"]).trim();
31
+ }
32
+ }
33
+
34
+ function getAuthor(repoRoot) {
35
+ return runGit(repoRoot, ["config", "user.name"]).trim();
36
+ }
37
+
38
+ function getAuthorEmail(repoRoot) {
39
+ return runGit(repoRoot, ["config", "user.email"]).trim();
40
+ }
41
+
42
+ function getHeadSha(repoRoot) {
43
+ return runGit(repoRoot, ["rev-parse", "HEAD"]).trim();
44
+ }
45
+
46
+ function getHeadCommitTime(repoRoot) {
47
+ const seconds = Number(runGit(repoRoot, ["show", "-s", "--format=%ct", "HEAD"]).trim());
48
+ if (!Number.isFinite(seconds)) {
49
+ return null;
50
+ }
51
+ return seconds * 1000;
52
+ }
53
+
54
+ function getRemoteUrl(repoRoot) {
55
+ try {
56
+ const originUrl = runGit(repoRoot, ["remote", "get-url", "origin"]).trim();
57
+ if (originUrl) {
58
+ return originUrl;
59
+ }
60
+ } catch (_error) {
61
+ // Fall through to generic remote parsing.
62
+ }
63
+
64
+ try {
65
+ const remoteList = runGit(repoRoot, ["remote", "-v"]);
66
+ const lines = remoteList
67
+ .split("\n")
68
+ .map((item) => item.trim())
69
+ .filter(Boolean);
70
+ const parsed = lines
71
+ .map((line) => {
72
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/i);
73
+ if (!match) {
74
+ return null;
75
+ }
76
+ return {
77
+ name: match[1],
78
+ url: match[2],
79
+ direction: match[3].toLowerCase(),
80
+ };
81
+ })
82
+ .filter(Boolean);
83
+
84
+ const firstFetch = parsed.find((item) => item.direction === "fetch");
85
+ if (firstFetch?.url) {
86
+ return firstFetch.url;
87
+ }
88
+ return parsed[0]?.url || null;
89
+ } catch (_error) {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function maskRemoteUrl(raw) {
95
+ if (typeof raw !== "string") {
96
+ return null;
97
+ }
98
+ const trimmed = raw.trim();
99
+ if (!trimmed) {
100
+ return null;
101
+ }
102
+
103
+ const match = trimmed.match(/(?:[a-z]+:\/\/|[^@]+@)?[^:/]+(?::\d+)?[:/](.+)$/i);
104
+ if (!match?.[1]) {
105
+ return null;
106
+ }
107
+
108
+ const slug = match[1]
109
+ .toLowerCase()
110
+ .replace(/\.git$/i, "")
111
+ .replace(/\/+/g, "/")
112
+ .replace(/^\/+/, "")
113
+ .trim();
114
+
115
+ return slug || null;
116
+ }
117
+
118
+ function isMergeCommitInProgress(repoRoot) {
119
+ try {
120
+ runGit(repoRoot, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
121
+ return true;
122
+ } catch (_error) {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function listStagedFiles(repoRoot) {
128
+ const raw = runGit(repoRoot, ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
129
+ if (!raw.trim()) {
130
+ return [];
131
+ }
132
+ return raw
133
+ .split("\n")
134
+ .map((item) => item.trim())
135
+ .filter(Boolean);
136
+ }
137
+
138
+ function getStagedUnifiedDiff(repoRoot, files) {
139
+ if (!files.length) {
140
+ return "";
141
+ }
142
+
143
+ const outputParts = [];
144
+ const batchSize = 200;
145
+ for (let i = 0; i < files.length; i += batchSize) {
146
+ const batch = files.slice(i, i + batchSize);
147
+ const args = ["diff", "--cached", "--unified=0", "--no-color", "--", ...batch];
148
+ const part = runGit(repoRoot, args);
149
+ if (part) {
150
+ outputParts.push(part);
151
+ }
152
+ }
153
+ return outputParts.join("\n");
154
+ }
155
+
156
+ module.exports = {
157
+ runGit,
158
+ getRepoRoot,
159
+ getGitDir,
160
+ getBranch,
161
+ getAuthor,
162
+ getAuthorEmail,
163
+ getHeadSha,
164
+ getHeadCommitTime,
165
+ getRemoteUrl,
166
+ maskRemoteUrl,
167
+ isMergeCommitInProgress,
168
+ listStagedFiles,
169
+ getStagedUnifiedDiff,
170
+ };
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ const { runPreCommit } = require("./cli-pre-commit");
2
+ const { runPostCommit } = require("./cli-post-commit");
3
+ const { runInstallHooks } = require("./cli-install-hooks");
4
+
5
+ module.exports = {
6
+ runPreCommit,
7
+ runPostCommit,
8
+ runInstallHooks,
9
+ };
package/src/metrics.js ADDED
@@ -0,0 +1,63 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ function extractTaskSerials(branch) {
5
+ if (!branch || typeof branch !== "string") {
6
+ return [];
7
+ }
8
+ const patterns = [
9
+ /(?:feature|fix|hotfix|bugfix|task|story|issue)[/\-_]?(\d{4,})/gi,
10
+ /[A-Z]{2,10}-\d+/g,
11
+ ];
12
+ const results = new Set();
13
+ for (const pattern of patterns) {
14
+ const matches = branch.match(pattern);
15
+ if (matches) {
16
+ for (const m of matches) {
17
+ results.add(m);
18
+ }
19
+ }
20
+ }
21
+ return [...results];
22
+ }
23
+
24
+ function resolveMemberMatch(repoRoot, config, author) {
25
+ const result = { memberMatched: false, matchedMemberName: null };
26
+ if (!config.membersFile) {
27
+ return result;
28
+ }
29
+ const membersPath = path.join(repoRoot, config.membersFile);
30
+ if (!fs.existsSync(membersPath)) {
31
+ return result;
32
+ }
33
+ try {
34
+ const names = JSON.parse(fs.readFileSync(membersPath, "utf8"));
35
+ if (!Array.isArray(names)) {
36
+ return result;
37
+ }
38
+ const normalizedAuthor = String(author || "").trim().toLowerCase();
39
+ const matched = names.find(
40
+ (name) => String(name || "").trim().toLowerCase() === normalizedAuthor
41
+ );
42
+ if (matched) {
43
+ result.memberMatched = true;
44
+ result.matchedMemberName = String(matched).trim();
45
+ }
46
+ } catch (_error) {
47
+ // ignore
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function ratio(numerator, denominator) {
53
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) {
54
+ return null;
55
+ }
56
+ return Math.round((numerator / denominator) * 1000000) / 1000000;
57
+ }
58
+
59
+ module.exports = {
60
+ extractTaskSerials,
61
+ resolveMemberMatch,
62
+ ratio,
63
+ };
@@ -0,0 +1,112 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+
6
+ const DEFAULT_TIMEOUT_MS = 5000;
7
+ const DEFAULT_STALE_LOCK_MS = 5000;
8
+ const DEFAULT_RETRY_BASE_MS = 50;
9
+ const DEFAULT_RETRY_MAX_MS = 500;
10
+ const LOCK_OWNER_FILE = "owner.json";
11
+
12
+ async function acquireMutexLock(lockDirPath, options = {}) {
13
+ const timeoutMs = positiveNumber(options.timeoutMs, DEFAULT_TIMEOUT_MS);
14
+ const staleLockMs = positiveNumber(options.staleLockMs, DEFAULT_STALE_LOCK_MS);
15
+ const retryBaseMs = positiveNumber(options.retryBaseMs, DEFAULT_RETRY_BASE_MS);
16
+ const retryMaxMs = Math.max(retryBaseMs, positiveNumber(options.retryMaxMs, DEFAULT_RETRY_MAX_MS));
17
+ const ownerInfo = {
18
+ id: crypto.randomUUID(),
19
+ pid: process.pid,
20
+ hostname: os.hostname(),
21
+ startMs: Date.now(),
22
+ };
23
+ const ownerPath = path.join(lockDirPath, LOCK_OWNER_FILE);
24
+ const startedAt = Date.now();
25
+ let attempt = 0;
26
+
27
+ while (Date.now() - startedAt < timeoutMs) {
28
+ try {
29
+ await fs.promises.mkdir(lockDirPath, { recursive: false });
30
+ await fs.promises.writeFile(ownerPath, `${JSON.stringify(ownerInfo)}\n`, "utf8");
31
+ return { lockDirPath, ownerPath, ownerInfo };
32
+ } catch (error) {
33
+ if (error?.code !== "EEXIST") {
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ await recoverStaleLock(lockDirPath, ownerPath, staleLockMs);
39
+
40
+ const delayMs = Math.min(retryMaxMs, retryBaseMs * Math.pow(2, Math.min(4, attempt)));
41
+ await sleep(delayMs);
42
+ attempt += 1;
43
+ }
44
+
45
+ throw new Error(`LOCK_ACQUIRE_TIMEOUT:${lockDirPath}`);
46
+ }
47
+
48
+ async function releaseMutexLock(lockHandle) {
49
+ if (!lockHandle?.lockDirPath || !lockHandle?.ownerPath || !lockHandle?.ownerInfo?.id) {
50
+ return;
51
+ }
52
+ const currentOwner = await readOwner(lockHandle.ownerPath);
53
+ if (!currentOwner || currentOwner.id !== lockHandle.ownerInfo.id) {
54
+ return;
55
+ }
56
+ await fs.promises.rm(lockHandle.lockDirPath, { recursive: true, force: true });
57
+ }
58
+
59
+ function resolveEventLockPath(repoRoot) {
60
+ return path.join(repoRoot, ".cursor", "ai-events.lock");
61
+ }
62
+
63
+ async function recoverStaleLock(lockDirPath, ownerPath, staleLockMs) {
64
+ const owner = await readOwner(ownerPath);
65
+ const startedAtMs = Number(owner?.startMs);
66
+ const now = Date.now();
67
+ if (Number.isFinite(startedAtMs) && now - startedAtMs <= staleLockMs) {
68
+ return false;
69
+ }
70
+
71
+ if (!Number.isFinite(startedAtMs)) {
72
+ const lockStat = await safeStat(lockDirPath);
73
+ if (lockStat && now - lockStat.mtimeMs <= staleLockMs) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ await fs.promises.rm(lockDirPath, { recursive: true, force: true });
79
+ return true;
80
+ }
81
+
82
+ async function readOwner(ownerPath) {
83
+ try {
84
+ const text = await fs.promises.readFile(ownerPath, "utf8");
85
+ return JSON.parse(String(text || "").trim() || "{}");
86
+ } catch (_error) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function safeStat(filePath) {
92
+ try {
93
+ return await fs.promises.stat(filePath);
94
+ } catch (_error) {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function positiveNumber(value, fallback) {
100
+ const number = Number(value);
101
+ return Number.isFinite(number) && number > 0 ? number : fallback;
102
+ }
103
+
104
+ async function sleep(ms) {
105
+ await new Promise((resolve) => setTimeout(resolve, Math.max(1, Number(ms) || 1)));
106
+ }
107
+
108
+ module.exports = {
109
+ acquireMutexLock,
110
+ releaseMutexLock,
111
+ resolveEventLockPath,
112
+ };
package/src/report.js ADDED
@@ -0,0 +1,68 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ ERROR_HISTORY_PATH,
5
+ FINAL_REPORT_PATH,
6
+ HISTORY_PATH,
7
+ PENDING_REPORT_PATH,
8
+ } = require("./constants");
9
+
10
+ function writePendingReport(repoRoot, report) {
11
+ writeJson(repoRoot, PENDING_REPORT_PATH, report);
12
+ }
13
+
14
+ function readPendingReport(repoRoot) {
15
+ const preferredPath = path.join(repoRoot, PENDING_REPORT_PATH);
16
+ const fallbackPath = path.join(
17
+ repoRoot,
18
+ PENDING_REPORT_PATH.replace(/^\.cursor\//, ".cursor-roi/")
19
+ );
20
+ const filePath = fs.existsSync(preferredPath) ? preferredPath : fallbackPath;
21
+ if (!fs.existsSync(filePath)) {
22
+ return null;
23
+ }
24
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
25
+ }
26
+
27
+ function writeFinalReport(repoRoot, report) {
28
+ writeJson(repoRoot, FINAL_REPORT_PATH, report);
29
+ appendJsonLine(repoRoot, HISTORY_PATH, report);
30
+ }
31
+
32
+ function writeErrorStatus(repoRoot, errorStatus, context = {}) {
33
+ appendJsonLine(repoRoot, ERROR_HISTORY_PATH, {
34
+ ...context,
35
+ errorStatus,
36
+ ts: Date.now(),
37
+ });
38
+ }
39
+
40
+ function writeJson(repoRoot, relativePath, payload) {
41
+ const targetPath = resolveWritablePath(repoRoot, relativePath);
42
+ fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
43
+ }
44
+
45
+ function appendJsonLine(repoRoot, relativePath, payload) {
46
+ const targetPath = resolveWritablePath(repoRoot, relativePath);
47
+ fs.appendFileSync(targetPath, `${JSON.stringify(payload)}\n`, "utf8");
48
+ }
49
+
50
+ function resolveWritablePath(repoRoot, relativePath) {
51
+ const preferredPath = path.join(repoRoot, relativePath);
52
+ try {
53
+ fs.mkdirSync(path.dirname(preferredPath), { recursive: true });
54
+ return preferredPath;
55
+ } catch (_error) {
56
+ const fallbackRelative = relativePath.replace(/^\.cursor\//, ".cursor-roi/");
57
+ const fallbackPath = path.join(repoRoot, fallbackRelative);
58
+ fs.mkdirSync(path.dirname(fallbackPath), { recursive: true });
59
+ return fallbackPath;
60
+ }
61
+ }
62
+
63
+ module.exports = {
64
+ writePendingReport,
65
+ readPendingReport,
66
+ writeFinalReport,
67
+ writeErrorStatus,
68
+ };