mandrel 2.64.0 → 2.66.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/.agents/agents/acceptance-critic.md +8 -7
- package/.agents/agents/auditor.md +20 -20
- package/.agents/agents/plan-critic.md +8 -7
- package/.agents/agents/story-worker.md +7 -7
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +1 -9
- package/.agents/docs/configuration.md +8 -7
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/instructions.md +10 -12
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +12 -3
- package/.agents/rules/git-conventions.md +9 -7
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -13
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
- package/.agents/scripts/bootstrap.js +102 -91
- package/.agents/scripts/check-context-budget.js +1 -1
- package/.agents/scripts/lib/ITicketingProvider.js +1 -3
- package/.agents/scripts/lib/audit-suite/findings.js +1 -17
- package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
- package/.agents/scripts/lib/audit-suite/index.js +0 -6
- package/.agents/scripts/lib/audit-suite/selector.js +0 -31
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
- package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
- package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config/review-chain-default.js +13 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
- package/.agents/scripts/lib/doc-tiers.js +25 -6
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/orchestration/code-review.js +33 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
- package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
- package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/providers/github/issues.js +14 -23
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/sync-claude-agents.js +1 -1
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- package/.agents/workflows/audit-quality.md +42 -7
- package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/code-review.md +15 -38
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +7 -3
- package/.agents/workflows/helpers/deliver-story.md +9 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/helpers/plan-reference.md +9 -8
- package/.agents/workflows/mandrel-deliver.md +3 -2
- package/.agents/workflows/mandrel-plan.md +11 -7
- package/.agents/workflows/mandrel-update.md +5 -3
- package/docs/CHANGELOG.md +57 -0
- package/lib/cli/claude-code-version.js +73 -0
- package/lib/cli/doctor.js +2 -2
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/registry.js +9 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- package/lib/cli/uninstall.js +37 -9
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
- package/package.json +3 -2
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
- package/.agents/scripts/lib/audit-suite/runner.js +0 -205
- package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
- package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Close-scope lens diff-floor: a diff under the changed-line floor that
|
|
3
|
-
* touches no sensitive path skips lens materialization. Fail-open — every
|
|
4
|
-
* degraded input (unknown count, unreadable manifest, floor `0`) means "do
|
|
5
|
-
* not skip". Sensitive classes come from the same selector as review depth,
|
|
6
|
-
* so the two never disagree. Total; no I/O beyond the injected git spawn.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { gitSpawn } from '../git-utils.js';
|
|
10
|
-
import { readNumstatRows } from '../orchestration/diff-magnitude.js';
|
|
11
|
-
import { selectSensitivePathClasses } from './selector.js';
|
|
12
|
-
|
|
13
|
-
/** Measured: zero-yield closes clustered well under this size. */
|
|
14
|
-
export const DEFAULT_LENS_DIFF_FLOOR = 40;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Whole-diff additions + deletions (no companion exemption: the question is
|
|
18
|
-
* "is this diff small"). Shares the numstat parse with the light path's
|
|
19
|
-
* backstop. `null` on any git failure; binary rows count 0.
|
|
20
|
-
*
|
|
21
|
-
* @param {{
|
|
22
|
-
* baseRef: string,
|
|
23
|
-
* headRef: string,
|
|
24
|
-
* cwd?: string,
|
|
25
|
-
* gitSpawnFn?: typeof gitSpawn,
|
|
26
|
-
* }} args
|
|
27
|
-
* @returns {number|null}
|
|
28
|
-
*/
|
|
29
|
-
export function countChangedLines({
|
|
30
|
-
baseRef,
|
|
31
|
-
headRef,
|
|
32
|
-
cwd = process.cwd(),
|
|
33
|
-
gitSpawnFn = gitSpawn,
|
|
34
|
-
} = {}) {
|
|
35
|
-
const rows = readNumstatRows({ baseRef, headRef, cwd, gitSpawnFn });
|
|
36
|
-
if (rows === null) return null;
|
|
37
|
-
return rows.reduce((total, row) => total + row.additions + row.deletions, 0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Skip only when the floor is enabled, the count is known and below it, and
|
|
42
|
-
* no sensitive-path class matches; every other state names its reason.
|
|
43
|
-
*
|
|
44
|
-
* @param {{
|
|
45
|
-
* changedFiles?: string[]|null,
|
|
46
|
-
* changedLineCount?: number|null,
|
|
47
|
-
* floor?: number,
|
|
48
|
-
* injectedRules?: object,
|
|
49
|
-
* selectSensitivePathClassesFn?: typeof selectSensitivePathClasses,
|
|
50
|
-
* }} [input]
|
|
51
|
-
* @returns {{
|
|
52
|
-
* skip: boolean,
|
|
53
|
-
* reason: 'floor-disabled'|'line-count-unknown'|'at-or-above-floor'|'sensitive-classes-unknown'|'sensitive-path-hit'|'below-floor',
|
|
54
|
-
* floor: number,
|
|
55
|
-
* changedLineCount: number|null,
|
|
56
|
-
* sensitiveClasses: string[],
|
|
57
|
-
* }}
|
|
58
|
-
*/
|
|
59
|
-
export function evaluateLensDiffFloor(input = {}) {
|
|
60
|
-
const floorRaw = input.floor;
|
|
61
|
-
const floor =
|
|
62
|
-
typeof floorRaw === 'number' && Number.isInteger(floorRaw) && floorRaw >= 0
|
|
63
|
-
? floorRaw
|
|
64
|
-
: DEFAULT_LENS_DIFF_FLOOR;
|
|
65
|
-
const count =
|
|
66
|
-
typeof input.changedLineCount === 'number' &&
|
|
67
|
-
Number.isFinite(input.changedLineCount) &&
|
|
68
|
-
input.changedLineCount >= 0
|
|
69
|
-
? Math.floor(input.changedLineCount)
|
|
70
|
-
: null;
|
|
71
|
-
const verdict = (skip, reason, sensitiveClasses = []) => ({
|
|
72
|
-
skip,
|
|
73
|
-
reason,
|
|
74
|
-
floor,
|
|
75
|
-
changedLineCount: count,
|
|
76
|
-
sensitiveClasses,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
if (floor <= 0) return verdict(false, 'floor-disabled');
|
|
80
|
-
if (count === null) return verdict(false, 'line-count-unknown');
|
|
81
|
-
if (count >= floor) return verdict(false, 'at-or-above-floor');
|
|
82
|
-
|
|
83
|
-
const select =
|
|
84
|
-
input.selectSensitivePathClassesFn ?? selectSensitivePathClasses;
|
|
85
|
-
let classes;
|
|
86
|
-
try {
|
|
87
|
-
classes = select({
|
|
88
|
-
changedFiles: Array.isArray(input.changedFiles) ? input.changedFiles : [],
|
|
89
|
-
injectedRules: input.injectedRules,
|
|
90
|
-
});
|
|
91
|
-
} catch {
|
|
92
|
-
return verdict(false, 'sensitive-classes-unknown');
|
|
93
|
-
}
|
|
94
|
-
const matched = Array.isArray(classes) ? classes : [];
|
|
95
|
-
if (matched.length > 0) {
|
|
96
|
-
return verdict(false, 'sensitive-path-hit', matched);
|
|
97
|
-
}
|
|
98
|
-
return verdict(true, 'below-floor');
|
|
99
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `runAuditSuite`: owns the audit envelope shape (`metadata`, `findings`,
|
|
3
|
-
* `workflows`) and the per-audit fan-out; invoked via the barrel.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs/promises';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
|
|
9
|
-
import { ValidationError } from '../errors/index.js';
|
|
10
|
-
import { aggregateSummary } from './findings.js';
|
|
11
|
-
import { summarizeWorkflow } from './frontmatter.js';
|
|
12
|
-
import { applySubstitutions, computeAllowedKeys } from './substitutions.js';
|
|
13
|
-
import { defaultWriteArtifact, loadWorkflow } from './workflow-loader.js';
|
|
14
|
-
|
|
15
|
-
async function loadRules(paths) {
|
|
16
|
-
const rulesPath = path.join(
|
|
17
|
-
PROJECT_ROOT,
|
|
18
|
-
paths.schemasRoot,
|
|
19
|
-
'audit-rules.json',
|
|
20
|
-
);
|
|
21
|
-
const rulesContent = await fs.readFile(rulesPath, 'utf8');
|
|
22
|
-
return JSON.parse(rulesContent);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function rejectUnknownKeys(allowedKeys, callerSubstitutions) {
|
|
26
|
-
const unknownKeys = Object.keys(callerSubstitutions).filter(
|
|
27
|
-
(k) => !allowedKeys.has(k),
|
|
28
|
-
);
|
|
29
|
-
if (unknownKeys.length === 0) return;
|
|
30
|
-
const allowedList = [...allowedKeys].sort().join(', ');
|
|
31
|
-
throw new ValidationError(
|
|
32
|
-
`Unknown substitution key(s): ${unknownKeys.join(', ')}. Allowed for this call: ${allowedList}.`,
|
|
33
|
-
{ unknownKeys, allowedKeys: [...allowedKeys] },
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function emptyEnvelope(auditWorkflows) {
|
|
38
|
-
return {
|
|
39
|
-
metadata: {
|
|
40
|
-
timestamp: new Date().toISOString(),
|
|
41
|
-
auditsRequested: auditWorkflows,
|
|
42
|
-
auditsRun: [],
|
|
43
|
-
summary: { critical: 0, high: 0, medium: 0, low: 0 },
|
|
44
|
-
},
|
|
45
|
-
findings: [],
|
|
46
|
-
workflows: [],
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function notDefinedFinding(auditName) {
|
|
51
|
-
return {
|
|
52
|
-
error: true,
|
|
53
|
-
finding: {
|
|
54
|
-
audit: auditName,
|
|
55
|
-
severity: 'low',
|
|
56
|
-
message: `Requested audit workflow '${auditName}' is not defined in audit-rules.json.`,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function notFoundFinding(auditName) {
|
|
62
|
-
return {
|
|
63
|
-
error: true,
|
|
64
|
-
finding: {
|
|
65
|
-
audit: auditName,
|
|
66
|
-
severity: 'low',
|
|
67
|
-
message: `Audit workflow '${auditName}.md' not found in workflows directory.`,
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function processAudit({
|
|
73
|
-
auditName,
|
|
74
|
-
validAudits,
|
|
75
|
-
loader,
|
|
76
|
-
workflowsDir,
|
|
77
|
-
effectiveSubstitutions,
|
|
78
|
-
}) {
|
|
79
|
-
if (!validAudits.includes(auditName)) {
|
|
80
|
-
return notDefinedFinding(auditName);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const workflow = await loader(auditName, workflowsDir);
|
|
84
|
-
if (!workflow) {
|
|
85
|
-
return notFoundFinding(auditName);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const substituted = applySubstitutions(
|
|
89
|
-
workflow.content,
|
|
90
|
-
effectiveSubstitutions,
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
success: true,
|
|
95
|
-
auditName,
|
|
96
|
-
workflowPath: workflow.path ?? null,
|
|
97
|
-
workflowContent: substituted,
|
|
98
|
-
summary: summarizeWorkflow(workflow.content),
|
|
99
|
-
byteSize: Buffer.byteLength(substituted, 'utf8'),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function reduceResults({
|
|
104
|
-
results,
|
|
105
|
-
envelope,
|
|
106
|
-
artifactPrefix,
|
|
107
|
-
effectiveArtifactsDir,
|
|
108
|
-
writeArtifact,
|
|
109
|
-
}) {
|
|
110
|
-
for (const result of results) {
|
|
111
|
-
if (result.error) {
|
|
112
|
-
envelope.findings.push(result.finding);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (!result.success) continue;
|
|
116
|
-
|
|
117
|
-
envelope.metadata.auditsRun.push(result.auditName);
|
|
118
|
-
let artifactPath = null;
|
|
119
|
-
if (artifactPrefix) {
|
|
120
|
-
const fileName = `audit-${artifactPrefix}-${result.auditName}.md`;
|
|
121
|
-
artifactPath = await writeArtifact(
|
|
122
|
-
effectiveArtifactsDir,
|
|
123
|
-
fileName,
|
|
124
|
-
result.workflowContent,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
envelope.workflows.push({
|
|
128
|
-
audit: result.auditName,
|
|
129
|
-
path: result.workflowPath,
|
|
130
|
-
summary: result.summary,
|
|
131
|
-
byteSize: result.byteSize,
|
|
132
|
-
artifactPath,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Resolve registered audit workflows into slim descriptors; with
|
|
139
|
-
* `artifactPrefix`, full bodies are written to disk instead of travelling in
|
|
140
|
-
* the envelope. Substitution keys beyond the built-ins must be declared as
|
|
141
|
-
* `substitutionKeys` in audit-rules.json, else ValidationError.
|
|
142
|
-
*
|
|
143
|
-
* @param {object} opts
|
|
144
|
-
* @param {string[]} opts.auditWorkflows
|
|
145
|
-
* @param {Record<string,string>} [opts.substitutions]
|
|
146
|
-
* @param {string} [opts.artifactPrefix] - writes `audit-<prefix>-<audit>.md`.
|
|
147
|
-
* @param {string} [opts.artifactsDir] - defaults to `<auditOutputDir>`.
|
|
148
|
-
* @param {Function} [opts.injectedLoadWorkflow]
|
|
149
|
-
* @param {object} [opts.injectedRules]
|
|
150
|
-
* @param {Function} [opts.injectedWriteArtifact]
|
|
151
|
-
* @returns {Promise<object>} Aggregated audit results.
|
|
152
|
-
*/
|
|
153
|
-
export async function runAuditSuite({
|
|
154
|
-
auditWorkflows,
|
|
155
|
-
substitutions,
|
|
156
|
-
artifactPrefix,
|
|
157
|
-
artifactsDir,
|
|
158
|
-
injectedLoadWorkflow,
|
|
159
|
-
injectedRules,
|
|
160
|
-
injectedWriteArtifact,
|
|
161
|
-
}) {
|
|
162
|
-
const config = resolveConfig();
|
|
163
|
-
const paths = getPaths(config);
|
|
164
|
-
const callerSubstitutions = substitutions ?? {};
|
|
165
|
-
const rules = injectedRules ?? (await loadRules(paths));
|
|
166
|
-
|
|
167
|
-
const allowedKeys = computeAllowedKeys(rules, auditWorkflows);
|
|
168
|
-
rejectUnknownKeys(allowedKeys, callerSubstitutions);
|
|
169
|
-
|
|
170
|
-
const effectiveSubstitutions = {
|
|
171
|
-
auditOutputDir: paths.auditOutputDir,
|
|
172
|
-
...callerSubstitutions,
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const validAudits = Object.keys(rules.audits || {});
|
|
176
|
-
const envelope = emptyEnvelope(auditWorkflows);
|
|
177
|
-
const workflowsDir = path.join(PROJECT_ROOT, paths.workflowsRoot);
|
|
178
|
-
const effectiveArtifactsDir =
|
|
179
|
-
artifactsDir ?? path.join(PROJECT_ROOT, paths.auditOutputDir);
|
|
180
|
-
const writeArtifact = injectedWriteArtifact ?? defaultWriteArtifact;
|
|
181
|
-
const loader = injectedLoadWorkflow ?? loadWorkflow;
|
|
182
|
-
|
|
183
|
-
const results = await Promise.all(
|
|
184
|
-
auditWorkflows.map((auditName) =>
|
|
185
|
-
processAudit({
|
|
186
|
-
auditName,
|
|
187
|
-
validAudits,
|
|
188
|
-
loader,
|
|
189
|
-
workflowsDir,
|
|
190
|
-
effectiveSubstitutions,
|
|
191
|
-
}),
|
|
192
|
-
),
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
await reduceResults({
|
|
196
|
-
results,
|
|
197
|
-
envelope,
|
|
198
|
-
artifactPrefix,
|
|
199
|
-
effectiveArtifactsDir,
|
|
200
|
-
writeArtifact,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
envelope.metadata.summary = aggregateSummary(envelope.findings);
|
|
204
|
-
return envelope;
|
|
205
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `{{key}}` substitution for audit workflow bodies, plus the CLI glue and the
|
|
3
|
-
* per-run allowed-key set.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { ValidationError } from '../errors/index.js';
|
|
7
|
-
|
|
8
|
-
export const BUILT_IN_SUBSTITUTION_KEYS = Object.freeze([
|
|
9
|
-
'auditOutputDir',
|
|
10
|
-
'ticketId',
|
|
11
|
-
'baseBranch',
|
|
12
|
-
// Newline-joined change set; when unsubstituted, lens templates read the
|
|
13
|
-
// literal as "no scope filter".
|
|
14
|
-
'changedFiles',
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
function escapeRegExp(str) {
|
|
18
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Unrecognised placeholders are left intact; keys are validated upstream.
|
|
23
|
-
*
|
|
24
|
-
* @param {string} content
|
|
25
|
-
* @param {Record<string, string>} substitutions
|
|
26
|
-
* @returns {string}
|
|
27
|
-
*/
|
|
28
|
-
export function applySubstitutions(content, substitutions) {
|
|
29
|
-
let out = content;
|
|
30
|
-
for (const [key, value] of Object.entries(substitutions)) {
|
|
31
|
-
out = out.replace(
|
|
32
|
-
new RegExp(`\\{\\{${escapeRegExp(key)}\\}\\}`, 'g'),
|
|
33
|
-
value,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Throws {@link ValidationError} on a missing `=` or empty key.
|
|
41
|
-
*
|
|
42
|
-
* @param {string[]} [pairs]
|
|
43
|
-
* @returns {Record<string, string>}
|
|
44
|
-
*/
|
|
45
|
-
export function parseSubstitutionPairs(pairs = []) {
|
|
46
|
-
const out = {};
|
|
47
|
-
for (const entry of pairs) {
|
|
48
|
-
const eq = entry.indexOf('=');
|
|
49
|
-
if (eq <= 0) {
|
|
50
|
-
throw new ValidationError(
|
|
51
|
-
`Invalid --substitution "${entry}"; expected key=value.`,
|
|
52
|
-
{ entry },
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
const key = entry.slice(0, eq);
|
|
56
|
-
const value = entry.slice(eq + 1);
|
|
57
|
-
out[key] = value;
|
|
58
|
-
}
|
|
59
|
-
return out;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Fill `ticketId`/`baseBranch` from flags in place, unless an explicit
|
|
64
|
-
* `--substitution` already set them.
|
|
65
|
-
*
|
|
66
|
-
* @param {Record<string, unknown>} values
|
|
67
|
-
* @param {Record<string, string|undefined>} substitutions
|
|
68
|
-
*/
|
|
69
|
-
export function applyImplicitSubstitutions(values, substitutions) {
|
|
70
|
-
if (values.ticket && substitutions.ticketId === undefined) {
|
|
71
|
-
substitutions.ticketId = String(values.ticket);
|
|
72
|
-
}
|
|
73
|
-
if (values['base-branch'] && substitutions.baseBranch === undefined) {
|
|
74
|
-
substitutions.baseBranch = values['base-branch'];
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Built-ins plus each requested audit's declared keys; unregistered audits
|
|
80
|
-
* are rejected elsewhere.
|
|
81
|
-
*
|
|
82
|
-
* @param {{ audits?: Record<string, { substitutionKeys?: string[] }> }} rules
|
|
83
|
-
* @param {string[]} auditWorkflows
|
|
84
|
-
* @returns {Set<string>}
|
|
85
|
-
*/
|
|
86
|
-
export function computeAllowedKeys(rules, auditWorkflows) {
|
|
87
|
-
const allowed = new Set(BUILT_IN_SUBSTITUTION_KEYS);
|
|
88
|
-
for (const auditName of auditWorkflows) {
|
|
89
|
-
const entry = rules.audits?.[auditName];
|
|
90
|
-
if (!entry) continue;
|
|
91
|
-
for (const k of entry.substitutionKeys ?? []) {
|
|
92
|
-
allowed.add(k);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return allowed;
|
|
96
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem IO for audit workflows, kept out of the runner so tests can
|
|
3
|
-
* inject stubs.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs/promises';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* `null` when missing; the runner records a finding rather than failing.
|
|
11
|
-
*
|
|
12
|
-
* @param {string} auditName
|
|
13
|
-
* @param {string} workflowsDir absolute path to the workflows root
|
|
14
|
-
* @returns {Promise<{ path: string, content: string } | null>}
|
|
15
|
-
*/
|
|
16
|
-
export async function loadWorkflow(auditName, workflowsDir) {
|
|
17
|
-
const workflowPath = path.join(workflowsDir, `${auditName}.md`);
|
|
18
|
-
try {
|
|
19
|
-
const content = await fs.readFile(workflowPath, 'utf8');
|
|
20
|
-
return { path: workflowPath, content };
|
|
21
|
-
} catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} artifactsDir
|
|
28
|
-
* @param {string} fileName
|
|
29
|
-
* @param {string} content
|
|
30
|
-
* @returns {Promise<string>}
|
|
31
|
-
*/
|
|
32
|
-
export async function defaultWriteArtifact(artifactsDir, fileName, content) {
|
|
33
|
-
await fs.mkdir(artifactsDir, { recursive: true });
|
|
34
|
-
const fullPath = path.join(artifactsDir, fileName);
|
|
35
|
-
await fs.writeFile(fullPath, content, 'utf8');
|
|
36
|
-
return fullPath;
|
|
37
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* phases/local-lens-review.js — the Story-scope local-lens pass: selects the
|
|
3
|
-
* local lenses matching the Story diff and materializes their prompts. The
|
|
4
|
-
* review spine (`runStoryReviewCore`) owns diff enumeration and code review.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
evaluateLensDiffFloor,
|
|
9
|
-
runAuditSuite,
|
|
10
|
-
selectLocalLenses,
|
|
11
|
-
} from '../../../audit-suite/index.js';
|
|
12
|
-
import { gitSpawn } from '../../../git-utils.js';
|
|
13
|
-
import {
|
|
14
|
-
emitRuntimeFriction,
|
|
15
|
-
RUNTIME_FRICTION_CATEGORIES,
|
|
16
|
-
} from '../../../observability/runtime-friction.js';
|
|
17
|
-
import { computeChangeSet } from '../../change-set.js';
|
|
18
|
-
|
|
19
|
-
/** Fixed for this tier; not risk-scaled. */
|
|
20
|
-
const STORY_SCOPE_LENS_DEPTH = 'light';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Roster of materialized lens-prompt artifacts the host MUST walk — the
|
|
24
|
-
* default review provider never reads them, so without this they are inert.
|
|
25
|
-
*
|
|
26
|
-
* @param {object|null} materialized the `runAuditSuite` result envelope.
|
|
27
|
-
* @returns {string|null} the roster block, or `null` when there is nothing to walk.
|
|
28
|
-
*/
|
|
29
|
-
function renderLensArtifactRoster(materialized) {
|
|
30
|
-
const paths = (materialized?.workflows ?? [])
|
|
31
|
-
.map((w) => w?.artifactPath)
|
|
32
|
-
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
33
|
-
if (paths.length === 0) return null;
|
|
34
|
-
return [
|
|
35
|
-
`Lens prompts materialized (host MUST read/walk each against the Story diff):`,
|
|
36
|
-
...paths.map((p) => ` - ${p}`),
|
|
37
|
-
].join('\n');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Built-in substitution keys, so no per-lens declaration is needed.
|
|
42
|
-
*
|
|
43
|
-
* @param {{ changedFiles: string[], storyId?: number|string|null }} args
|
|
44
|
-
* @returns {Record<string, string>}
|
|
45
|
-
*/
|
|
46
|
-
function buildLensSubstitutions({ changedFiles, storyId }) {
|
|
47
|
-
const substitutions = { changedFiles: changedFiles.join('\n') };
|
|
48
|
-
if (storyId != null && `${storyId}`.length > 0) {
|
|
49
|
-
substitutions.ticketId = String(storyId);
|
|
50
|
-
}
|
|
51
|
-
return substitutions;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Never throws: an unknown diff returns `[]`, which matches no lens, same as
|
|
56
|
-
* an empty one.
|
|
57
|
-
*
|
|
58
|
-
* @param {{
|
|
59
|
-
* baseRef: string,
|
|
60
|
-
* headRef: string,
|
|
61
|
-
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
62
|
-
* }} args
|
|
63
|
-
* @returns {string[]} Changed file paths, or `[]` on any failure.
|
|
64
|
-
*/
|
|
65
|
-
function enumerateChangedFiles({ baseRef, headRef, gitSpawnFn = gitSpawn }) {
|
|
66
|
-
return computeChangeSet({ baseRef, headRef, gitSpawnFn }).files ?? [];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Three-state injection: an array is used verbatim; `null` means the spine
|
|
71
|
-
* already failed to enumerate, so don't re-spawn git; `undefined` means
|
|
72
|
-
* nobody enumerated, so self-enumerate.
|
|
73
|
-
*
|
|
74
|
-
* @param {{
|
|
75
|
-
* changedFiles: string[]|null|undefined,
|
|
76
|
-
* baseRef: string,
|
|
77
|
-
* headRef: string,
|
|
78
|
-
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
79
|
-
* }} args
|
|
80
|
-
* @returns {string[]}
|
|
81
|
-
*/
|
|
82
|
-
function resolveLensChangeSet({
|
|
83
|
-
changedFiles,
|
|
84
|
-
baseRef,
|
|
85
|
-
headRef,
|
|
86
|
-
gitSpawnFn = gitSpawn,
|
|
87
|
-
}) {
|
|
88
|
-
if (changedFiles === undefined) {
|
|
89
|
-
return enumerateChangedFiles({ baseRef, headRef, gitSpawnFn });
|
|
90
|
-
}
|
|
91
|
-
return changedFiles ?? [];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Run the maker-blind Story-scope lens pass (called only from
|
|
96
|
-
* `runStoryReviewCore`). No matching lens → no lens work. A diff below the
|
|
97
|
-
* lens diff-floor with no sensitive-path hits records the roster but skips
|
|
98
|
-
* materialization; an unknown line count fails open. Advisory: any failure
|
|
99
|
-
* degrades to a skipped envelope plus a friction signal.
|
|
100
|
-
*
|
|
101
|
-
* @param {{
|
|
102
|
-
* baseRef: string,
|
|
103
|
-
* headRef: string,
|
|
104
|
-
* changedFiles?: string[]|null,
|
|
105
|
-
* changedLineCount?: number|null,
|
|
106
|
-
* lensDiffFloor?: number,
|
|
107
|
-
* storyId?: number|string|null,
|
|
108
|
-
* artifactPrefix?: string,
|
|
109
|
-
* progress: (tag: string, msg: string) => void,
|
|
110
|
-
* progressTag?: string,
|
|
111
|
-
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
112
|
-
* selectLocalLensesFn?: typeof selectLocalLenses,
|
|
113
|
-
* runAuditSuiteFn?: typeof runAuditSuite,
|
|
114
|
-
* evaluateLensDiffFloorFn?: typeof evaluateLensDiffFloor,
|
|
115
|
-
* emitToolDegradationFn?: typeof emitRuntimeFriction,
|
|
116
|
-
* }} args
|
|
117
|
-
* @returns {Promise<{
|
|
118
|
-
* depth: 'light',
|
|
119
|
-
* lenses: string[],
|
|
120
|
-
* skipped: boolean,
|
|
121
|
-
* floorSkip: object|null,
|
|
122
|
-
* materialized: object|null,
|
|
123
|
-
* artifactPaths: string[],
|
|
124
|
-
* }>}
|
|
125
|
-
*/
|
|
126
|
-
export async function runLocalLensReview({
|
|
127
|
-
baseRef,
|
|
128
|
-
headRef,
|
|
129
|
-
changedFiles: injectedChangedFiles,
|
|
130
|
-
changedLineCount = null,
|
|
131
|
-
lensDiffFloor,
|
|
132
|
-
storyId,
|
|
133
|
-
artifactPrefix,
|
|
134
|
-
progress,
|
|
135
|
-
progressTag = 'CODE-REVIEW',
|
|
136
|
-
gitSpawnFn = gitSpawn,
|
|
137
|
-
selectLocalLensesFn = selectLocalLenses,
|
|
138
|
-
runAuditSuiteFn = runAuditSuite,
|
|
139
|
-
evaluateLensDiffFloorFn = evaluateLensDiffFloor,
|
|
140
|
-
emitToolDegradationFn = emitRuntimeFriction,
|
|
141
|
-
}) {
|
|
142
|
-
const empty = {
|
|
143
|
-
depth: STORY_SCOPE_LENS_DEPTH,
|
|
144
|
-
lenses: [],
|
|
145
|
-
skipped: true,
|
|
146
|
-
floorSkip: null,
|
|
147
|
-
materialized: null,
|
|
148
|
-
artifactPaths: [],
|
|
149
|
-
};
|
|
150
|
-
try {
|
|
151
|
-
const changedFiles = resolveLensChangeSet({
|
|
152
|
-
changedFiles: injectedChangedFiles,
|
|
153
|
-
baseRef,
|
|
154
|
-
headRef,
|
|
155
|
-
gitSpawnFn,
|
|
156
|
-
});
|
|
157
|
-
const lenses = selectLocalLensesFn({ changedFiles });
|
|
158
|
-
if (lenses.length === 0) {
|
|
159
|
-
progress(
|
|
160
|
-
progressTag,
|
|
161
|
-
'No local lens matched the Story diff — skipping the lens pass.',
|
|
162
|
-
);
|
|
163
|
-
return empty;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// After selection so a floor-skip still records which lenses it skipped.
|
|
167
|
-
const floorVerdict = evaluateLensDiffFloorFn({
|
|
168
|
-
changedFiles,
|
|
169
|
-
changedLineCount,
|
|
170
|
-
floor: lensDiffFloor,
|
|
171
|
-
});
|
|
172
|
-
if (floorVerdict.skip) {
|
|
173
|
-
progress(
|
|
174
|
-
progressTag,
|
|
175
|
-
`Lens diff-floor: ${floorVerdict.changedLineCount} changed line(s) < ` +
|
|
176
|
-
`floor ${floorVerdict.floor} with zero sensitive-path hits — ` +
|
|
177
|
-
`skipping materialization of ${lenses.join(', ')}.`,
|
|
178
|
-
);
|
|
179
|
-
return {
|
|
180
|
-
depth: STORY_SCOPE_LENS_DEPTH,
|
|
181
|
-
lenses,
|
|
182
|
-
skipped: true,
|
|
183
|
-
floorSkip: floorVerdict,
|
|
184
|
-
materialized: null,
|
|
185
|
-
artifactPaths: [],
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
// Story-scoped names so concurrent closes can't clobber each other.
|
|
189
|
-
const effectivePrefix =
|
|
190
|
-
artifactPrefix ?? (storyId != null ? `story-${storyId}` : 'story-scope');
|
|
191
|
-
const materialized = await runAuditSuiteFn({
|
|
192
|
-
auditWorkflows: lenses,
|
|
193
|
-
substitutions: buildLensSubstitutions({ changedFiles, storyId }),
|
|
194
|
-
artifactPrefix: effectivePrefix,
|
|
195
|
-
});
|
|
196
|
-
progress(
|
|
197
|
-
progressTag,
|
|
198
|
-
`Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
|
|
199
|
-
);
|
|
200
|
-
const roster = renderLensArtifactRoster(materialized);
|
|
201
|
-
if (roster) progress(progressTag, roster);
|
|
202
|
-
const artifactPaths = (materialized?.workflows ?? [])
|
|
203
|
-
.map((w) => w?.artifactPath)
|
|
204
|
-
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
205
|
-
return {
|
|
206
|
-
depth: STORY_SCOPE_LENS_DEPTH,
|
|
207
|
-
lenses,
|
|
208
|
-
skipped: false,
|
|
209
|
-
floorSkip: floorVerdict,
|
|
210
|
-
materialized,
|
|
211
|
-
artifactPaths,
|
|
212
|
-
};
|
|
213
|
-
} catch (err) {
|
|
214
|
-
// Degradations are operational signals (friction), not findings.
|
|
215
|
-
progress(
|
|
216
|
-
progressTag,
|
|
217
|
-
`⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
|
|
218
|
-
);
|
|
219
|
-
try {
|
|
220
|
-
await emitToolDegradationFn({
|
|
221
|
-
storyId,
|
|
222
|
-
category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
|
|
223
|
-
tool: 'local-lens-review',
|
|
224
|
-
details: {
|
|
225
|
-
surface: 'lens-materialization',
|
|
226
|
-
reason: String(err?.message ?? err).slice(0, 500),
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
} catch {
|
|
230
|
-
// Observability must never fail the close.
|
|
231
|
-
}
|
|
232
|
-
return empty;
|
|
233
|
-
}
|
|
234
|
-
}
|