@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.
- package/CLAUDE.md +7 -5
- package/README.md +3 -3
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +9 -8
- package/framework/agents.json +14 -8
- package/framework/docs/CLI.md +5 -3
- package/framework/hooks/README.md +2 -1
- package/framework/hooks/claude-code/core/chain.js +2 -0
- package/framework/hooks/claude-code/core/comment-guard.js +107 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +39 -8
- package/framework/hooks/claude-code/core/worktree-create.js +22 -6
- package/framework/hooks/shared/comment-rules.js +171 -0
- package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
- package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
- package/framework/hooks/shared/git-paths.js +38 -5
- package/framework/hooks/shared/source-files.js +34 -0
- package/framework/mcp/registry.json +26 -65
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +58 -26
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +46 -25
- package/framework/skills/core/morph-media/SKILL.md +2 -2
- package/framework/skills/core/morph-plan/SKILL.md +7 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/standards/PROVENANCE.json +4 -4
- package/framework/standards/STANDARDS.json +7 -7
- package/framework/standards/architecture/code-comments.md +9 -2
- package/framework/standards/backend/dotnet/testing.md +4 -2
- package/framework/standards/frontend/design-system/ai-image-generation.md +42 -28
- package/framework/standards/frontend/design-system/ai-video-generation.md +24 -9
- package/framework/standards/frontend/nextjs/testing.md +4 -2
- package/framework/standards/integration/mcp/mcp-tools.md +19 -12
- package/package.json +1 -1
- package/src/commands/project/doctor.js +9 -1
- package/src/commands/project/worktree.js +60 -4
- package/src/commands/state/gate-check.js +5 -0
- package/src/commands/tasks/dag.js +27 -4
- package/src/commands/validation/verify.js +22 -38
- package/src/lib/ai/code-audit.js +1 -1
- package/src/lib/ai/registry-audit.js +1 -1
- package/src/lib/risk-detector.js +13 -0
- package/src/lib/tasks/dotnet-test-introspection.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +21 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
- package/src/lib/validators/code-budget/index.js +1 -1
- package/src/lib/validators/reuse/symbol-scanner.js +1 -28
- package/src/lib/worktree/env-allocator.js +9 -3
- package/src/utils/agents-installer.js +51 -1
- 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
|
|
28
|
-
* nearest morph tree above the
|
|
29
|
-
*
|
|
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
|
|
196
|
-
|
|
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
|
-
|
|
208
|
-
if (
|
|
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,
|
|
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`;
|
|
26
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
8
|
+
* @module comment-scanner
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { stripComments } from '
|
|
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
|
-
* - `
|
|
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
|
-
|
|
123
|
-
|
|
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
|
+
}
|