@massu/core 2.5.1 → 2.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.
@@ -697,6 +697,13 @@ var init_config = __esm({
697
697
  });
698
698
  LanguageFrameworkEntrySchema = z2.object({
699
699
  framework: z2.string().optional(),
700
+ // `source_dirs` is emitted by `init` (commands/init.ts:417) and existence-checked
701
+ // by config validation (init.ts:868-876), but until 2026-08-14 it survived only via
702
+ // `.passthrough()` — declared in the file, untyped in the schema. `lib/source-layout.ts`
703
+ // derives the index builders' candidate set from it, so it is typed here rather than
704
+ // read as an unknown: a consumer that has to shape-check its own SoT is one branch away
705
+ // from silently contributing `undefined` to a path predicate.
706
+ source_dirs: z2.array(z2.string()).optional(),
700
707
  test_framework: z2.string().optional(),
701
708
  test: z2.string().optional(),
702
709
  runtime: z2.string().optional(),
@@ -3988,7 +3995,7 @@ var init_hook_failure_signal = __esm({
3988
3995
  // src/hooks/auto-learning-pipeline.ts
3989
3996
  init_config();
3990
3997
  import { execFileSync } from "child_process";
3991
- import { existsSync as existsSync9, readFileSync as readFileSync6, unlinkSync as unlinkSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3998
+ import { existsSync as existsSync10, readFileSync as readFileSync7, unlinkSync as unlinkSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3992
3999
  import { tmpdir as tmpdir2 } from "os";
3993
4000
  import { join as join6 } from "path";
3994
4001
 
@@ -4029,17 +4036,215 @@ function writeHookContext(hookEventName, message) {
4029
4036
 
4030
4037
  // src/hooks/auto-learning-pipeline.ts
4031
4038
  init_hook_failure_signal();
4039
+
4040
+ // src/hooks/lib/is-direct-invocation.ts
4041
+ import { pathToFileURL } from "url";
4042
+ function isDirectInvocation(moduleUrl) {
4043
+ const entry = process.argv[1];
4044
+ if (!entry) return false;
4045
+ try {
4046
+ return moduleUrl === pathToFileURL(entry).href;
4047
+ } catch {
4048
+ return false;
4049
+ }
4050
+ }
4051
+
4052
+ // src/hooks/lib/session-touched-files.ts
4053
+ import { readFileSync as readFileSync6, existsSync as existsSync9, realpathSync } from "fs";
4054
+ import { relative, isAbsolute, resolve as resolve5 } from "path";
4055
+ var FILE_WRITING_TOOLS = Object.freeze({
4056
+ Edit: ["file_path"],
4057
+ Write: ["file_path"],
4058
+ MultiEdit: ["file_path"],
4059
+ NotebookEdit: ["notebook_path", "file_path"]
4060
+ });
4061
+ function canonical(p) {
4062
+ try {
4063
+ return realpathSync(p);
4064
+ } catch {
4065
+ return p;
4066
+ }
4067
+ }
4068
+ function toRepoRelative(root, filePath) {
4069
+ if (typeof filePath !== "string" || filePath === "") return null;
4070
+ const absRaw = isAbsolute(filePath) ? filePath : resolve5(root, filePath);
4071
+ const canonicalRoot = canonical(root);
4072
+ const abs = resolve5(canonical(resolve5(absRaw, "..")), absRaw.split("/").pop() ?? "");
4073
+ const rel = relative(canonicalRoot, abs).split("\\").join("/");
4074
+ if (rel === "" || rel.startsWith("../")) return null;
4075
+ return rel;
4076
+ }
4077
+ function transcriptTouchedFiles(root, transcriptPath) {
4078
+ let raw;
4079
+ try {
4080
+ raw = readFileSync6(transcriptPath, "utf-8");
4081
+ } catch {
4082
+ return null;
4083
+ }
4084
+ const out = /* @__PURE__ */ new Set();
4085
+ for (const line of raw.split("\n")) {
4086
+ if (!line.trim()) continue;
4087
+ let entry;
4088
+ try {
4089
+ entry = JSON.parse(line);
4090
+ } catch {
4091
+ continue;
4092
+ }
4093
+ const message = entry.message;
4094
+ const content = message?.content;
4095
+ if (!Array.isArray(content)) continue;
4096
+ for (const block of content) {
4097
+ if (!block || typeof block !== "object") continue;
4098
+ const b = block;
4099
+ if (b.type !== "tool_use") continue;
4100
+ const keys = b.name ? FILE_WRITING_TOOLS[b.name] : void 0;
4101
+ if (!keys || !b.input) continue;
4102
+ for (const key of keys) {
4103
+ const rel = toRepoRelative(root, b.input[key]);
4104
+ if (rel) out.add(rel);
4105
+ }
4106
+ }
4107
+ }
4108
+ return out;
4109
+ }
4110
+ function flagFileTouchedFiles(root, flagPath) {
4111
+ if (!existsSync9(flagPath)) return null;
4112
+ let raw;
4113
+ try {
4114
+ raw = readFileSync6(flagPath, "utf-8");
4115
+ } catch {
4116
+ return null;
4117
+ }
4118
+ const out = /* @__PURE__ */ new Set();
4119
+ for (const line of raw.split("\n")) {
4120
+ if (!line.trim()) continue;
4121
+ try {
4122
+ const rel = toRepoRelative(root, JSON.parse(line).file ?? "");
4123
+ if (rel) out.add(rel);
4124
+ } catch {
4125
+ continue;
4126
+ }
4127
+ }
4128
+ return out;
4129
+ }
4130
+ function sessionTouchedFiles(opts) {
4131
+ const sources = [];
4132
+ if (opts.transcriptPath) sources.push(transcriptTouchedFiles(opts.root, opts.transcriptPath));
4133
+ if (opts.flagPath) sources.push(flagFileTouchedFiles(opts.root, opts.flagPath));
4134
+ const readable = sources.filter((s) => s !== null);
4135
+ if (readable.length === 0) return null;
4136
+ const union = /* @__PURE__ */ new Set();
4137
+ for (const s of readable) for (const f of s) union.add(f);
4138
+ return union;
4139
+ }
4140
+
4141
+ // src/hooks/lib/git-safe-env.ts
4142
+ var GIT_ENV_LEAKS = [
4143
+ "GIT_DIR",
4144
+ "GIT_WORK_TREE",
4145
+ "GIT_INDEX_FILE",
4146
+ "GIT_OBJECT_DIRECTORY",
4147
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
4148
+ "GIT_COMMON_DIR",
4149
+ "GIT_PREFIX"
4150
+ ];
4151
+ function gitSafeEnv(extra = {}, source = process.env) {
4152
+ const e = { ...source, ...extra };
4153
+ for (const k of GIT_ENV_LEAKS) delete e[k];
4154
+ return e;
4155
+ }
4156
+
4157
+ // src/hooks/auto-learning-pipeline.ts
4032
4158
  var HOOK_EVENT = "Stop";
4033
4159
  var MAX_FULL_DIFF_BYTES = 2 * 1024 * 1024;
4160
+ var DOC_EXCLUDE_PATHSPEC = [
4161
+ ":(exclude)*.md",
4162
+ ":(exclude)*.markdown",
4163
+ ":(exclude)*.mdx",
4164
+ ":(exclude)*.rst",
4165
+ ":(exclude)*.adoc",
4166
+ ":(exclude)*.txt",
4167
+ ":(exclude)docs/**",
4168
+ // massu's OWN runtime artifacts: agent-result JSONL, workflow logs, rule candidates.
4169
+ // Audit findings quote source lines verbatim, so leaving these in scope re-creates the
4170
+ // prose-as-code false positive with the hook's own output as the input.
4171
+ ":(exclude).massu/**"
4172
+ ];
4173
+ var CODE_ONLY_PATHSPEC = ["--", ".", ...DOC_EXCLUDE_PATHSPEC];
4174
+ var MAX_SESSION_PATHSPEC_FILES = 500;
4175
+ var FIX_PATTERN_RE = /^\+.*(?:\b(?:try|except|catch|guard|throw|raise|assert|validate)\b|\bif\b.*\b(?:null|nil|None|undefined)\b)/gm;
4176
+ var REMOVED_BROKEN_RE = /^-.*\b(?:bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)\b/gm;
4034
4177
  function getSessionFlagPath(sessionId) {
4035
4178
  return join6(tmpdir2(), "massu-auto-learning", `fixes-${sessionId.slice(0, 12)}.jsonl`);
4036
4179
  }
4180
+ var RECENT_INCIDENT_WINDOW_MS = 12 * 60 * 60 * 1e3;
4181
+ function recentIncidentFiles(dir, nowMs = Date.now()) {
4182
+ try {
4183
+ return readdirSync2(dir).filter((f) => f.endsWith(".md")).filter((f) => {
4184
+ try {
4185
+ return nowMs - statSync2(join6(dir, f)).mtimeMs <= RECENT_INCIDENT_WINDOW_MS;
4186
+ } catch {
4187
+ return false;
4188
+ }
4189
+ }).sort();
4190
+ } catch {
4191
+ return [];
4192
+ }
4193
+ }
4194
+ function scanUncommittedForFix(opts) {
4195
+ const git = opts.git ?? ((args, t, m) => execFileSync("git", [...args], {
4196
+ cwd: opts.root,
4197
+ // `cwd` does NOT scope git: GIT_DIR in the environment outranks it, and a leaked one
4198
+ // would make this read a different repository while looking correct. This hook runs
4199
+ // at Stop and is never reachable from a git hook, so nothing here depends on the
4200
+ // index a commit-time hook is handed — the variables are removed rather than asserted.
4201
+ env: gitSafeEnv(),
4202
+ timeout: t,
4203
+ encoding: "utf-8",
4204
+ maxBuffer: m
4205
+ }));
4206
+ if (opts.sessionTouched === null) {
4207
+ return { uncommittedFix: false, reason: "unattributable", filesScanned: 0 };
4208
+ }
4209
+ if (opts.sessionTouched.size === 0) {
4210
+ return { uncommittedFix: false, reason: "not-this-session", filesScanned: 0 };
4211
+ }
4212
+ try {
4213
+ const nameOnly = git(["diff", "--name-only", ...CODE_ONLY_PATHSPEC], 3e3, 1024 * 1024);
4214
+ const changed = nameOnly.split("\n").map((s) => s.trim()).filter(Boolean);
4215
+ if (changed.length === 0) {
4216
+ return { uncommittedFix: false, reason: "no-changes", filesScanned: 0 };
4217
+ }
4218
+ const mine = changed.filter((f) => opts.sessionTouched.has(f));
4219
+ if (mine.length === 0) {
4220
+ return { uncommittedFix: false, reason: "not-this-session", filesScanned: 0 };
4221
+ }
4222
+ const scoped = mine.length <= MAX_SESSION_PATHSPEC_FILES ? ["--", ...mine, ...DOC_EXCLUDE_PATHSPEC] : [...CODE_ONLY_PATHSPEC];
4223
+ const shortstat = git(["diff", "--shortstat", ...scoped], 2e3, 64 * 1024);
4224
+ const insertions = parseInt(shortstat.match(/(\d+) insertion/)?.[1] ?? "0", 10);
4225
+ const deletions = parseInt(shortstat.match(/(\d+) deletion/)?.[1] ?? "0", 10);
4226
+ if ((insertions + deletions) * 80 > MAX_FULL_DIFF_BYTES) {
4227
+ return { uncommittedFix: false, reason: "over-cap", filesScanned: mine.length };
4228
+ }
4229
+ const fullDiff = git(["diff", ...scoped], 5e3, MAX_FULL_DIFF_BYTES);
4230
+ const fixPatterns = (fullDiff.match(FIX_PATTERN_RE) || []).length;
4231
+ const removedBroken = (fullDiff.match(REMOVED_BROKEN_RE) || []).length;
4232
+ const hit = fixPatterns > 3 || removedBroken > 1;
4233
+ return {
4234
+ uncommittedFix: hit,
4235
+ reason: hit ? "fix-detected" : "no-signal",
4236
+ filesScanned: mine.length
4237
+ };
4238
+ } catch {
4239
+ return { uncommittedFix: false, reason: "git-unavailable", filesScanned: 0 };
4240
+ }
4241
+ }
4037
4242
  async function main() {
4038
4243
  try {
4039
4244
  const input = await readStdin();
4040
4245
  const hookInput = JSON.parse(input);
4041
4246
  const config = getConfig();
4042
- if (config.autoLearning?.enabled === false) {
4247
+ if (config.autoLearning?.enabled === false || config.autoLearning?.fixDetection?.enabled === false) {
4043
4248
  process.exit(0);
4044
4249
  return;
4045
4250
  }
@@ -4049,46 +4254,20 @@ async function main() {
4049
4254
  const autoLearn = config.autoLearning;
4050
4255
  const flagPath = getSessionFlagPath(hookInput.session_id);
4051
4256
  let sessionFixes = [];
4052
- if (existsSync9(flagPath)) {
4257
+ if (existsSync10(flagPath)) {
4053
4258
  try {
4054
- sessionFixes = readFileSync6(flagPath, "utf-8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
4259
+ sessionFixes = readFileSync7(flagPath, "utf-8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
4055
4260
  } catch {
4056
4261
  }
4057
4262
  }
4058
- let uncommittedFix = false;
4059
- try {
4060
- const nameOnly = execFileSync("git", ["diff", "--name-only"], {
4061
- cwd: root,
4062
- timeout: 3e3,
4063
- encoding: "utf-8",
4064
- maxBuffer: 1024 * 1024
4065
- });
4066
- if (nameOnly.trim()) {
4067
- const shortstat = execFileSync("git", ["diff", "--shortstat"], {
4068
- cwd: root,
4069
- timeout: 2e3,
4070
- encoding: "utf-8",
4071
- maxBuffer: 64 * 1024
4072
- });
4073
- const insertions = parseInt(shortstat.match(/(\d+) insertion/)?.[1] ?? "0", 10);
4074
- const deletions = parseInt(shortstat.match(/(\d+) deletion/)?.[1] ?? "0", 10);
4075
- const estimatedBytes = (insertions + deletions) * 80;
4076
- if (estimatedBytes <= MAX_FULL_DIFF_BYTES) {
4077
- const fullDiff = execFileSync("git", ["diff"], {
4078
- cwd: root,
4079
- timeout: 5e3,
4080
- encoding: "utf-8",
4081
- maxBuffer: MAX_FULL_DIFF_BYTES
4082
- });
4083
- const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|throw|raise|assert|validate|if.*null|if.*nil|if.*None|if.*undefined)/gm) || []).length;
4084
- const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)/gm) || []).length;
4085
- if (fixPatterns > 3 || removedBroken > 1) {
4086
- uncommittedFix = true;
4087
- }
4088
- }
4089
- }
4090
- } catch {
4091
- }
4263
+ const uncommittedFix = scanUncommittedForFix({
4264
+ root,
4265
+ sessionTouched: sessionTouchedFiles({
4266
+ root,
4267
+ transcriptPath: hookInput.transcript_path,
4268
+ flagPath
4269
+ })
4270
+ }).uncommittedFix;
4092
4271
  if (sessionFixes.length === 0 && !uncommittedFix) {
4093
4272
  cleanup(flagPath);
4094
4273
  process.exit(0);
@@ -4117,10 +4296,18 @@ async function main() {
4117
4296
  lines.push("");
4118
4297
  lines.push(" Additional uncommitted fix patterns detected in git diff.");
4119
4298
  }
4299
+ const todaysIncidents = recentIncidentFiles(join6(root, incidentDir));
4120
4300
  lines.push("");
4121
- lines.push(" Complete these steps before this session ends:");
4301
+ if (todaysIncidents.length > 0) {
4302
+ lines.push(" ALREADY SATISFIED TODAY (verify these cover the fixes above):");
4303
+ for (const f of todaysIncidents.slice(0, 5)) lines.push(` - ${incidentDir}/${f}`);
4304
+ lines.push("");
4305
+ lines.push(" If they do, nothing further is required. Otherwise:");
4306
+ } else {
4307
+ lines.push(" Complete these steps before this session ends:");
4308
+ }
4122
4309
  lines.push("");
4123
- if (autoLearn?.pipeline?.requireIncidentReport !== false) {
4310
+ if (autoLearn?.pipeline?.requireIncidentReport !== false && todaysIncidents.length === 0) {
4124
4311
  lines.push(" STEP 1: INCIDENT REPORT");
4125
4312
  lines.push(` For each distinct bug fixed, create: ${incidentDir}/YYYY-MM-DD-<slug>.md`);
4126
4313
  lines.push(" Include: Date, Severity, Symptoms, Root Cause, Fix, Files Changed, Prevention Rules");
@@ -4157,9 +4344,9 @@ async function main() {
4157
4344
  }
4158
4345
  function cleanup(flagPath) {
4159
4346
  try {
4160
- if (existsSync9(flagPath)) unlinkSync2(flagPath);
4347
+ if (existsSync10(flagPath)) unlinkSync2(flagPath);
4161
4348
  const dir = join6(tmpdir2(), "massu-auto-learning");
4162
- if (existsSync9(dir)) {
4349
+ if (existsSync10(dir)) {
4163
4350
  const now = Date.now();
4164
4351
  for (const file of readdirSync2(dir)) {
4165
4352
  const fullPath = join6(dir, file);
@@ -4176,17 +4363,27 @@ function cleanup(flagPath) {
4176
4363
  }
4177
4364
  }
4178
4365
  function readStdin() {
4179
- return new Promise((resolve5) => {
4366
+ return new Promise((resolve6) => {
4180
4367
  let data = "";
4181
4368
  process.stdin.setEncoding("utf-8");
4182
4369
  process.stdin.on("data", (chunk) => {
4183
4370
  data += chunk;
4184
4371
  });
4185
- process.stdin.on("end", () => resolve5(data));
4186
- setTimeout(() => resolve5(data), 5e3);
4372
+ process.stdin.on("end", () => resolve6(data));
4373
+ setTimeout(() => resolve6(data), 5e3);
4187
4374
  });
4188
4375
  }
4189
- main();
4376
+ if (isDirectInvocation(import.meta.url)) {
4377
+ main();
4378
+ }
4190
4379
  export {
4191
- MAX_FULL_DIFF_BYTES
4380
+ CODE_ONLY_PATHSPEC,
4381
+ DOC_EXCLUDE_PATHSPEC,
4382
+ FIX_PATTERN_RE,
4383
+ MAX_FULL_DIFF_BYTES,
4384
+ MAX_SESSION_PATHSPEC_FILES,
4385
+ RECENT_INCIDENT_WINDOW_MS,
4386
+ REMOVED_BROKEN_RE,
4387
+ recentIncidentFiles,
4388
+ scanUncommittedForFix
4192
4389
  };