@polymorphism-tech/morph-spec 8.37.0 → 8.38.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 +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +55 -18
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- 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 +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- 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 +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- package/src/utils/hooks-installer.js +75 -15
|
@@ -9,52 +9,134 @@
|
|
|
9
9
|
* the same split fleet-scanner uses. CLI-side; runs in the target project cwd.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
13
13
|
import { join, basename } from 'path';
|
|
14
14
|
import { parseWorktreeList, resolvePrimaryRoot } from '../../commands/project/worktree.js';
|
|
15
|
+
import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
|
|
16
|
+
import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
|
|
15
17
|
|
|
16
|
-
const
|
|
18
|
+
const LOGS_DIR = join('.morph', 'logs');
|
|
19
|
+
const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
|
|
20
|
+
const EVENTS_LOG = /^events(-.+)?\.jsonl$/;
|
|
21
|
+
// Caches a finish backup may hold; none of them contains a log.
|
|
22
|
+
const BACKUP_SKIP_DIRS = new Set(['node_modules', '.next', 'bin', 'obj', 'TestResults', 'graphify-out', '.git']);
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
25
|
+
* The same line reaches the reader through the live log, its rotated archives
|
|
26
|
+
* and every finish backup that copied it. v2 events carry an `eventId`; a v1
|
|
27
|
+
* event is identical only to a byte-identical copy of itself.
|
|
28
|
+
*/
|
|
29
|
+
function dedupeKey(event) {
|
|
30
|
+
return event.eventId ? `id:${event.eventId}` : `raw:${JSON.stringify(event)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function eventTime(event) {
|
|
34
|
+
const t = Date.parse(event.ts ?? '');
|
|
35
|
+
return Number.isNaN(t) ? 0 : t;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapse copies of the same event and restore time order across sources.
|
|
40
|
+
* The sort is stable, so events sharing a timestamp keep their write order.
|
|
21
41
|
*
|
|
22
|
-
* @param {
|
|
42
|
+
* @param {Object[]} events
|
|
23
43
|
* @returns {Object[]}
|
|
24
44
|
*/
|
|
25
|
-
export function
|
|
45
|
+
export function dedupeEvents(events) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const e of events || []) {
|
|
49
|
+
if (!e || typeof e !== 'object') continue;
|
|
50
|
+
const key = dedupeKey(e);
|
|
51
|
+
if (seen.has(key)) continue;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
out.push(e);
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => eventTime(a) - eventTime(b));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readJsonlFile(path, out) {
|
|
26
59
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const events = [];
|
|
30
|
-
for (const line of readFileSync(p, 'utf-8').split('\n')) {
|
|
60
|
+
if (!existsSync(path)) return;
|
|
61
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
31
62
|
const trimmed = line.trim();
|
|
32
63
|
if (!trimmed) continue;
|
|
33
|
-
try {
|
|
64
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* skip corrupt line */ }
|
|
34
65
|
}
|
|
35
|
-
|
|
66
|
+
} catch { /* unreadable file contributes nothing */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function archiveFiles(sessionsDir) {
|
|
70
|
+
try {
|
|
71
|
+
return readdirSync(sessionsDir)
|
|
72
|
+
.filter((name) => name.startsWith('events-') && EVENTS_LOG.test(name))
|
|
73
|
+
.sort()
|
|
74
|
+
.map((name) => join(sessionsDir, name));
|
|
36
75
|
} catch {
|
|
37
76
|
return [];
|
|
38
77
|
}
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `resolvePrimaryRoot` from commands/project/worktree.js — see
|
|
46
|
-
* src/lib/worktree/fleet-scanner.js and worktree-audit.js for the sibling
|
|
47
|
-
* consumers) instead of re-walking `git worktree list` here.
|
|
81
|
+
* Read the event stream of a project root: the live log plus, by default, the
|
|
82
|
+
* archives SessionStart rotated into `.morph/logs/sessions/` — reading only the
|
|
83
|
+
* live file loses everything older than the last 5 MB.
|
|
48
84
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
* @param {string} [projectPath]
|
|
86
|
+
* @param {{ archives?: boolean }} [options]
|
|
87
|
+
* @returns {Object[]} deduplicated, in time order; [] when there is no log
|
|
88
|
+
*/
|
|
89
|
+
export function loadTelemetry(projectPath, { archives = true } = {}) {
|
|
90
|
+
const root = projectPath || process.cwd();
|
|
91
|
+
const events = [];
|
|
92
|
+
if (archives) for (const file of archiveFiles(join(root, LOGS_DIR, 'sessions'))) readJsonlFile(file, events);
|
|
93
|
+
readJsonlFile(join(root, EVENTS_FILE), events);
|
|
94
|
+
return dedupeEvents(events);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectBackupLogs(dir, depth, out) {
|
|
98
|
+
if (depth < 0) return;
|
|
99
|
+
let entries;
|
|
100
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const full = join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
if (!BACKUP_SKIP_DIRS.has(entry.name)) collectBackupLogs(full, depth - 1, out);
|
|
105
|
+
} else if (EVENTS_LOG.test(entry.name) && /[\\/]\.morph[\\/]logs([\\/]sessions)?$/.test(dir)) {
|
|
106
|
+
out.push(full);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Event logs that `finish` saved under `<git-common-dir>/morph-spec/finish-backup/`
|
|
113
|
+
* before tearing a worktree down — once the worktree is gone, a feature built
|
|
114
|
+
* there leaves its telemetry nowhere else.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} [cwd]
|
|
117
|
+
* @returns {{ path: string, events: Object[] }|null}
|
|
118
|
+
*/
|
|
119
|
+
export function loadFinishBackupTelemetry(cwd = process.cwd()) {
|
|
120
|
+
const common = resolveGitCommonDir(cwd);
|
|
121
|
+
if (!common) return null;
|
|
122
|
+
const base = join(common, 'morph-spec', 'finish-backup');
|
|
123
|
+
const files = [];
|
|
124
|
+
collectBackupLogs(base, 6, files);
|
|
125
|
+
if (!files.length) return null;
|
|
126
|
+
const events = [];
|
|
127
|
+
for (const file of files.sort()) readJsonlFile(file, events);
|
|
128
|
+
return { path: base, events: dedupeEvents(events) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every git worktree that has its OWN `.morph/logs/events.jsonl` — the source
|
|
133
|
+
* of `cost --all-trees` — plus a `finish-backup` entry for worktrees already
|
|
134
|
+
* torn down. Reuses the worktree enumeration of `doctor --worktrees`/`fleet`.
|
|
54
135
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
136
|
+
* The PRIMARY worktree is labeled `'root'`; every other entry by its directory
|
|
137
|
+
* basename (typically the feature slug). A tree that never ran the harness
|
|
138
|
+
* contributes nothing. Outside a git repo this degrades to a single root
|
|
139
|
+
* entry — never throws.
|
|
58
140
|
*
|
|
59
141
|
* @param {string} [cwd]
|
|
60
142
|
* @returns {Array<{ tree: string, path: string, events: Object[] }>}
|
|
@@ -74,6 +156,8 @@ export function loadTelemetryAllTrees(cwd = process.cwd()) {
|
|
|
74
156
|
events: loadTelemetry(t.path),
|
|
75
157
|
});
|
|
76
158
|
}
|
|
159
|
+
const backup = loadFinishBackupTelemetry(root);
|
|
160
|
+
if (backup) out.push({ tree: 'finish-backup', path: backup.path, events: backup.events });
|
|
77
161
|
return out;
|
|
78
162
|
}
|
|
79
163
|
|
|
@@ -247,9 +331,9 @@ function finalizeTask(state) {
|
|
|
247
331
|
* with `open: true` and `durationSeconds: null`, rather than silently
|
|
248
332
|
* dropped.
|
|
249
333
|
*
|
|
250
|
-
* Byte attribution
|
|
251
|
-
*
|
|
252
|
-
*
|
|
334
|
+
* Byte attribution prefers `bytesRead` (the line window a Read put in context)
|
|
335
|
+
* and falls back to `bytes` (the file size — all v1 events have) — keyed off
|
|
336
|
+
* the number alone, not the tool.
|
|
253
337
|
*
|
|
254
338
|
* Dispatch prompt size (Furo 3 — contract with the hooks-side telemetry-log
|
|
255
339
|
* rewrite): a dispatch event's `prompt` field is now TRUNCATED to 2k chars on
|
|
@@ -259,17 +343,13 @@ function finalizeTask(state) {
|
|
|
259
343
|
* `prompt.length`, unchanged from before — every existing byte count stays
|
|
260
344
|
* exact against old logs.
|
|
261
345
|
*
|
|
262
|
-
* SessionUsage
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* other feature-less event would be — no special-casing. `sessionUsage` is
|
|
270
|
-
* `null` when the stream carries none (nothing to show), never a zeroed
|
|
271
|
-
* object, so a caller can tell "observed, all zero" apart from "never
|
|
272
|
-
* observed" the same way `evalScore` does in gate-check's signals.
|
|
346
|
+
* SessionUsage (main session) and AgentUsage (each sub-agent) are real token
|
|
347
|
+
* counts, not the bytes proxy, and never touch toolCounts/bytesByKind/
|
|
348
|
+
* dispatches. Only their `delta` is summed — the top-level numbers are a
|
|
349
|
+
* running total repeated on every sample — so a v1 sample (no `delta`) is
|
|
350
|
+
* counted in `sessionUsageLegacySamples` and never added in. Like any event
|
|
351
|
+
* they obey the `--feature` filter. `sessionUsage`/`agentUsage` are `null`
|
|
352
|
+
* when nothing summable was observed, never a zeroed object.
|
|
273
353
|
*
|
|
274
354
|
* @param {Object[]} events - raw events (from loadTelemetry)
|
|
275
355
|
* @param {{ feature?: string }} [opts] - optional feature filter
|
|
@@ -280,7 +360,9 @@ function finalizeTask(state) {
|
|
|
280
360
|
* bytesByKind: Object<string, number>,
|
|
281
361
|
* bytesTotal: number,
|
|
282
362
|
* dispatches: {count:number, promptChars:number},
|
|
283
|
-
* sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number}|null,
|
|
363
|
+
* sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number, samples:number}|null,
|
|
364
|
+
* sessionUsageLegacySamples: number,
|
|
365
|
+
* agentUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, agents:number, samples:number}|null,
|
|
284
366
|
* tools: Array<{name:string,count:number}>,
|
|
285
367
|
* tasks: Array<Object>,
|
|
286
368
|
* phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
|
|
@@ -288,6 +370,14 @@ function finalizeTask(state) {
|
|
|
288
370
|
*/
|
|
289
371
|
const SESSION_USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
|
|
290
372
|
|
|
373
|
+
/** Bytes a Read put in context: its window when known, else the file size. */
|
|
374
|
+
export function ingestedBytes(e) {
|
|
375
|
+
if (isFailedToolCall(e)) return null;
|
|
376
|
+
if (Number.isFinite(e.bytesRead)) return e.bytesRead;
|
|
377
|
+
if (Number.isFinite(e.bytes)) return e.bytes;
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
291
381
|
export function aggregateCost(events, opts = {}) {
|
|
292
382
|
const scoped = opts.feature
|
|
293
383
|
? (events || []).filter(e => e.feature === opts.feature)
|
|
@@ -300,8 +390,13 @@ export function aggregateCost(events, opts = {}) {
|
|
|
300
390
|
const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
|
|
301
391
|
const activeByFeature = new Map(); // featureKey -> open task state
|
|
302
392
|
const tasks = [];
|
|
303
|
-
const
|
|
304
|
-
|
|
393
|
+
const newUsageBucket = () => ({
|
|
394
|
+
totals: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 },
|
|
395
|
+
ids: new Set(),
|
|
396
|
+
samples: 0,
|
|
397
|
+
legacy: 0,
|
|
398
|
+
});
|
|
399
|
+
const usage = { session: newUsageBucket(), agent: newUsageBucket() };
|
|
305
400
|
|
|
306
401
|
for (const e of scoped) {
|
|
307
402
|
if (!e || typeof e !== 'object') continue;
|
|
@@ -325,10 +420,18 @@ export function aggregateCost(events, opts = {}) {
|
|
|
325
420
|
continue;
|
|
326
421
|
}
|
|
327
422
|
|
|
328
|
-
if (e.event === 'SessionUsage') {
|
|
329
|
-
|
|
423
|
+
if (e.event === 'SessionUsage' || e.event === 'AgentUsage') {
|
|
424
|
+
const bucket = e.event === 'SessionUsage' ? usage.session : usage.agent;
|
|
425
|
+
// Only a delta can be summed: the top-level numbers are the transcript's
|
|
426
|
+
// running total, and v1 samples (no delta) repeat it on every Stop.
|
|
427
|
+
if (!e.delta || typeof e.delta !== 'object') {
|
|
428
|
+
bucket.legacy += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
bucket.samples += 1;
|
|
432
|
+
bucket.ids.add(e.event === 'SessionUsage' ? (e.sessionId ?? 'default') : e.agentId);
|
|
330
433
|
for (const key of SESSION_USAGE_KEYS) {
|
|
331
|
-
if (
|
|
434
|
+
if (Number.isFinite(e.delta[key])) bucket.totals[key] += e.delta[key];
|
|
332
435
|
}
|
|
333
436
|
continue;
|
|
334
437
|
}
|
|
@@ -339,14 +442,15 @@ export function aggregateCost(events, opts = {}) {
|
|
|
339
442
|
active.tools.set(e.tool, (active.tools.get(e.tool) || 0) + 1);
|
|
340
443
|
}
|
|
341
444
|
|
|
342
|
-
|
|
445
|
+
const read = ingestedBytes(e);
|
|
446
|
+
if (read !== null) {
|
|
343
447
|
const kind = e.kind || 'unknown';
|
|
344
|
-
bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) +
|
|
345
|
-
if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) +
|
|
448
|
+
bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) + read);
|
|
449
|
+
if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) + read);
|
|
346
450
|
if (e.phase) {
|
|
347
451
|
if (!phaseBytes.has(e.phase)) phaseBytes.set(e.phase, new Map());
|
|
348
452
|
const m = phaseBytes.get(e.phase);
|
|
349
|
-
m.set(kind, (m.get(kind) || 0) +
|
|
453
|
+
m.set(kind, (m.get(kind) || 0) + read);
|
|
350
454
|
}
|
|
351
455
|
}
|
|
352
456
|
|
|
@@ -384,7 +488,13 @@ export function aggregateCost(events, opts = {}) {
|
|
|
384
488
|
bytesByKind: mapToObj(bytesByKindGlobal),
|
|
385
489
|
bytesTotal: sumValues(bytesByKindGlobal),
|
|
386
490
|
dispatches,
|
|
387
|
-
sessionUsage:
|
|
491
|
+
sessionUsage: usage.session.samples > 0
|
|
492
|
+
? { ...usage.session.totals, sessions: usage.session.ids.size, samples: usage.session.samples }
|
|
493
|
+
: null,
|
|
494
|
+
sessionUsageLegacySamples: usage.session.legacy,
|
|
495
|
+
agentUsage: usage.agent.samples > 0
|
|
496
|
+
? { ...usage.agent.totals, agents: usage.agent.ids.size, samples: usage.agent.samples }
|
|
497
|
+
: null,
|
|
388
498
|
tools: rank(toolCounts),
|
|
389
499
|
tasks,
|
|
390
500
|
phases,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Verify` telemetry event: one line per `morph-spec verify` run with the
|
|
3
|
+
* scope, per-node status and duration, the commit it covered and whether the
|
|
4
|
+
* reuse validator could run. The Bash event of the same command says who
|
|
5
|
+
* called it and how long the whole command took; this one says what it did.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { appendTelemetryEvent, readTelemetryEvents } from '../../../framework/hooks/shared/telemetry-logger.js';
|
|
11
|
+
|
|
12
|
+
const NODE_NAMES = ['build', 'tests', 'validators', 'e2e'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} result - runVerify() output
|
|
16
|
+
* @param {{ sha: string|null, tree: 'root'|'worktree', persisted: boolean, reason: string|null,
|
|
17
|
+
* fingerprint?: string|null }} context
|
|
18
|
+
* @returns {Object}
|
|
19
|
+
*/
|
|
20
|
+
export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null }) {
|
|
21
|
+
const nodes = {};
|
|
22
|
+
for (const name of NODE_NAMES) {
|
|
23
|
+
const node = result.nodes?.[name];
|
|
24
|
+
if (!node) continue;
|
|
25
|
+
nodes[name] = { status: node.status, durationMs: Number.isFinite(node.durationMs) ? node.durationMs : null };
|
|
26
|
+
}
|
|
27
|
+
if (nodes.validators) {
|
|
28
|
+
nodes.validators.ran = [...(result.nodes.validators.ran || [])];
|
|
29
|
+
nodes.validators.skipped = (result.nodes.validators.skipped || [])
|
|
30
|
+
.map(({ validator, reason: why, unavailable }) => ({ validator, reason: why, unavailable: unavailable === true }));
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
event: 'Verify',
|
|
34
|
+
feature: result.feature,
|
|
35
|
+
task: result.task ?? null,
|
|
36
|
+
scope: result.scope,
|
|
37
|
+
status: result.status,
|
|
38
|
+
sha,
|
|
39
|
+
fingerprint,
|
|
40
|
+
tree,
|
|
41
|
+
persisted,
|
|
42
|
+
reason: persisted ? null : (reason ?? null),
|
|
43
|
+
nodes,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The last complete run of the same verify (feature, task, scope) over the
|
|
49
|
+
* same tree, or null. A partial run (`--filter`, `--skip-*`, `--no-persist`)
|
|
50
|
+
* is not the proof a complete one replaces, so it is passed over. Only the
|
|
51
|
+
* live log is read: a repeat worth warning about is recent.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} projectPath
|
|
54
|
+
* @param {{ feature: string, task: string|null, scope: string, fingerprint: string|null }} run
|
|
55
|
+
* @returns {{ ts: string, status: string, sha: string|null }|null}
|
|
56
|
+
*/
|
|
57
|
+
export function findRepeatedVerify(projectPath, { feature, task, scope, fingerprint }) {
|
|
58
|
+
if (!fingerprint) return null;
|
|
59
|
+
const events = readTelemetryEvents(projectPath);
|
|
60
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
61
|
+
const e = events[i];
|
|
62
|
+
if (e.event !== 'Verify' || e.feature !== feature || e.scope !== scope || (e.task ?? null) !== (task ?? null)) continue;
|
|
63
|
+
if (e.persisted === false) continue;
|
|
64
|
+
return e.fingerprint === fingerprint ? { ts: e.ts, status: e.status, sha: e.sha ?? null } : null;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Appends only where a morph project already exists: this command must never
|
|
71
|
+
* be the one that creates `.morph/`.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} projectPath
|
|
74
|
+
* @param {Object} event
|
|
75
|
+
*/
|
|
76
|
+
export function recordVerifyEvent(projectPath, event) {
|
|
77
|
+
if (!existsSync(join(projectPath, '.morph'))) return;
|
|
78
|
+
appendTelemetryEvent(event, projectPath);
|
|
79
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Budget Validator — o que a feature adicionou além do código.
|
|
3
|
+
*
|
|
4
|
+
* Mede, só nas linhas que esta árvore adicionou contra a branch default:
|
|
5
|
+
* (a) linhas de comentário que citam processo (task, UC, ADR, Gate...), que o
|
|
6
|
+
* standard `architecture-code-comments` manda para o commit;
|
|
7
|
+
* (b) linhas de teste ÷ linhas de produção, contra o orçamento por task do
|
|
8
|
+
* `morph-plan` (um teste por pós-condição + um de regressão por bug).
|
|
9
|
+
*
|
|
10
|
+
* Sempre `warning`: é medida de volume, e o excesso pode estar justificado na
|
|
11
|
+
* task. Um aviso por medida, não por linha, porque cada aviso custa 1 ponto no
|
|
12
|
+
* score do validador. Qualquer falha de medição vira skip com razão.
|
|
13
|
+
*
|
|
14
|
+
* @module code-budget-validator
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
|
|
21
|
+
import { resolveBaseRef } from '../../git/branch-classifier.js';
|
|
22
|
+
import { isProjectSource, isTestFile } from '../reuse/symbol-scanner.js';
|
|
23
|
+
import { citesProcess, commentLines } from './comment-scanner.js';
|
|
24
|
+
|
|
25
|
+
/** Razão teste ÷ produção acima da qual se avisa. */
|
|
26
|
+
export const MAX_TEST_RATIO = 1.5;
|
|
27
|
+
|
|
28
|
+
/** Abaixo disto a razão não é julgada: a regressão de um bug de 2 linhas cabe aqui. */
|
|
29
|
+
export const MIN_TEST_LINES = 40;
|
|
30
|
+
|
|
31
|
+
/** Quantos locais citar no aviso de comentário antes de resumir com "(+N)". */
|
|
32
|
+
const MAX_CITED = 4;
|
|
33
|
+
|
|
34
|
+
/** Código gerado não é escrito por ninguém, então não entra em nenhum dos lados. */
|
|
35
|
+
const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
|
|
36
|
+
|
|
37
|
+
const decimal = (n) => n.toFixed(1).replace('.', ',');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Puro, sem I/O.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array<{path: string, content: string, added: number[]|null}>} files -
|
|
43
|
+
* `added` são as linhas adicionadas; `null` é o arquivo inteiro (novo)
|
|
44
|
+
* @returns {{commentLines: number, processLines: Array<{file: string, line: number, text: string}>,
|
|
45
|
+
* testLines: number, productionLines: number}}
|
|
46
|
+
*/
|
|
47
|
+
export function measureCodeBudget(files) {
|
|
48
|
+
const measured = { commentLines: 0, processLines: [], testLines: 0, productionLines: 0 };
|
|
49
|
+
|
|
50
|
+
for (const { path, content, added } of files) {
|
|
51
|
+
if (!isProjectSource(path) || GENERATED.test(path)) continue;
|
|
52
|
+
const addedSet = added ? new Set(added) : null;
|
|
53
|
+
const isAdded = (n) => addedSet === null || addedSet.has(n);
|
|
54
|
+
|
|
55
|
+
const nonBlank = String(content).split('\n')
|
|
56
|
+
.filter((row, i) => isAdded(i + 1) && row.trim()).length;
|
|
57
|
+
if (isTestFile(path)) measured.testLines += nonBlank;
|
|
58
|
+
else measured.productionLines += nonBlank;
|
|
59
|
+
|
|
60
|
+
for (const [line, text] of commentLines(content, path)) {
|
|
61
|
+
if (!isAdded(line)) continue;
|
|
62
|
+
measured.commentLines++;
|
|
63
|
+
if (citesProcess(text)) measured.processLines.push({ file: path, line, text });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return measured;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {ReturnType<typeof measureCodeBudget>} measured
|
|
72
|
+
* @returns {Array<object>} avisos no formato do validation-runner
|
|
73
|
+
*/
|
|
74
|
+
export function budgetIssues(measured) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
const { commentLines: comments, processLines, testLines, productionLines } = measured;
|
|
77
|
+
|
|
78
|
+
if (processLines.length > 0) {
|
|
79
|
+
const shown = processLines.slice(0, MAX_CITED).map((p) => `${p.file}:${p.line}`);
|
|
80
|
+
const more = processLines.length - shown.length;
|
|
81
|
+
issues.push({
|
|
82
|
+
level: 'warning',
|
|
83
|
+
message:
|
|
84
|
+
`${processLines.length} de ${comments} linhas de comentário adicionadas citam processo ` +
|
|
85
|
+
`(UC, ADR, T{n}, FR, Gate, spec.md, mockup): ${shown.join(', ')}${more > 0 ? ` (+${more})` : ''}`,
|
|
86
|
+
file: processLines[0].file,
|
|
87
|
+
line: processLines[0].line,
|
|
88
|
+
solution:
|
|
89
|
+
'A história vai na mensagem de commit e no decisions.md; no código fica só o porquê que ' +
|
|
90
|
+
'ele não mostra (standard architecture-code-comments).',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (testLines > MIN_TEST_LINES && testLines > MAX_TEST_RATIO * productionLines) {
|
|
95
|
+
const ratio = productionLines > 0 ? ` (${decimal(testLines / productionLines)}×)` : '';
|
|
96
|
+
issues.push({
|
|
97
|
+
level: 'warning',
|
|
98
|
+
message:
|
|
99
|
+
`linhas de teste adicionadas passam de ${decimal(MAX_TEST_RATIO)}× as de produção: ` +
|
|
100
|
+
`${testLines} de teste para ${productionLines} de produção${ratio}`,
|
|
101
|
+
solution:
|
|
102
|
+
'Orçamento por task: um teste por pós-condição + um de regressão por bug, cenários do mesmo ' +
|
|
103
|
+
'comportamento numa tabela, nenhum teste que só fixa texto. Excesso justificado fica escrito na task.',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return issues;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validador plugado no `validation-runner` (e por ele no nó `validators` do
|
|
112
|
+
* `morph-spec verify`).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} projectPath
|
|
115
|
+
* @param {string|null} [featureName] - não usado: a medida é da árvore inteira contra a default
|
|
116
|
+
* @param {object} [options]
|
|
117
|
+
* @param {Map<string, number[]|null>} [options.addedLines] - override da coleta (testes)
|
|
118
|
+
* @returns {Promise<{errors: number, warnings: number, issues: object[], _skipReason?: string}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function validateCodeBudget(projectPath, featureName = null, options = {}) {
|
|
121
|
+
const result = { errors: 0, warnings: 0, issues: [] };
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const added = options.addedLines
|
|
125
|
+
?? addedLinesAgainst(resolveBaseRef(detectDefaultBranch(projectPath), projectPath), projectPath);
|
|
126
|
+
if (!added) {
|
|
127
|
+
result._skipReason = 'sem base git para medir o que a feature adicionou';
|
|
128
|
+
result._skipUnavailable = true;
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const files = [];
|
|
133
|
+
// Todo path do diff existe no disco; um que não se lê é medida faltando, e isso se diz.
|
|
134
|
+
const unreadable = [];
|
|
135
|
+
for (const [path, lines] of added) {
|
|
136
|
+
if (!isProjectSource(path)) continue;
|
|
137
|
+
try {
|
|
138
|
+
files.push({ path, content: readFileSync(join(projectPath, path), 'utf8'), added: lines });
|
|
139
|
+
} catch {
|
|
140
|
+
unreadable.push(path);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const named = unreadable.slice(0, MAX_CITED).join(', ') +
|
|
144
|
+
(unreadable.length > MAX_CITED ? ` (+${unreadable.length - MAX_CITED})` : '');
|
|
145
|
+
|
|
146
|
+
const measured = measureCodeBudget(files);
|
|
147
|
+
if (measured.testLines + measured.productionLines === 0) {
|
|
148
|
+
result._skipReason = unreadable.length > 0
|
|
149
|
+
? `nenhum arquivo adicionado pôde ser lido (${named})`
|
|
150
|
+
: 'nenhuma linha de código adicionada nesta árvore';
|
|
151
|
+
if (unreadable.length > 0) result._skipUnavailable = true;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
result.issues = budgetIssues(measured);
|
|
156
|
+
if (unreadable.length > 0) {
|
|
157
|
+
result.issues.push({
|
|
158
|
+
level: 'warning',
|
|
159
|
+
message: `${unreadable.length} arquivo(s) adicionado(s) não puderam ser lidos, a medida é parcial: ${named}`,
|
|
160
|
+
solution: 'Confira se o verify roda na raiz do projeto e se os arquivos têm permissão de leitura.',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
result.warnings = result.issues.length;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
result.issues = [];
|
|
166
|
+
result.warnings = 0;
|
|
167
|
+
result._skipReason = `medição indisponível: ${err.message}`;
|
|
168
|
+
result._skipUnavailable = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linhas de comentário de um arquivo e se elas contam história de processo.
|
|
3
|
+
*
|
|
4
|
+
* O comentário é o que `stripComments` apaga: o mesmo varredor que já separa
|
|
5
|
+
* comentário de string para o `doctor --ai`, com a sintaxe de TS/JS ligada
|
|
6
|
+
* fora do C#. Um regex por linha confundiria `https://` com comentário.
|
|
7
|
+
*
|
|
8
|
+
* @module code-budget-comment-scanner
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { stripComments } from '../../ai/comment-stripper.js';
|
|
12
|
+
|
|
13
|
+
/** Vocabulário de processo do morph-spec, fora o id de task (abaixo). */
|
|
14
|
+
const PROCESS_CITATION = [
|
|
15
|
+
/\bUC[-\s]?\d+/,
|
|
16
|
+
/\bADR[-\s]?\d+/,
|
|
17
|
+
/\bFR[-\s]?\d+/,
|
|
18
|
+
/\bgate\s?[1-3]\b/i,
|
|
19
|
+
/\b(?:spec|mandate)\.md\b/i,
|
|
20
|
+
/\bmockups?\b/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `T{n}` também é nome de parâmetro genérico. Não casa depois de `template`/
|
|
25
|
+
* `typename`/`generic`, nem colado a letra ou dígito (a hora ISO `24T10`).
|
|
26
|
+
*/
|
|
27
|
+
const TASK_ID = /(?<!\b(?:[Tt]emplate|[Tt]ypename|[Gg]eneric)\s+)(?<![\w"])T\d{1,3}\b(?!")/;
|
|
28
|
+
|
|
29
|
+
/** Lista de tipos `<…>` e `{…}` (`Func<T1, T2>`, `cref="Func{T1,T2}"`, XML doc), de dentro para fora. */
|
|
30
|
+
const TYPE_LIST = /<[^<>]*>|\{[^{}]*\}/g;
|
|
31
|
+
|
|
32
|
+
function withoutTypeLists(text) {
|
|
33
|
+
let previous;
|
|
34
|
+
let current = text;
|
|
35
|
+
do {
|
|
36
|
+
previous = current;
|
|
37
|
+
current = current.replace(TYPE_LIST, ' ');
|
|
38
|
+
} while (current !== previous);
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} commentText
|
|
44
|
+
* @returns {boolean} o comentário usa o vocabulário de processo acima
|
|
45
|
+
*/
|
|
46
|
+
export function citesProcess(commentText) {
|
|
47
|
+
return PROCESS_CITATION.some((re) => re.test(commentText))
|
|
48
|
+
|| TASK_ID.test(withoutTypeLists(commentText));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} content - conteúdo do arquivo
|
|
53
|
+
* @param {string} file - path, só para escolher a sintaxe
|
|
54
|
+
* @returns {Map<number, string>} nº da linha (1-based) → texto do comentário nela
|
|
55
|
+
*/
|
|
56
|
+
export function commentLines(content, file) {
|
|
57
|
+
const text = String(content || '');
|
|
58
|
+
const stripped = stripComments(text, { js: !/\.cs$/i.test(file) });
|
|
59
|
+
const out = new Map();
|
|
60
|
+
let start = 0;
|
|
61
|
+
text.split('\n').forEach((row, index) => {
|
|
62
|
+
let first = -1;
|
|
63
|
+
let last = -1;
|
|
64
|
+
for (let k = 0; k < row.length; k++) {
|
|
65
|
+
if (stripped[start + k] === row[k]) continue;
|
|
66
|
+
if (first < 0) first = k;
|
|
67
|
+
last = k;
|
|
68
|
+
}
|
|
69
|
+
// O espaço dentro do comentário não muda ao ser apagado; o trecho vai do
|
|
70
|
+
// primeiro ao último caractere apagado.
|
|
71
|
+
if (first >= 0) out.set(index + 1, row.slice(first, last + 1).trim());
|
|
72
|
+
start += row.length + 1;
|
|
73
|
+
});
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Budget Validators
|
|
3
|
+
*
|
|
4
|
+
* Volume que a feature adicionou além do código: comentário que conta a
|
|
5
|
+
* história do processo e teste acima do orçamento (ver code-budget-validator.js).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from './code-budget-validator.js';
|
|
9
|
+
export * from './comment-scanner.js';
|
|
@@ -114,8 +114,8 @@ export function findDuplicateSymbols(declarations, existing, opts = {}) {
|
|
|
114
114
|
const language = languageOf(decls[0].file);
|
|
115
115
|
|
|
116
116
|
// Onde mais esse nome vive: no grafo (código que já existia) e nos OUTROS
|
|
117
|
-
// arquivos desta mesma leva (código novo que o grafo ainda não viu —
|
|
118
|
-
//
|
|
117
|
+
// arquivos desta mesma leva (código novo que o grafo ainda não viu — ele
|
|
118
|
+
// só é refeito depois do commit, e é exatamente o caso SimpleBuilding).
|
|
119
119
|
const fromGraph = (existing.get(key) ?? []).filter(
|
|
120
120
|
(e) => e.language === language && !declaredFiles.has(e.file),
|
|
121
121
|
);
|
|
@@ -280,6 +280,8 @@ export async function validateReuse(projectPath, featureName = null, options = {
|
|
|
280
280
|
// segue em frente. Um validador que reprovasse aqui transformaria uma
|
|
281
281
|
// dependência opcional em pré-requisito.
|
|
282
282
|
result._skipReason = 'sem grafo de codebase — `morph-spec graph build` habilita esta verificação';
|
|
283
|
+
// "Could not check", unlike "nothing to check": verify reports it aloud.
|
|
284
|
+
result._skipUnavailable = true;
|
|
283
285
|
return result;
|
|
284
286
|
}
|
|
285
287
|
|