@polymorphism-tech/morph-spec 8.40.0 → 8.42.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.
Files changed (59) hide show
  1. package/CLAUDE.md +7 -5
  2. package/README.md +3 -3
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -2
  7. package/framework/MORPH.md +9 -8
  8. package/framework/agents.json +14 -8
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/README.md +2 -1
  11. package/framework/hooks/claude-code/core/chain.js +2 -0
  12. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  13. package/framework/hooks/claude-code/core/telemetry-log.js +39 -8
  14. package/framework/hooks/claude-code/core/worktree-create.js +22 -6
  15. package/framework/hooks/shared/comment-rules.js +171 -0
  16. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  17. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  18. package/framework/hooks/shared/git-paths.js +38 -5
  19. package/framework/hooks/shared/source-files.js +34 -0
  20. package/framework/mcp/registry.json +26 -65
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +58 -26
  24. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  25. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  26. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  27. package/framework/skills/core/morph-implement/SKILL.md +46 -25
  28. package/framework/skills/core/morph-media/SKILL.md +2 -2
  29. package/framework/skills/core/morph-plan/SKILL.md +7 -1
  30. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  31. package/framework/skills/core/morph-review/SKILL.md +1 -1
  32. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  33. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  34. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  35. package/framework/standards/PROVENANCE.json +4 -4
  36. package/framework/standards/STANDARDS.json +7 -7
  37. package/framework/standards/architecture/code-comments.md +9 -2
  38. package/framework/standards/backend/dotnet/testing.md +4 -2
  39. package/framework/standards/frontend/design-system/ai-image-generation.md +42 -28
  40. package/framework/standards/frontend/design-system/ai-video-generation.md +24 -9
  41. package/framework/standards/frontend/nextjs/testing.md +4 -2
  42. package/framework/standards/integration/mcp/mcp-tools.md +19 -12
  43. package/package.json +1 -1
  44. package/src/commands/project/doctor.js +9 -1
  45. package/src/commands/project/worktree.js +60 -4
  46. package/src/commands/state/gate-check.js +5 -0
  47. package/src/commands/tasks/dag.js +27 -4
  48. package/src/commands/validation/verify.js +22 -38
  49. package/src/lib/ai/code-audit.js +1 -1
  50. package/src/lib/ai/registry-audit.js +1 -1
  51. package/src/lib/risk-detector.js +13 -0
  52. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  53. package/src/lib/tasks/tasks-json-parser.js +21 -0
  54. package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
  55. package/src/lib/validators/code-budget/index.js +1 -1
  56. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  57. package/src/lib/worktree/env-allocator.js +9 -3
  58. package/src/utils/agents-installer.js +51 -1
  59. package/src/utils/hooks-installer.js +3 -2
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse Hook: comments the edit added in a shape the
5
+ * `architecture-code-comments` standard never allows.
6
+ *
7
+ * Event: PostToolUse | Matcher: Write|Edit
8
+ *
9
+ * `decision: block` makes the agent fix it in the same turn; the edit itself
10
+ * already landed. Only comments absent from `tool_response.originalFile`
11
+ * count, so editing a legacy file never reports its old comments.
12
+ *
13
+ * Fail-open: any error passes silently.
14
+ */
15
+
16
+ import { readFileSync } from 'fs';
17
+ import { isAbsolute, relative, resolve } from 'path';
18
+
19
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
20
+ import { commentFindings, docCommentsForbidden, MAX_BLOCK_LINES } from '../../shared/comment-rules.js';
21
+ import { isGenerated, isProjectSource } from '../../shared/source-files.js';
22
+
23
+ const MAX_LISTED = 10;
24
+
25
+ const RULE_LABEL = {
26
+ process: 'história de processo (UC/ADR/task/gate)',
27
+ banner: 'banner ou #region',
28
+ 'doc-comment': '///·JSDoc em código que não é pacote publicado',
29
+ 'long-block': `bloco com mais de ${MAX_BLOCK_LINES} linhas`,
30
+ };
31
+
32
+ function spliceEdit(original, oldString, newString, replaceAll) {
33
+ if (replaceAll) return original.split(oldString).join(newString);
34
+ const at = original.indexOf(oldString);
35
+ if (at < 0) return null;
36
+ return original.slice(0, at) + newString + original.slice(at + oldString.length);
37
+ }
38
+
39
+ function readOrNull(file) {
40
+ try {
41
+ return readFileSync(file, 'utf8');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function contentsOf(payload, absolute) {
48
+ const input = payload.tool_input ?? {};
49
+ const response = payload.tool_response ?? {};
50
+ const before = response.originalFile ?? '';
51
+ if (payload.tool_name === 'Write') {
52
+ if (response.originalFile === undefined) return null;
53
+ return { before, after: readOrNull(absolute) ?? input.content ?? '' };
54
+ }
55
+ if (typeof response.originalFile !== 'string') return null;
56
+ const after = readOrNull(absolute)
57
+ ?? spliceEdit(response.originalFile, input.old_string ?? '', input.new_string ?? '', input.replace_all === true);
58
+ return after === null ? null : { before, after };
59
+ }
60
+
61
+ // NOT_SOURCE matches `build/`, `out/` anywhere: judged on an absolute path,
62
+ // a project checked out under such a folder would be skipped whole.
63
+ function projectRelative(absolute, projectDir) {
64
+ const rel = relative(projectDir, absolute);
65
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : absolute;
66
+ }
67
+
68
+ function describe(file, findings) {
69
+ const listed = findings.slice(0, MAX_LISTED)
70
+ .map((f) => `- ${file}:${f.line} — ${RULE_LABEL[f.rule]} — ${f.text}`);
71
+ if (findings.length > MAX_LISTED) listed.push(`- (+${findings.length - MAX_LISTED})`);
72
+ return [
73
+ 'Comentários que o padrão do projeto não aceita (architecture-code-comments: o padrão é nenhum comentário):',
74
+ ...listed,
75
+ '',
76
+ 'Remova-os agora. O que o nome, o tipo ou o teste podem dizer vai para eles; o elo com a pós-condição vai no NOME do teste.',
77
+ `Só fica o porquê que o código não mostra (invariante, ordem que importa, quirk externo com link), em até ${MAX_BLOCK_LINES} linhas.`,
78
+ ].join('\n');
79
+ }
80
+
81
+ /**
82
+ * @param {object} payload - PostToolUse stdin payload
83
+ * @returns {Promise<object|null>}
84
+ */
85
+ export async function run(payload) {
86
+ try {
87
+ if (!['Write', 'Edit'].includes(payload?.tool_name)) return null;
88
+ const file = payload.tool_input?.file_path ?? '';
89
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
90
+ const absolute = isAbsolute(file) ? file : resolve(projectDir, file);
91
+ const judged = projectRelative(absolute, projectDir);
92
+ if (!isProjectSource(judged) || isGenerated(judged)) return null;
93
+
94
+ const contents = contentsOf(payload, absolute);
95
+ if (!contents) return null;
96
+
97
+ let docForbidden = false;
98
+ try { docForbidden = docCommentsForbidden(absolute); } catch { /* unreadable project file: stay quiet */ }
99
+
100
+ const findings = commentFindings({ file, ...contents, docCommentsForbidden: docForbidden });
101
+ return findings.length ? blockResult(describe(file, findings)) : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ await runStandalone(run);
@@ -24,9 +24,13 @@
24
24
  * output, where a secret value can appear with no name beside it.
25
25
  *
26
26
  * SINK RESOLUTION: the event is appended to the `.morph/` tree that OWNS the
27
- * tool's target file (`resolveMorphRoot`); tools with no file path sink on the
28
- * nearest morph tree above the payload's `cwd`, so a sub-agent working in a
29
- * worktree logs its Bash calls next to its Reads and Writes.
27
+ * tool's target file — the tree of its `.morph/features/` segment
28
+ * (`resolveMorphRoot`), else the nearest morph tree above the file; tools with
29
+ * no file path sink on the nearest morph tree above the payload's `cwd`, so a
30
+ * sub-agent working in a worktree logs its Bash calls next to its Reads and
31
+ * Writes. The feature is the tree's BINDING (`morph/<f>` branch, then
32
+ * `MORPH_FEATURE` in `.morph/worktree.env`) before its most recent active
33
+ * feature; a bound feature with no `feature.json` in that tree keeps phase null.
30
34
  *
31
35
  * SILENT: never blocks, never injects context — only appends. Fail-open on any
32
36
  * error (exit 0). Only active inside a morph project (stateExists gate).
@@ -40,6 +44,7 @@ import { runStandalone } from '../../shared/hook-response.js';
40
44
  import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
41
45
  import { redactSecrets } from '../../shared/redact-secrets.js';
42
46
  import { exitCodeMasked } from '../../shared/command-tokens.js';
47
+ import { morphBranchFeature } from '../../shared/git-paths.js';
43
48
  import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
44
49
 
45
50
  function normalize(p) {
@@ -115,6 +120,24 @@ function morphRootAbove(dir, fallback) {
115
120
  return fallback;
116
121
  }
117
122
 
123
+ const FEATURE_NAME = /^[A-Za-z0-9._-]+$/;
124
+
125
+ /**
126
+ * The feature the TREE is bound to, before any guess from its state: the
127
+ * branch `morph/<f>` checked out there, then `MORPH_FEATURE` in its
128
+ * `.morph/worktree.env` (a task worktree sits on `morph-task/<n>`). Without
129
+ * it, a sub-agent's worktree reported the root's most recent active feature.
130
+ */
131
+ async function boundFeature(root) {
132
+ const fromBranch = morphBranchFeature(root);
133
+ if (fromBranch) return fromBranch;
134
+ const manifest = join(root, '.morph', 'worktree.env');
135
+ if (!existsSync(manifest)) return null;
136
+ const { loadEnvFile } = await import('../../shared/e2e-check.js');
137
+ const name = loadEnvFile(manifest)?.MORPH_FEATURE;
138
+ return name && FEATURE_NAME.test(name) && name !== '.' && name !== '..' ? name : null;
139
+ }
140
+
118
141
  function featureFromPath(target) {
119
142
  const m = /\/\.morph\/features\/([^/]+)\//.exec(`/${normalize(target)}`);
120
143
  return m ? m[1] : null;
@@ -192,8 +215,9 @@ export async function run(payload) {
192
215
 
193
216
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
194
217
  const targetFilePath = getFilePath(payload);
195
- const cwd = targetFilePath
196
- ? resolveMorphRoot(targetFilePath, projectDir)
218
+ const targetAbs = targetFilePath ? resolveTargetPath(targetFilePath, projectDir) : '';
219
+ const cwd = targetAbs
220
+ ? resolveMorphRoot(targetAbs, null) ?? morphRootAbove(dirname(targetAbs), projectDir)
197
221
  : morphRootAbove(payload.cwd, projectDir);
198
222
 
199
223
  if (!stateExists(cwd)) return null;
@@ -204,8 +228,15 @@ export async function run(payload) {
204
228
  let phase = null;
205
229
  try {
206
230
  const active = getActiveFeature(cwd);
207
- feature = active?.name ?? featureFromPath(targetFilePath);
208
- if (feature) phase = active?.feature?.phase || derivePhaseForFeature(feature, cwd);
231
+ const bound = await boundFeature(cwd);
232
+ if (bound) {
233
+ feature = bound;
234
+ if (bound === active?.name && active.feature?.phase) phase = active.feature.phase;
235
+ else if (existsSync(join(cwd, '.morph', 'features', bound, 'feature.json'))) phase = derivePhaseForFeature(bound, cwd);
236
+ } else {
237
+ feature = active?.name ?? featureFromPath(targetFilePath);
238
+ if (feature) phase = active?.feature?.phase || derivePhaseForFeature(feature, cwd);
239
+ }
209
240
  } catch {
210
241
  // enrichment is best-effort — a missing feature never blocks capture
211
242
  }
@@ -263,7 +294,7 @@ export async function run(payload) {
263
294
  // between the Read and this hook omits the numbers rather than fail.
264
295
  if (hookEvent !== 'PostToolUseFailure') {
265
296
  try {
266
- const abs = resolveTargetPath(event.target, cwd);
297
+ const abs = targetAbs || resolveTargetPath(event.target, projectDir);
267
298
  event.bytes = statSync(abs).size;
268
299
  const read = readSliceBytes(abs, input, event.bytes);
269
300
  if (read !== undefined) event.bytesRead = read;
@@ -22,8 +22,9 @@
22
22
  * `morph-spec worktree provision <n> [--base <ref>]`.
23
23
  * 4. `worktree.baseRef` from the merged settings (`~/.claude/settings.json`
24
24
  * < `<root>/.claude/settings.json` < `<root>/.claude/settings.local.json`):
25
- * `"head"` → the current branch (or sha) of `payload.cwd`; anything else
26
- * → the repo's default branch (the CLI's own default).
25
+ * `"head"` → the current branch (or sha) of `payload.cwd`; unset and
26
+ * `payload.cwd` on `morph/<f>` → that feature branch; `"fresh"` or
27
+ * anything else → the repo's default branch (the CLI's own default).
27
28
  * 5. CLI exit 0 or 2 (already exists) → its `path`. Exit 1/3 → error.
28
29
  * CLI unavailable → in-hook git fallback that still lands the tree under
29
30
  * `<root>/worktrees/<n>` on `morph-task/<n>` (degraded: no junctions, no
@@ -81,10 +82,11 @@ function readJsonSafe(path) {
81
82
 
82
83
  /**
83
84
  * `worktree.baseRef` across the three settings layers, last one wins.
84
- * Fail-open per file. Returns `'fresh'` (the documented default) when unset.
85
+ * Fail-open per file. `null` when unset — distinct from an explicit `'fresh'`,
86
+ * which overrides the feature-branch default (see `resolveBase`).
85
87
  */
86
88
  export function readBaseRefSetting(root, home = homedir()) {
87
- let value = 'fresh';
89
+ let value = null;
88
90
  for (const p of [
89
91
  join(home, '.claude', 'settings.json'),
90
92
  join(root, '.claude', 'settings.json'),
@@ -108,6 +110,21 @@ function headRef(cwd) {
108
110
  }
109
111
  }
110
112
 
113
+ /**
114
+ * The base of a task tree. `"head"` → whatever `cwd` has checked out;
115
+ * `"fresh"` (or any other explicit value) → the default branch; unset → the
116
+ * `morph/<f>` branch `cwd` is on, when it is one. A sub-agent dispatched by
117
+ * the apply needs the feature's work, and a tree born from the default branch
118
+ * made every dispatch open with `git merge --ff-only morph/<f>`.
119
+ * `morph-task/*` does not match: a tree of a tree tips at the default.
120
+ */
121
+ export function resolveBase(setting, cwd) {
122
+ if (setting === 'head') return headRef(cwd);
123
+ if (setting) return null;
124
+ const current = headRef(cwd);
125
+ return current && /^morph\/.+/.test(current) ? current : null;
126
+ }
127
+
111
128
  function isKnownFeature(root, name) {
112
129
  if (existsSync(join(root, '.morph', 'features', name, 'feature.json'))) return true;
113
130
  try {
@@ -158,8 +175,7 @@ export async function run(payload) {
158
175
  if (!root) return { error: `worktree-create: "${start}" is not inside a git repository` };
159
176
 
160
177
  const name = slugify(payload?.name);
161
- const baseSetting = readBaseRefSetting(root);
162
- const base = baseSetting === 'head' ? headRef(start) : null;
178
+ const base = resolveBase(readBaseRefSetting(root), start);
163
179
 
164
180
  // Outside a morph project there is nothing to govern beyond WHERE the tree
165
181
  // lands — plain git, under <root>/worktrees/.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The comment shapes the `architecture-code-comments` standard forbids in
3
+ * every case, judged only on the comments an edit ADDED. Whether a short
4
+ * comment restates the code is not decidable here; that stays with the
5
+ * Gate 3 evaluator and the `code-budget` density.
6
+ *
7
+ * @module comment-rules
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from 'fs';
11
+ import { dirname, join } from 'path';
12
+
13
+ import { citesProcess, commentLines } from './comment-scanner.js';
14
+
15
+ export const MAX_BLOCK_LINES = 3;
16
+
17
+ const ALLOWED = [
18
+ /READS STANDARDS?:/,
19
+ /\bVERIFY:/,
20
+ /eslint-(?:disable|enable)/,
21
+ /@ts-(?:ignore|expect-error|nocheck|check)/,
22
+ /<auto-generated/i,
23
+ /prettier-ignore/,
24
+ /ReSharper\s+(?:disable|restore)/,
25
+ /@(?:vitest|jest)-environment/,
26
+ /^\/\/\/\s*<reference\b/,
27
+ /^#!/,
28
+ /istanbul ignore|c8 ignore|v8 ignore/,
29
+ ];
30
+
31
+ const DECORATION = /[-=*#~_+]{3,}/;
32
+ const REGION = /^\s*#\s*(?:end)?region\b/;
33
+
34
+ function bodyOf(text) {
35
+ return text.replace(/^(?:\/\/+|\/\*+|\*+)/, '').replace(/\*+\/$/, '').trim();
36
+ }
37
+
38
+ function isBanner(text) {
39
+ const body = bodyOf(text);
40
+ if (REGION.test(body)) return true;
41
+ if (!DECORATION.test(body)) return false;
42
+ const words = body.replace(/[-=*#~_+|]/g, '').trim();
43
+ return words === '' || /^[-=*#~_+]{3,}.*[-=*#~_+]{3,}$/.test(body);
44
+ }
45
+
46
+ function isDocComment(text, isCs) {
47
+ return isCs ? /^\/\/\/(?!\/)/.test(text) : /^\/\*\*(?!\*)/.test(text);
48
+ }
49
+
50
+ function nearest(startDir, test) {
51
+ let dir = startDir;
52
+ for (;;) {
53
+ const hit = test(dir);
54
+ if (hit) return hit;
55
+ const parent = dirname(dir);
56
+ if (parent === dir) return null;
57
+ dir = parent;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Doc-comments are allowed only on a package consumed outside the repo. When
63
+ * that cannot be read from the nearest project file, the rule stays quiet.
64
+ *
65
+ * @param {string} file - absolute path of the edited file
66
+ * @returns {boolean}
67
+ */
68
+ export function docCommentsForbidden(file) {
69
+ const dir = dirname(file);
70
+ if (/\.cs$/i.test(file)) {
71
+ const proj = nearest(dir, (d) => {
72
+ try {
73
+ const name = readdirSync(d).find((n) => /\.csproj$/i.test(n));
74
+ return name ? join(d, name) : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ });
79
+ if (!proj) return false;
80
+ const xml = readFileSync(proj, 'utf8');
81
+ return !/<(?:IsPackable|GeneratePackageOnBuild)>\s*true\s*</i.test(xml) && !/<PackageId>/i.test(xml);
82
+ }
83
+ const pkg = nearest(dir, (d) => (existsSync(join(d, 'package.json')) ? join(d, 'package.json') : null));
84
+ if (!pkg) return false;
85
+ return JSON.parse(readFileSync(pkg, 'utf8')).private === true;
86
+ }
87
+
88
+ /** Lines of `after` whose comment is not one `before` already had (moved comments do not count). */
89
+ function addedCommentLines(before, after, file) {
90
+ const remaining = new Map();
91
+ for (const text of commentLines(before, file).values()) {
92
+ remaining.set(text, (remaining.get(text) ?? 0) + 1);
93
+ }
94
+ const added = new Map();
95
+ for (const [line, text] of commentLines(after, file)) {
96
+ const left = remaining.get(text) ?? 0;
97
+ if (left > 0) remaining.set(text, left - 1);
98
+ else added.set(line, text);
99
+ }
100
+ return added;
101
+ }
102
+
103
+ function addedRegionLines(before, after) {
104
+ const old = new Map();
105
+ for (const row of before.split('\n')) {
106
+ if (REGION.test(row)) old.set(row.trim(), (old.get(row.trim()) ?? 0) + 1);
107
+ }
108
+ const out = [];
109
+ after.split('\n').forEach((row, i) => {
110
+ if (!REGION.test(row)) return;
111
+ const left = old.get(row.trim()) ?? 0;
112
+ if (left > 0) old.set(row.trim(), left - 1);
113
+ else out.push({ line: i + 1, text: row.trim() });
114
+ });
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * @param {{ file: string, before: string, after: string, docCommentsForbidden?: boolean }} input
120
+ * @returns {Array<{ line: number, rule: 'process'|'banner'|'doc-comment'|'long-block', text: string }>}
121
+ */
122
+ export function commentFindings({ file, before, after, docCommentsForbidden: docForbidden = false }) {
123
+ const isCs = /\.cs$/i.test(file);
124
+ const all = commentLines(after, file);
125
+ const added = addedCommentLines(before, after, file);
126
+ const findings = [];
127
+ const flagged = new Set();
128
+ const flag = (line, rule, text) => {
129
+ if (flagged.has(line)) return;
130
+ flagged.add(line);
131
+ findings.push({ line, rule, text });
132
+ };
133
+
134
+ for (const [line, text] of added) {
135
+ if (ALLOWED.some((re) => re.test(text))) continue;
136
+ if (citesProcess(text)) flag(line, 'process', text);
137
+ else if (isBanner(text)) flag(line, 'banner', text);
138
+ }
139
+ for (const { line, text } of addedRegionLines(before, after)) flag(line, 'banner', text);
140
+
141
+ if (docForbidden) {
142
+ for (const [line, text] of added) {
143
+ const opensBlock = !isCs || !isDocComment(all.get(line - 1) ?? '', isCs);
144
+ if (isDocComment(text, isCs) && opensBlock) flag(line, 'doc-comment', text);
145
+ }
146
+ }
147
+
148
+ const rows = after.split('\n');
149
+ const lines = [...all.keys()]
150
+ .filter((line) => (rows[line - 1] ?? '').trim() === all.get(line))
151
+ .sort((a, b) => a - b);
152
+ let run = [];
153
+ const closeRun = () => {
154
+ if (run.length && isDocComment(all.get(run[0]), isCs)) {
155
+ run = [];
156
+ return;
157
+ }
158
+ const addedInRun = run.filter((l) => added.has(l) && !ALLOWED.some((re) => re.test(all.get(l))));
159
+ if (addedInRun.length > MAX_BLOCK_LINES) {
160
+ flag(run[0], 'long-block', `${run.length} linhas: ${all.get(run[0])}`);
161
+ }
162
+ run = [];
163
+ };
164
+ for (const line of lines) {
165
+ if (run.length && line !== run[run.length - 1] + 1) closeRun();
166
+ run.push(line);
167
+ }
168
+ closeRun();
169
+
170
+ return findings.sort((a, b) => a.line - b.line);
171
+ }
@@ -5,10 +5,10 @@
5
5
  * comentário de string para o `doctor --ai`, com a sintaxe de TS/JS ligada
6
6
  * fora do C#. Um regex por linha confundiria `https://` com comentário.
7
7
  *
8
- * @module code-budget-comment-scanner
8
+ * @module comment-scanner
9
9
  */
10
10
 
11
- import { stripComments } from '../../ai/comment-stripper.js';
11
+ import { stripComments } from './comment-stripper.js';
12
12
 
13
13
  /** Vocabulário de processo do morph-spec, fora o id de task (abaixo). */
14
14
  const PROCESS_CITATION = [
@@ -11,7 +11,8 @@
11
11
  * canônico `model-registry.v2.json` usa e que o leitor C# do kit aceita de
12
12
  * propósito (`JsonCommentHandling.Skip`, `AllowTrailingCommas`);
13
13
  * - `dotnet-test-introspection.js` — lê o `global.json` por `jsoncToJson`;
14
- * - `validators/code-budget/comment-scanner.js` — acha as linhas de
14
+ * - `comment-scanner.js` (ao lado; lido pelo `code-budget` e pelo hook
15
+ * `comment-guard`) — acha as linhas de
15
16
  * comentário de `.cs`/`.ts`/`.js`, e é o único que liga a opção `js`.
16
17
  *
17
18
  * **Por que não um regex.** O exemplar tem `"http://localhost:11434/v1"` e uma
@@ -58,6 +58,42 @@ function findDotGit(startDir) {
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * A linked worktree's `.git` FILE holds `gitdir: <path>` (absolute or relative
63
+ * to the file) pointing at its private gitdir. `null` when the line is absent.
64
+ *
65
+ * @param {string} dotGitFile
66
+ * @returns {string|null}
67
+ */
68
+ function privateGitDirOf(dotGitFile) {
69
+ const match = readFileSync(dotGitFile, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
70
+ if (!match) return null;
71
+ return isAbsolute(match[1]) ? match[1] : resolve(dirname(dotGitFile), match[1]);
72
+ }
73
+
74
+ const MORPH_BRANCH = /^ref:\s*refs\/heads\/morph\/([A-Za-z0-9._-]+)\s*$/;
75
+
76
+ /**
77
+ * The feature a checkout is bound to by its branch `morph/<feature>`, read from
78
+ * `<root>/.git` exactly (no climbing) — the primary's `.git/HEAD` or, for a
79
+ * linked worktree, `<gitdir>/HEAD`. `null` for any other branch (`morph-task/*`
80
+ * included), a detached HEAD, or no `.git` at `root`.
81
+ *
82
+ * @param {string} root
83
+ * @returns {string|null}
84
+ */
85
+ export function morphBranchFeature(root) {
86
+ try {
87
+ const dotGit = join(root, '.git');
88
+ const gitDir = statSync(dotGit).isDirectory() ? dotGit : privateGitDirOf(dotGit);
89
+ if (!gitDir) return null;
90
+ const name = MORPH_BRANCH.exec(readFileSync(join(gitDir, 'HEAD'), 'utf8').trim())?.[1];
91
+ return name && name !== '.' && name !== '..' ? name : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
61
97
  /**
62
98
  * Is `startDir` inside a LINKED (secondary) worktree?
63
99
  *
@@ -119,11 +155,8 @@ export function resolveGitCommonDir(startDir) {
119
155
  return found.path;
120
156
  }
121
157
 
122
- // `.git` is a FILE: `gitdir: <path>` → this worktree's private gitdir.
123
- const content = readFileSync(found.path, 'utf8');
124
- const match = content.match(/^gitdir:\s*(.+?)\s*$/m);
125
- if (!match) return null;
126
- const privateGitDir = isAbsolute(match[1]) ? match[1] : resolve(dirname(found.path), match[1]);
158
+ const privateGitDir = privateGitDirOf(found.path);
159
+ if (!privateGitDir) return null;
127
160
 
128
161
  // Inside the private gitdir, `commondir` points (relative to itself, or
129
162
  // absolute) at the real common dir.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which files count as the project's hand-written source. One reading for the
3
+ * `reuse` and `code-budget` validators and the `comment-guard` hook, so the
4
+ * three never disagree about the same file.
5
+ *
6
+ * @module source-files
7
+ */
8
+
9
+ const SCANNABLE = /\.(cs|ts|tsx|js|jsx|mjs|cjs)$/i;
10
+
11
+ // `.morph/` is real: an archived feature's `contracts.cs` entered ProspectPRO's
12
+ // graph as a second `RoulettePriority`.
13
+ const NOT_SOURCE = /(^|\/)(?:\.morph|node_modules|dist|build|out|\.next|bin|obj|coverage)\//;
14
+
15
+ const DECLARATION_FILE = /\.d\.ts$/i;
16
+
17
+ const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
18
+
19
+ /**
20
+ * @param {string} sourceFile
21
+ * @returns {boolean} project code in a scanned extension, tests included
22
+ */
23
+ export function isProjectSource(sourceFile) {
24
+ const f = String(sourceFile || '').replace(/\\/g, '/');
25
+ return SCANNABLE.test(f) && !DECLARATION_FILE.test(f) && !NOT_SOURCE.test(f);
26
+ }
27
+
28
+ /**
29
+ * @param {string} sourceFile
30
+ * @returns {boolean} generated C# nobody writes by hand
31
+ */
32
+ export function isGenerated(sourceFile) {
33
+ return GENERATED.test(String(sourceFile || ''));
34
+ }